diff --git a/.gitignore b/.gitignore index 3ecc9dd..c258ce4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.py[codz] *$py.class +# dstrack custom files +.dstrack/ + # C extensions *.so diff --git a/README.md b/README.md index 4f59b75..7845c73 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![codecov](https://codecov.io/github/leoyala/dstrack/graph/badge.svg?token=DEKCE4LR88)](https://codecov.io/github/leoyala/dstrack)

- dstrack logo + dstrack logo

@@ -14,6 +14,12 @@ A Python package for versioning and monitoring dataset changes throughout the ma CLI example

+ +
+PDF Diff report +
Exemplary PDF report (1st page only)
+
+ ## Overview `dstrack` helps data scientists and ML engineers track how datasets evolve over time, catching schema drift, distribution shifts, and unexpected mutations before they silently break pipelines or degrade model performance. @@ -84,15 +90,37 @@ Each snapshot captures the file's schema, a content fingerprint, and per-column statistics. See the [Getting Started guide](https://leoyala.github.io/dstrack/getting_started/) for options such as `--name`, `--reader`, and `--dataset-id`. +**4. See what changed** - `dstrack log` shows *that* a dataset changed; +`dstrack diff` shows *what* changed: + +```bash +dstrack diff data.csv +``` + +The two snapshots are shown side by side, with the schema changes, the dataset's own +numbers, and every column whose statistics moved, each colored by how much it should +worry you. Add `--json` for a pipeline to read, or `--pdf report.pdf` for a formatted +report to share (needs the `report` extra). + +It is a library too, so a comparison can be done entirely from Python: + +```python +from dstrack.diff import diff_snapshots + +result = diff_snapshots(old_payload, new_payload) +print(result.severity, result.summary) +``` + ## Features - **Dataset versioning** - snapshot a dataset and track its lineage across pipeline stages - **Rich snapshots** - schema hash, content fingerprint, and per-column statistics (ranges, null rates, and more) +- **Snapshot comparison** - schema, statistics and distribution-shift diffs, in the terminal, as JSON, or as a PDF report - **CSV out of the box** - pure standard-library reader, no heavy dependencies - **Lightweight CLI** - a small, git-like local store you can commit alongside your code ## Roadmap -Change detection and drift monitoring are on the way - comparing snapshots, surfacing -schema and distribution shifts, and failing CI on drift. See the -[roadmap](docs/roadmap.md) for what's planned. +Drift monitoring in CI is next - comparing a working file against its latest snapshot +and failing the build when data drifts. See the [roadmap](docs/roadmap.md) for what's +planned. diff --git a/docs/API/diff.md b/docs/API/diff.md new file mode 100644 index 0000000..63df517 --- /dev/null +++ b/docs/API/diff.md @@ -0,0 +1,5 @@ +--- +icon: lucide/git-compare +--- + +::: dstrack.diff diff --git a/docs/assets/diff-report.png b/docs/assets/diff-report.png new file mode 100644 index 0000000..8458f1a Binary files /dev/null and b/docs/assets/diff-report.png differ diff --git a/docs/decisions/0004-diff-semantics-and-severity.md b/docs/decisions/0004-diff-semantics-and-severity.md new file mode 100644 index 0000000..acb86bf --- /dev/null +++ b/docs/decisions/0004-diff-semantics-and-severity.md @@ -0,0 +1,185 @@ +--- +icon: lucide/git-compare +--- + +# ADR-0004: Diff semantics and severity classification + +## Status +:lucide-circle-check: Accepted + +## Context +[ADR-0001](0001-dataset-snapshot-content.md) defines what a snapshot contains and +[ADR-0003](0003-local-snapshot-store-layout.md) where snapshots live. Neither says what +it means for two of them to *differ*, which is the whole of the roadmap's "Diff Engine" +milestone and the question `dstrack diff` exists to answer. + +Comparing two snapshots is not one problem but three, and they cannot be answered the +same way: + +1. **Structural.** Is a column still there, and does it still hold the same type? This + is exact: nothing is inferred and nothing is thresholded. +2. **Scalar.** A number moved - is that worth telling anyone? Every answer here is a + judgement against a threshold, and the right threshold depends on what kind of + number it is. +3. **Distributional.** Do the values still fall where they used to? No single scalar + answers this, and what *can* be asked depends on which statistics the snapshot + happened to record. + +These choices are expensive to revisit once users depend on them. A threshold that +changes silently makes yesterday's clean run today's alarm, and a JSON key that changes +meaning breaks every pipeline reading it. + +## Decision + +### A single severity vocabulary +Every comparison, at every level, is classified as one of `unchanged`, `info`, +`warning`, or `critical`. Groups fold upward: a column is as serious as its most serious +measurement, and the diff is as serious as its most serious column. One vocabulary means +the terminal, the JSON and the PDF all rank things identically, and a caller can filter +on severity without knowing what produced it. + +Structural severities are fixed rather than thresholded, because their consequences are: + +| Change | Severity | Why | +|---|---|---| +| Column removed | `critical` | Anything reading it by name breaks outright. | +| Column type changed | `critical` | Readers break, and the old statistics stop being comparable. | +| Column added | `warning` | Notable, but a consumer that ignores it keeps working. | +| Nullability changed | `info` | A contract change with no immediate breakage. | +| Joined `constant_columns` / `high_null_columns` | `warning` | A feature just went dead or mostly-null. | +| Left either set | `info` | Good news, reported without raising the alarm. | + +### Relative thresholds for counts, absolute for fractions +A count is judged by its relative change; a quantity already expressed as a fraction of +one is judged by how far it moved in absolute terms. + +This is not a stylistic split. A null fraction going from 0.001 to 0.002 has doubled and +means nothing, and grading it relatively would fire an alarm on two rows in a million. +Conversely a row count going from 1,000 to 1,200 is a 20% change whatever the absolute +figure, and grading it absolutely would need a different threshold for every dataset. + +A value moving from exactly zero to something has no defined relative scale, and is +classified `warning`: structurally notable, ungradeable. + +Defaults: 10% warns and 50% is critical for relative change; 0.02 and 0.10 absolute for +fractions. Three named presets - `low`, `normal`, `high` - move all thresholds together, +and are the only sensitivity control the CLI exposes. Individual thresholds are available +to the Python API through `DiffOptions`; a dozen float flags would be an unusable +command line. + +### Distribution shift: PSI, with a percentile fallback +For numeric columns the measure is the **population stability index** over the recorded +histograms, using the industry-standard bands (0.10 warns, 0.25 is critical). + +The two histograms almost never share bin edges, because each snapshot derives its edges +from its own minimum and maximum. Counts cannot be re-binned exactly, so each source +bin's count is spread across a shared grid **in proportion to overlap length** - assuming +uniform density within a bin. This is an approximation, and it can distort the score for +a heavy-tailed column summarized in twenty equal-width bins. It is accepted because it +needs no dependency, because the alternative of refusing to compare unaligned histograms +would refuse almost every real comparison, and because the method is always reported +alongside the score. + +Zero counts are smoothed by crediting every bucket **half an observation** (the Jeffreys +prior) rather than by a fixed floor. A fixed floor treats an empty bucket in a four-row +snapshot as being as certainly empty as one in a four-million-row snapshot, and reports +the first as near-total drift; half an observation is a large share of a tiny sample and +a negligible share of a large one, which is exactly how confident the emptiness is. + +When a histogram cannot describe a shape at all - too few bins, or the single degenerate +bin a constant column produces - the fallback is the largest percentile move, scaled by +the baseline's p5-p95 spread. The result carries `ShiftMethod.PERCENTILE_SHIFT` so the +score is never read on the PSI scale. + +### Distribution shift for text: total variation distance +Text columns record only their top values, so the distance is computed over the union of +both sides' top values **plus a synthetic bucket carrying the untracked tail** +(`1 - top_values_coverage`). Without that bucket, a column whose top values held steady +while everything beneath them churned would score zero. + +### No rename inference +A renamed column is reported as one removal and one addition. + +Snapshots carry no row-level identity, so the only available heuristic is "same dtype and +similar statistics" - which cannot distinguish a rename from a genuine drop-and-add of +two similar features. The cost of the two errors is not symmetric: reporting a rename as +a drop-and-add is verbose but true, while reporting a drop-and-add as a rename silently +corrupts the per-column drift table, which is the part users act on. `renamed` remains +available as a purely additive key should snapshots ever carry column identity. + +### Reference resolution: id, then path, then prefix +`dstrack diff` reads each argument as a full id first, a tracked path second, and a short +id prefix last. + +The id-before-path precedence is inherited from `dstrack log` and exists for the same +reason: reading a path first would let any file in the current directory shadow a real +id. The prefix comes *after* the path so a file whose name happens to be eight hex +characters keeps the meaning `log` already gives it, and *before* the final failure so a +mistyped path is reported as a path rather than as an unknown id. + +Naming one side leaves the other implied - a snapshot alone is compared against the one +it succeeded - because that is the question most often asked. + +### Comparing two datasets is legal +Two snapshots from different datasets compare normally. The result carries +`same_dataset: false` and every renderer says so, but nothing is refused: comparing a +staging table against a production one is a real use, and the engine cannot know which +comparisons are meaningful. + +### Snapshot format versions: refuse only what cannot be read +Each side's `format_version` is checked against the set of versions this dstrack can +read (`SUPPORTED_FORMAT_VERSIONS`), not against the one version it writes. Only a +version outside that set is refused, which in practice means a snapshot written by a +newer dstrack: guessing at a shape the engine has never seen would produce a diff that +looks authoritative and is not. A payload recording no version is compared as far as its +fields allow, like any other hand-built dict. + +Matching the written version exactly would be simpler and wrong. Snapshots stay in a +store indefinitely, so the first format bump would make every existing snapshot +incomparable - with each other and with the new ones - and the baseline-versus-latest +comparison users most want after an upgrade would be the one refused. A format bump +therefore **adds** to the supported set rather than replacing its member, and comes with +whatever lets the engine read the older shape, whether that is the extractors reading +both or an upgrade step applied to old payloads before comparison. Two snapshots in +different supported versions compare normally. + +This is a different version from the one below. `format_version` describes the +snapshot the engine reads; `diff_format_version` describes the result it writes. The +two move independently, and only the first ever causes a refusal. + +### `diff_format_version` and an additive-only promise +The JSON output carries `diff_format_version`, currently `"1"`. Within a version, keys +may be **added** but are never renamed, removed, or given a new meaning. Consumers are +expected to ignore keys they do not recognize. + +### Tolerating what a payload does not carry +Every field is read defensively; a missing or malformed one degrades to "not recorded" +rather than raising. A metric neither side recorded is omitted from the result entirely. + +This is what lets the engine accept dictionaries a caller built by hand, and it is also +why the diff does **not** require `near_duplicate_estimate`, `row_minhash` or +`row_hyperloglog`. Those are listed as required in `snapshot_v1.json` but no current +builder produces them; the gap is pre-existing and out of scope here. Nobody should +"fix" the diff engine to demand them. + +Which statistics variant a column holds is inferred from **the keys actually present**, +falling back to the declared dtype. Where the two disagree the keys win: the statistics +are what is about to be compared arithmetically, and trusting a dtype that says +`float64` over an entry carrying `top_values` would read a dictionary as a number. + +## Consequences + +- The severity vocabulary and the default thresholds are now a compatibility surface. + Changing a default changes what an unchanged pipeline reports, and is a breaking + change in everything but name. +- PSI scores are comparable across dstrack versions only as long as the re-binning and + smoothing are unchanged. Both are recorded here for that reason. +- Every snapshot format bump has to keep the engine able to read the previous format. + Dropping a version from `SUPPORTED_FORMAT_VERSIONS` strands every stored snapshot + written in it, so a version can only leave the set once migration tooling can rewrite + those snapshots. +- A rename shows up as two changes and will look noisier than users expect. This is a + deliberate trade and should be documented wherever the diff output is explained. +- The diff engine depends on nothing beyond the standard library, so it stays importable + anywhere `dstrack` is. Only the PDF report needs the optional `report` extra, and it is + imported lazily so its absence cannot break the command. diff --git a/docs/getting_started.md b/docs/getting_started.md index 5f39ffc..893c23a 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -161,6 +161,121 @@ dstrack log Useful flags: `-n 5` to show only the latest few, `--oneline` to condense each snapshot to a single line, and `--reverse` to read oldest-first. +## 5. Compare two snapshots + +`dstrack log` says *that* a dataset changed. `dstrack diff` says *what* changed. Point it +at the dataset and it compares the latest snapshot against the one before it: + +```bash +dstrack diff data.csv +``` + +The output puts the two snapshots side by side and colors each difference by how much it +should worry you: the schema changes first, then the dataset's own numbers, then every +column whose statistics moved, with a distribution-shift score for each. + +Any of these name a side, so you can compare whichever two you mean: + +```bash +dstrack diff a587afd9 0c82297f # two snapshots, by the short ids `log` shows +dstrack diff --from 0c82297f # that snapshot against where the dataset stands now +dstrack diff customers.csv orders.csv # the latest snapshot of two datasets +``` + +Useful flags: `--oneline` for a single line, `--all` to include what did not change, and +`--sensitivity low|normal|high` to move the thresholds that decide what counts as a +significant shift. + +For a pipeline, `--json` prints the whole comparison as structured data instead of a +table: + +```bash +dstrack diff data.csv --json --yes > diff.json +``` + +### A shareable report + +With the optional `report` extra installed, `--pdf` also writes a formatted report of the +same comparison - the kind of thing to attach to a review: + +```bash +pip install 'dstrack[report]' +dstrack diff data.csv --pdf diff-report.pdf +``` + +### From Python + +The comparison is a library function, not just a command. It takes two snapshot payloads +as plain dictionaries, so it works on snapshots read out of a store and on ones you built +yourself: + +```python +from dstrack.diff import diff_snapshots +from dstrack.paths import resolve_store_root +from dstrack.store import read_snapshot + +store_root = resolve_store_root() +old = read_snapshot("", "", store_root=store_root) +new = read_snapshot("", "", store_root=store_root) + +result = diff_snapshots(old, new) +print(result.severity, result.summary) +for column in result.columns: + if column.distribution_shift is not None: + print(column.name, column.distribution_shift.score) +``` + +??? example "Exemplary bash diff output" + ```bash title="Command lind output" + ✔ Wrote the comparison report to report.pdf + ╭────────────────── dstrack diff ───────────────────╮ + │ before after │ + │ snapshot 0d86445c 6f6577b3 │ + │ name sample sample │ + │ path data/sample.csv data/sample.csv │ + │ taken 1 minute ago 55 seconds ago │ + │ by menjivar menjivar │ + │ rows 50 54 │ + │ columns 7 8 │ + │ source 2dfa4d9c36ae… 5887d2bc5a31… changed │ + ╰─────────────────── CRITICAL ────────────────────╯ + + Schema + column before after + ──────────────────────────────────────────────── + + country — string not null + ~ age int64 not null int64 null + + Dataset + • num rows 50 54 +4 (+8.0%) + ⚠ num columns 7 8 +1 (+14.3%) + + Columns + Numeric columns + column nulls mean std p50 min max uniq shift + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⚠ id 0 25.5 → 28.11 14.43 → 16.31 25.5 → 27.5 1 50 → 56 50 → 54 PSI 0.23 + ⚠ age 0 → 0.09259 44.14 → 45.37 18.31 → 18.94 37.5 → 39 18 75 33 → 31 PSI 0.15 + ❗ balance 0 1,235 → 2,213 684.5 → 1,180 1,226 → 2,192 97.28 → 170.2 2,403 → 4,202 50 → 54 PSI 1.09 + • is_active 0 0.5 0.5 0.5 0 1 2 PSI 0.00 + Text columns + column nulls uniq coverage avg len shift + ────────────────────────────────────────────────────────────────────────────────────────────────────────────── + • name 0 28 1 13.88 → 13.93 TVD 0.10 +Margaret Hopper -Katherine Torvalds + ⚠ plan 0 3 1 5.9 → 7 TVD 0.18 + • country (new) — → 0 — → 5 — → 1 — → 2 — + + +4 rows +1 columns 4 drifted + ``` + +??? example "Exemplary PDF diff report" +
+ ![Diff PDF report](assets/diff-report.png) +
Exemplary PDF report (1st page only)
+
+ +See the [diff API reference](API/diff.md) for the full result type. + ## Where snapshots live Each snapshot is a JSON file under its dataset's directory: @@ -194,6 +309,6 @@ This command creates a synthetic file in a temporary location to measure perform ## What's next -Change detection and drift monitoring - comparing snapshots to surface schema and -distribution shifts, and failing CI when data drifts - are planned. See the -[roadmap](roadmap.md) for the full picture. +Comparing a working file against its latest snapshot without taking a new one +(`dstrack status`), and failing CI when data drifts (`--fail-on-drift`), are planned. See +the [roadmap](roadmap.md) for the full picture. diff --git a/docs/index.md b/docs/index.md index bbe6e21..ac04ef5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,12 @@ DVC and use `dstrack` alongside it.
+!!! example "Exemplary PDF diff report" +
+ ![Diff PDF report](assets/diff-report.png) +
Exemplary PDF report (1st page only)
+
+ ## Installation ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index 129b768..b6bf184 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,11 +31,12 @@ Foundation for all future capabilities: reading a dataset and capturing an immut Compare two snapshots and surface what changed between them. -- [ ] Schema diff (added, removed, and type-changed columns) -- [ ] Statistics diff (value range and distribution shifts) -- [ ] Basic drift indicators -- [ ] `dstrack diff` command -- [ ] Structured JSON output for machine consumption +- [x] Schema diff (added, removed, and type-changed columns) +- [x] Statistics diff (value range and distribution shifts) +- [x] Basic drift indicators +- [x] `dstrack diff` command +- [x] Structured JSON output for machine consumption +- [x] PDF comparison report (optional `report` extra) ## v0.3 - CLI Polish diff --git a/pyproject.toml b/pyproject.toml index 36c6d07..baea3a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,11 @@ dependencies = [ "typer>=0.26.8", ] +[project.optional-dependencies] +# PDF reporting only. Kept out of the base install so the core stays as +# dependency-light as it is: most runs never write a report. +report = ["reportlab>=4.2"] + [project.urls] GitHub = "https://github.com/leoyala/dstrack" @@ -93,10 +98,18 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] module = [ "numpy.*", "pandas.*", + "reportlab.*", "sklearn.*", ] ignore_missing_imports = true +# ----------------------------------------------------------------------------- +# coverage +# ----------------------------------------------------------------------------- +[tool.coverage.run] +# Manual helper for trying out `dstrack diff`; deliberately untested. +omit = ["sample_data.py"] + # ----------------------------------------------------------------------------- # codespell: spell-checking for code, docstrings, and docs # ----------------------------------------------------------------------------- diff --git a/sample_data.py b/sample_data.py new file mode 100644 index 0000000..77fd764 --- /dev/null +++ b/sample_data.py @@ -0,0 +1,155 @@ +"""Generate and mutate a small CSV for trying out `dstrack diff` by hand. + +A development helper, not part of the package: it is not tested and is left +out of coverage. + + uv run sample_data.py create # write data/sample.csv + dstrack track data/sample.csv + uv run sample_data.py modify # apply a handful of changes + dstrack track data/sample.csv + dstrack diff data/sample.csv +""" + +import csv +import random +from pathlib import Path +from typing import Annotated + +import typer + +app = typer.Typer(add_completion=False, no_args_is_help=True) + +DATA_DIR = Path("data") +DEFAULT_FILE = "sample.csv" + +FIRST_NAMES = ["Ada", "Alan", "Grace", "Katherine", "Linus", "Margaret", "Dennis"] +LAST_NAMES = ["Lovelace", "Turing", "Hopper", "Johnson", "Torvalds", "Hamilton"] +PLANS = ["free", "pro", "enterprise"] +COUNTRIES = ["NL", "DE", "US", "SV", "JP"] + +FileOption = Annotated[ + str, typer.Option("--file", "-f", help="File name inside the data/ folder.") +] +SeedOption = Annotated[ + int | None, typer.Option(help="Random seed, for a reproducible result.") +] + + +def _path(file: str) -> Path: + DATA_DIR.mkdir(exist_ok=True) + return DATA_DIR / file + + +def _row(rng: random.Random, row_id: int) -> dict[str, str]: + return { + "id": str(row_id), + "name": f"{rng.choice(FIRST_NAMES)} {rng.choice(LAST_NAMES)}", + "age": str(rng.randint(18, 75)), + "balance": f"{rng.uniform(0, 2500):.2f}", + "signup_date": f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}", + "is_active": rng.choice(["true", "false"]), + "plan": rng.choice(PLANS), + } + + +def _read(path: Path) -> tuple[list[str], list[dict[str, str]]]: + with path.open(newline="") as fh: + reader = csv.DictReader(fh) + return list(reader.fieldnames or []), list(reader) + + +def _write(path: Path, fields: list[str], rows: list[dict[str, str]]) -> None: + with path.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +@app.command() +def create( + file: FileOption = DEFAULT_FILE, + rows: Annotated[int, typer.Option(help="Number of rows to generate.")] = 50, + seed: SeedOption = 0, +) -> None: + """Write a fresh sample CSV, overwriting any previous one.""" + rng = random.Random(seed) + data = [_row(rng, i) for i in range(1, rows + 1)] + path = _path(file) + _write(path, list(data[0]), data) + typer.echo(f"Wrote {len(data)} rows to {path}") + + +@app.command() +def modify( + file: FileOption = DEFAULT_FILE, + seed: SeedOption = None, + schema: Annotated[ + bool, typer.Option(help="Also change the schema (add/drop a column).") + ] = True, +) -> None: + """Apply a few simple changes to the sample CSV, in place.""" + path = _path(file) + if not path.exists(): + typer.echo(f"{path} does not exist; run `create` first.", err=True) + raise typer.Exit(1) + + rng = random.Random(seed) + fields, data = _read(path) + changes: list[str] = [] + + # Drop a few rows. + dropped = rng.sample(range(len(data)), k=min(3, len(data))) + data = [row for i, row in enumerate(data) if i not in dropped] + changes.append(f"dropped {len(dropped)} rows") + + # Append new rows, continuing the id sequence. + next_id = max((int(row["id"]) for row in data if row.get("id")), default=0) + 1 + added = rng.randint(3, 8) + for i in range(added): + new = _row(rng, next_id + i) + data.append({field: new.get(field, "") for field in fields}) + changes.append(f"added {added} rows") + + # Shift the balance distribution upwards. + if "balance" in fields: + factor = rng.uniform(1.2, 1.8) + for row in data: + if row["balance"]: + row["balance"] = f"{float(row['balance']) * factor:.2f}" + changes.append(f"scaled balance by {factor:.2f}") + + # Blank out some ages to raise the null rate. + if "age" in fields: + nulled = rng.sample(range(len(data)), k=max(1, len(data) // 10)) + for i in nulled: + data[i]["age"] = "" + changes.append(f"nulled {len(nulled)} ages") + + # Move part of the user base onto a single plan. + if "plan" in fields: + for row in data: + if rng.random() < 0.3: + row["plan"] = "enterprise" + changes.append("moved ~30% of rows to the enterprise plan") + + # Schema: add a country column the first time, drop it the next. + if schema: + if "country" in fields: + fields.remove("country") + for row in data: + row.pop("country", None) + changes.append("dropped column country") + else: + fields.append("country") + for row in data: + row["country"] = rng.choice(COUNTRIES) + changes.append("added column country") + + _write(path, fields, data) + typer.echo(f"Modified {path}:") + for change in changes: + typer.echo(f" - {change}") + + +if __name__ == "__main__": + app() diff --git a/src/dstrack/_cli.py b/src/dstrack/_cli.py index 02af29b..500256d 100644 --- a/src/dstrack/_cli.py +++ b/src/dstrack/_cli.py @@ -6,6 +6,7 @@ import typer from dstrack import __version__, console +from dstrack._diff import diff from dstrack._log import log from dstrack._store_template import STORE_TEMPLATE, materialize from dstrack._track import track @@ -95,6 +96,13 @@ def init( "or its path." )(log) +app.command( + help="Compare two snapshots and show what changed between them, side by " + "side. Each argument may be a snapshot id, a dataset id, or the path to a " + "tracked dataset; given one, it is compared against the snapshot it " + "succeeded." +)(diff) + @app.command(help="Print package version.") def version() -> None: diff --git a/src/dstrack/_diff.py b/src/dstrack/_diff.py new file mode 100644 index 0000000..9208288 --- /dev/null +++ b/src/dstrack/_diff.py @@ -0,0 +1,234 @@ +"""The ``dstrack diff`` command: compare two snapshots of a dataset.""" + +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated + +import typer + +from dstrack import cache, console +from dstrack._diff_render import render_diff +from dstrack._resolve import ensure_index, resolve_pair +from dstrack.diff import PRESETS, DiffOptions, SnapshotDiff, diff_snapshots, preset +from dstrack.errors import ( + AmbiguousSnapshotError, + DatasetNotFoundError, + IncompatibleSnapshotError, + MissingDependencyError, + SnapshotNotFoundError, + StoreCorruptionError, + StoreNotFoundError, +) +from dstrack.paths import resolve_store_root + +# Failures that mean "what you asked for is not in the store, or is not +# readable", all of which are reported the same way: a message, and exit 1. +_USER_ERRORS = ( + AmbiguousSnapshotError, + DatasetNotFoundError, + IncompatibleSnapshotError, + SnapshotNotFoundError, + StoreCorruptionError, + ValueError, + OSError, + sqlite3.DatabaseError, +) + + +def diff( + refs: Annotated[ + list[str] | None, + typer.Argument( + help="Snapshots to compare: a snapshot id (in full or by its " + "leading characters), a dataset id, or the path to a tracked " + "dataset file. Given one, it is compared against the snapshot it " + "succeeded; given two, the first is the baseline.", + ), + ] = None, + from_ref: Annotated[ + str | None, + typer.Option( + "--from", + help="Baseline to compare against. Alone, the dataset's latest " + "snapshot is compared to it.", + ), + ] = None, + to_ref: Annotated[ + str | None, + typer.Option( + "--to", + help="Snapshot to compare. Alone, it is compared against the one " + "it succeeded.", + ), + ] = None, + sensitivity: Annotated[ + str, + typer.Option( + "--sensitivity", + help="How much movement counts as significant: low, normal, or high.", + ), + ] = "normal", + show_all: Annotated[ + bool, + typer.Option("--all", help="Include columns and metrics that did not change."), + ] = False, + oneline: Annotated[ + bool, + typer.Option("--oneline", help="Condense the comparison to a single line."), + ] = False, + as_json: Annotated[ + bool, + typer.Option( + "--json", + help="Print the comparison as JSON instead of a table. Implies a " + "non-interactive run, so pass `--yes` in scripts.", + ), + ] = False, + pdf: Annotated[ + Path | None, + typer.Option( + "--pdf", + dir_okay=False, + writable=True, + help="Also write a PDF report of the comparison to this path. " + "Needs the `report` extra: pip install 'dstrack[report]'.", + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "-y", + "--yes", + help="Build the snapshot index without asking, if it does not exist yet.", + ), + ] = False, + rebuild: Annotated[ + bool, + typer.Option( + "--rebuild", + help="Discard the snapshot index and rebuild it from the dataset logs.", + ), + ] = False, + root: Annotated[ + Path | None, + typer.Option( + "--root", + help="Path root a REF path is made relative to, matching the one " + "`dstrack track` recorded it with. Defaults to the store root (the " + "directory containing .dstrack/). Ignored for a REF that is an id.", + ), + ] = None, +) -> None: + """Compare two snapshots and report what changed between them.""" + options = _options(sensitivity) + + # Locate the .dstrack/ store; without one there is nothing to compare. + try: + store_root = resolve_store_root() + except StoreNotFoundError as e: + console.error(str(e)) + raise typer.Exit(code=1) from e + + # Under --json the only thing on stdout may be the document itself, so the + # index cannot be offered interactively: consent has to come up front. + ensure_index(store_root, yes=yes or as_json, rebuild=rebuild, quiet=as_json) + + try: + # Sync before resolving: the index does not travel with the repository, + # so a snapshot taken elsewhere is only visible once its log is read. + reason = cache.sync(store_root) + if reason is not None and not as_json: + console.warning(f"Rebuilt the snapshot index: {reason}.") + + old, new = resolve_pair( + refs or [], + from_ref=from_ref, + to_ref=to_ref, + store_root=store_root, + root=root, + ) + result = diff_snapshots( + old.payload, + new.payload, + options=options, + old_dataset_id=old.dataset_id, + new_dataset_id=new.dataset_id, + ) + except _USER_ERRORS as e: + console.error(str(e)) + raise typer.Exit(code=1) from e + + if pdf is not None and not _write_pdf(result, pdf, quiet=as_json): + raise typer.Exit(code=1) + + if as_json: + # Deliberately not `console.display`: the document is data, and rich + # would wrap and highlight it into something no parser accepts. + print(json.dumps(result.to_dict(), indent=2)) + return + + if not result.same_dataset: + console.warning("Comparing snapshots from two different datasets.") + + console.display( + render_diff( + result, + width=console.width(), + now=datetime.now(UTC), + oneline=oneline, + show_unchanged=show_all, + ) + ) + + +def _options(sensitivity: str) -> DiffOptions: + """Return the thresholds a named sensitivity stands for. + + Args: + sensitivity: The preset name, as the user gave it. + + Returns: + Its thresholds. + + Raises: + typer.BadParameter: If no preset goes by that name. Raised rather than + reported, so the failure reads like any other bad option. + """ + try: + return preset(sensitivity) + except KeyError as e: + known = ", ".join(sorted(PRESETS)) + raise typer.BadParameter( + f"Unknown sensitivity {sensitivity!r}. Choose one of: {known}." + ) from e + + +def _write_pdf(result: SnapshotDiff, path: Path, *, quiet: bool) -> bool: + """Write the PDF report, reporting a failure rather than raising it. + + The reporting module is imported here rather than at the top of the file + because it needs an optional dependency: importing it eagerly would make + every `dstrack diff` pay for a feature most runs do not use, and would turn + a missing extra into an import error on an unrelated command. + + Args: + result: The comparison to report. + path: Where to write the PDF. + quiet: Suppress the success message, for a run whose stdout is data. + + Returns: + Whether the report was written. + """ + from dstrack._diff_report import write_pdf_report + + try: + write_pdf_report(result, path, now=datetime.now(UTC)) + except (MissingDependencyError, OSError) as e: + console.error(str(e)) + return False + + if not quiet: + console.success(f"Wrote the comparison report to {path}") + return True diff --git a/src/dstrack/_diff_render.py b/src/dstrack/_diff_render.py new file mode 100644 index 0000000..374c765 --- /dev/null +++ b/src/dstrack/_diff_render.py @@ -0,0 +1,808 @@ +"""Rendering of a snapshot comparison for the terminal. + +This is the only layer that knows what a diff looks like on screen. It builds +renderables and returns them; printing is the caller's job. + +Everything is laid out as before-and-after pairs, because that is the question +being asked: not "what does this snapshot say" but "what does it say that the +other one did not". A value that did not move is shown once and dimmed, so the +eye lands on the ones that did, and every changed value is colored by how much +it should worry the reader rather than by which direction it went -- a rising +null fraction is not good news because the number went up. + +The layout adapts to the terminal in three bands. Wide terminals get the full +per-column tables side by side; medium ones drop the least informative columns; +narrow ones abandon the tabular form entirely and stack each column as its own +small card, since a side-by-side table squeezed into forty cells is not a +side-by-side table any more. + +Every value that reaches a cell is user-derived -- a dataset name, a path, a +column name, a category from the data itself. Cells are therefore assembled as +`Text`, never interpolated into rich's markup: a column named ``price[/]usd`` +is not markup to be parsed, and treating it as such raises rather than prints. +""" + +from collections.abc import Sequence +from datetime import datetime +from typing import Final + +from rich import box +from rich.console import Group, RenderableType +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from dstrack._log_render import _humanize +from dstrack.diff import ( + ColumnKind, + ColumnSchemaChange, + ColumnStatDiff, + DistributionShift, + MetricDiff, + Severity, + ShiftMethod, + SnapshotDiff, +) + +_MISSING: Final = "\N{EM DASH}" +_ARROW: Final = "\N{RIGHTWARDS ARROW}" +_ELLIPSIS: Final = "\N{HORIZONTAL ELLIPSIS}" +_SHORT_ID_LEN: Final = 8 +_HASH_LEN: Final = 12 + +# Layout bands, in cells. +_WIDE_MIN_WIDTH: Final = 140 +_SIDE_BY_SIDE_MIN_WIDTH: Final = 100 + +_MAX_LISTED_COLUMNS: Final = 20 +_MAX_LISTED_CATEGORIES: Final = 5 + +_SEVERITY_STYLES: Final[dict[Severity, str]] = { + Severity.UNCHANGED: "dim", + Severity.INFO: "cyan", + Severity.WARNING: "yellow", + Severity.CRITICAL: "bold red", +} +_SEVERITY_GLYPHS: Final[dict[Severity, str]] = { + Severity.UNCHANGED: " ", + Severity.INFO: "\N{BULLET}", + Severity.WARNING: "\N{WARNING SIGN}", + Severity.CRITICAL: "\N{HEAVY EXCLAMATION MARK SYMBOL}", +} + +# Metrics where a rise is bad news, so the delta takes its color from the +# severity rather than from its sign. +_QUALITY_METRICS: Final[frozenset[str]] = frozenset( + {"null_fraction", "null_count", "duplicate_row_fraction"} +) + +# Numeric detail columns, most informative first. Trimmed from the end as the +# terminal narrows. +_NUMERIC_PRIORITY: Final[tuple[tuple[str, str], ...]] = ( + ("null_fraction", "nulls"), + ("mean", "mean"), + ("std", "std"), + ("percentiles.p50", "p50"), + ("min", "min"), + ("max", "max"), + ("num_unique", "uniq"), +) +_STRING_COLUMNS: Final[tuple[tuple[str, str], ...]] = ( + ("null_fraction", "nulls"), + ("num_unique", "uniq"), + ("top_values_coverage", "coverage"), + ("avg_char_length", "avg len"), +) +_DATETIME_COLUMNS: Final[tuple[tuple[str, str], ...]] = ( + ("null_fraction", "nulls"), + ("range_days", "range (d)"), +) +_OTHER_COLUMNS: Final[tuple[tuple[str, str], ...]] = ( + ("null_fraction", "nulls"), + ("null_count", "null count"), +) + +_KIND_TITLES: Final[dict[ColumnKind, str]] = { + ColumnKind.NUMERIC: "Numeric columns", + ColumnKind.STRING: "Text columns", + ColumnKind.DATETIME: "Datetime columns", + ColumnKind.OTHER: "Other columns", + ColumnKind.UNKNOWN: "Unrecognized columns", +} +_CHANGE_SIGNS: Final[dict[str, tuple[str, str]]] = { + "added": ("+", "green"), + "removed": ("-", "red"), + "type_changed": ("~", "yellow"), + "nullability_changed": ("~", "dim"), +} +_SHIFT_LABELS: Final[dict[ShiftMethod, str]] = { + ShiftMethod.PSI: "PSI", + ShiftMethod.PERCENTILE_SHIFT: "pctl", + ShiftMethod.TOP_VALUES_TVD: "TVD", + ShiftMethod.NONE: "", +} + + +def render_diff( + diff: SnapshotDiff, + *, + width: int, + now: datetime, + oneline: bool = False, + show_unchanged: bool = False, +) -> RenderableType: + """Render a snapshot comparison. + + Args: + diff: The comparison to show. + width: The terminal width in cells, which decides the layout band. + now: The moment each snapshot's age is measured against. + oneline: Condense the whole comparison to a single line. + show_unchanged: Include columns whose statistics did not move. + + Returns: + A renderable comparison. + """ + if oneline: + return _oneline(diff, now=now) + + sections: list[RenderableType] = [_identity(diff, now=now)] + + if diff.identical: + sections.append( + Text("The two snapshots are identical in everything compared.", "green") + ) + return Group(*sections) + + sections.append(_schema_section(diff)) + + metrics = _metrics_table(diff.metrics, show_unchanged=show_unchanged) + if metrics is not None: + sections.extend([Text(), Text("Dataset", style="bold"), metrics]) + + sections.extend(_column_sections(diff, width=width, show_unchanged=show_unchanged)) + sections.extend([Text(), _summary(diff)]) + return Group(*sections) + + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +def _identity(diff: SnapshotDiff, *, now: datetime) -> Panel: + """Render the two snapshots' identities, side by side. + + Args: + diff: The comparison. + now: The moment each snapshot's age is measured against. + + Returns: + A panel bordered in the diff's overall severity. + """ + grid = Table.grid(padding=(0, 2)) + grid.add_column(style="dim", no_wrap=True) + # No overflow settings on the value columns: rich's defaults ellipsize + # them, where a column told not to wrap is dropped whole when the panel has + # to shrink, taking the comparison with it. + grid.add_column() + grid.add_column() + + grid.add_row("", Text("before", style="dim"), Text("after", style="dim")) + grid.add_row( + "snapshot", + Text(_short(diff.old.snapshot_id), style="bold yellow"), + Text(_short(diff.new.snapshot_id), style="bold yellow"), + ) + if not diff.same_dataset: + grid.add_row( + "dataset", + Text(diff.old.dataset_id or _MISSING), + Text(diff.new.dataset_id or _MISSING), + ) + grid.add_row( + "name", + Text(diff.old.dataset_name or _MISSING, style="bold"), + Text(diff.new.dataset_name or _MISSING, style="bold"), + ) + grid.add_row( + "path", + Text(diff.old.dataset_path or _MISSING, style="dim"), + Text(diff.new.dataset_path or _MISSING, style="dim"), + ) + grid.add_row( + "taken", + Text(_humanize(diff.old.created_at, now)), + Text(_humanize(diff.new.created_at, now)), + ) + grid.add_row( + "by", + Text(diff.old.created_by or _MISSING), + Text(diff.new.created_by or _MISSING), + ) + grid.add_row( + "rows", + Text(_count(diff.old.num_rows), style="bold"), + Text(_count(diff.new.num_rows), style="bold"), + ) + grid.add_row( + "columns", + Text(_count(diff.old.num_columns), style="bold"), + Text(_count(diff.new.num_columns), style="bold"), + ) + grid.add_row( + "source", + Text(_hash(diff.old.source_hash), style="dim"), + _source_verdict(diff), + ) + + body: list[RenderableType] = [grid] + if not diff.same_dataset: + body.extend( + [ + Text(), + Text( + "These snapshots come from two different datasets.", + style="yellow", + ), + ] + ) + + return Panel( + Group(*body), + title=Text("dstrack diff"), + subtitle=_badge(diff.severity), + border_style=_SEVERITY_STYLES[diff.severity], + box=box.ROUNDED, + expand=False, + ) + + +def _source_verdict(diff: SnapshotDiff) -> Text: + """Render the later snapshot's source hash and what it says. + + Args: + diff: The comparison. + + Returns: + The hash, followed by whether the underlying bytes are known to match. + """ + text = Text(_hash(diff.new.source_hash), style="dim") + if diff.source_hash_equal: + text.append(" identical bytes", style="dim green") + elif diff.old.source_hash and diff.new.source_hash: + text.append(" changed", style="bold red") + return text + + +def _badge(severity: Severity) -> Text: + """Render a severity as a short badge. + + Args: + severity: The severity to show. + + Returns: + The severity's name, styled and spaced to read as a label. + """ + return Text(f" {severity.value.upper()} ", style=_SEVERITY_STYLES[severity]) + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +def _schema_section(diff: SnapshotDiff) -> RenderableType: + """Render the schema differences. + + Args: + diff: The comparison. + + Returns: + A table of column-level changes, or a note that the schema held still. + """ + schema = diff.schema + if not schema.changed: + lines: list[RenderableType] = [ + Text(), + Text("Schema", style="bold"), + Text(" unchanged", style="dim green"), + ] + if schema.reordered: + lines.append( + Text( + f" {len(schema.reordered)} column(s) reordered " + "(schema hash unchanged)", + style="dim", + ) + ) + return Group(*lines) + + table = Table(box=box.SIMPLE_HEAD, padding=(0, 1), show_edge=False) + table.add_column("", no_wrap=True) + table.add_column("column") + table.add_column("before") + table.add_column("after") + + changes = [ + (kind, change) + for kind, group in ( + ("added", schema.added), + ("removed", schema.removed), + ("type_changed", schema.type_changed), + ("nullability_changed", schema.nullability_changed), + ) + for change in group + ] + for kind, change in changes[:_MAX_LISTED_COLUMNS]: + sign, style = _CHANGE_SIGNS[kind] + table.add_row( + Text(sign, style=style), + Text(change.name, style=_SEVERITY_STYLES[change.severity]), + _dtype(change, old=True), + _dtype(change, old=False), + ) + + body: list[RenderableType] = [Text(), Text("Schema", style="bold"), table] + if len(changes) > _MAX_LISTED_COLUMNS: + hidden = len(changes) - _MAX_LISTED_COLUMNS + body.append(Text(f" {_ELLIPSIS} and {hidden} more", style="dim")) + return Group(*body) + + +def _dtype(change: ColumnSchemaChange, *, old: bool) -> Text: + """Render one side of a column's type, with its nullability. + + Args: + change: The column-level change. + old: Whether to render the baseline side. + + Returns: + The dtype, or an em-dash for the side the column was absent from. + """ + dtype = change.old_dtype if old else change.new_dtype + nullable = change.old_nullable if old else change.new_nullable + present = ( + (change.old_dtype is not None or change.old_nullable is not None) + if old + else (change.new_dtype is not None or change.new_nullable is not None) + ) + if dtype is None and not present: + return Text(_MISSING, style="dim") + + text = Text(dtype or _MISSING) + if nullable is not None: + text.append(" null" if nullable else " not null", style="dim") + return text + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +def _metrics_table( + metrics: Sequence[MetricDiff], *, show_unchanged: bool +) -> Table | None: + """Render a group of scalar comparisons. + + Args: + metrics: The comparisons to show. + show_unchanged: Include the ones that did not move. + + Returns: + A grid of metric, before, after and delta, or ``None`` if nothing is + worth showing. + """ + shown = [ + m for m in metrics if show_unchanged or m.severity is not Severity.UNCHANGED + ] + if not shown: + return None + + grid = Table.grid(padding=(0, 2)) + grid.add_column(no_wrap=True) + grid.add_column(style="dim", no_wrap=True) + grid.add_column(justify="right") + grid.add_column(justify="right") + grid.add_column(justify="right") + + for metric in shown: + grid.add_row( + Text( + _SEVERITY_GLYPHS[metric.severity], + style=_SEVERITY_STYLES[metric.severity], + ), + _label(metric.key), + Text(_value(metric.old), style="dim"), + Text(_value(metric.new), style=_SEVERITY_STYLES[metric.severity]), + _delta(metric), + ) + return grid + + +def _delta(metric: MetricDiff) -> Text: + """Render how far a metric moved. + + Args: + metric: The comparison. + + Returns: + The signed change and its relative size. A metric where a rise is bad + news takes its color from the severity, so that a growing null count is + never green; every other metric is colored by direction, as + `dstrack log` colors a row-count delta. + """ + if metric.delta is None or metric.delta == 0: + return Text("") + + text = Text(f"{metric.delta:+,.4g}", style=_delta_style(metric)) + if metric.pct_change: + text.append(f" ({metric.pct_change:+.1%})", style="dim") + return text + + +def _delta_style(metric: MetricDiff) -> str: + """Return the style a metric's delta is shown in. + + Args: + metric: The comparison. + + Returns: + A severity style for quality metrics, else green or red by direction. + """ + if metric.key in _QUALITY_METRICS: + return _SEVERITY_STYLES[metric.severity] + return "green" if (metric.delta or 0) > 0 else "red" + + +# --------------------------------------------------------------------------- +# Columns +# --------------------------------------------------------------------------- + + +def _column_sections( + diff: SnapshotDiff, *, width: int, show_unchanged: bool +) -> list[RenderableType]: + """Render the per-column comparisons, grouped by statistics variant. + + One table per variant: the meaningful measurements differ between a numeric + column and a text one, and a single merged table would be mostly em-dashes. + + Args: + diff: The comparison. + width: The terminal width, which decides the layout band. + show_unchanged: Include columns whose statistics did not move. + + Returns: + The renderables to append, empty if no column is worth showing. + """ + shown = [ + column + for column in diff.columns + if show_unchanged or column.severity is not Severity.UNCHANGED + ] + if not shown: + return [] + + sections: list[RenderableType] = [Text(), Text("Columns", style="bold")] + if width < _SIDE_BY_SIDE_MIN_WIDTH: + sections.append(_stacked(shown)) + return sections + + for kind in _KIND_TITLES: + group = [column for column in shown if column.kind is kind] + if group: + sections.extend( + [ + Text(f" {_KIND_TITLES[kind]}", style="dim"), + _column_table(group, kind=kind, width=width), + ] + ) + return sections + + +def _column_table( + columns: Sequence[ColumnStatDiff], *, kind: ColumnKind, width: int +) -> Table: + """Render one variant's columns as a before-and-after table. + + Args: + columns: The columns to show, all of one variant. + kind: The variant they share. + width: The terminal width, which decides how many measurements fit. + + Returns: + A table with one row per column and one cell per measurement, each cell + holding both sides. + """ + fields = _fields_for(kind, width=width) + + table = Table(box=box.SIMPLE_HEAD, padding=(0, 1), show_edge=False, expand=False) + table.add_column("", no_wrap=True) + table.add_column("column") + for _, label in fields: + table.add_column(label, justify="right") + if kind in (ColumnKind.NUMERIC, ColumnKind.STRING): + table.add_column("shift") + + for column in columns: + cells: list[RenderableType] = [ + Text( + _SEVERITY_GLYPHS[column.severity], + style=_SEVERITY_STYLES[column.severity], + ), + _column_name(column), + ] + metrics = {metric.key: metric for metric in column.metrics} + cells.extend(_pair(metrics.get(key)) for key, _ in fields) + if kind in (ColumnKind.NUMERIC, ColumnKind.STRING): + cells.append(_shift(column.distribution_shift)) + table.add_row(*cells) + return table + + +def _fields_for(kind: ColumnKind, *, width: int) -> tuple[tuple[str, str], ...]: + """Return the measurements to show for a variant at a given width. + + Args: + kind: The statistics variant. + width: The terminal width in cells. + + Returns: + Metric key and column label pairs. Numeric columns carry the most + measurements and are the only ones trimmed, dropping the least + informative first. + """ + if kind is ColumnKind.STRING: + return _STRING_COLUMNS + if kind is ColumnKind.DATETIME: + return _DATETIME_COLUMNS + if kind is not ColumnKind.NUMERIC: + return _OTHER_COLUMNS + if width >= _WIDE_MIN_WIDTH: + return _NUMERIC_PRIORITY + return _NUMERIC_PRIORITY[:4] + + +def _column_name(column: ColumnStatDiff) -> Text: + """Render a column's name, noting a variant change. + + Args: + column: The column's comparison. + + Returns: + The name, followed by the variants it moved between when it changed + kind -- the one case where the statistics are not comparable at all. + """ + text = Text(column.name, style="bold") + if column.old_kind is not None: + text.append( + f" ({column.old_kind.value} {_ARROW} {column.kind.value})", style="bold red" + ) + elif not column.present_in_new: + text.append(" (dropped)", style="red") + elif not column.present_in_old: + text.append(" (new)", style="green") + return text + + +def _pair(metric: MetricDiff | None) -> Text: + """Render one measurement as its before-and-after pair. + + Args: + metric: The comparison, or ``None`` if this column never recorded it. + + Returns: + Both values with an arrow between them when they differ, or the single + value dimmed when they do not -- an unchanged number is context, not + news, and showing it twice would bury the ones that moved. + """ + if metric is None: + return Text(_MISSING, style="dim") + if metric.severity is Severity.UNCHANGED: + return Text(_value(metric.new), style="dim") + + text = Text(_value(metric.old), style="dim") + text.append(f" {_ARROW} ", style="dim") + text.append(_value(metric.new), style=_SEVERITY_STYLES[metric.severity]) + return text + + +def _shift(shift: DistributionShift | None) -> Text: + """Render a column's distribution shift. + + Args: + shift: The measured shift, or ``None`` if the variant records none. + + Returns: + The measure's name and score, followed by the categories that came and + went for a text column. + """ + if shift is None or shift.score is None: + return Text(_MISSING, style="dim") + + text = Text( + f"{_SHIFT_LABELS[shift.method]} {shift.score:.2f}", + style=_SEVERITY_STYLES[shift.severity], + ) + for sign, style, categories in ( + ("+", "green", shift.added_categories), + ("-", "red", shift.removed_categories), + ): + for category in categories[:_MAX_LISTED_CATEGORIES]: + text.append(" ") + text.append(f"{sign}{category}", style=style) + if len(categories) > _MAX_LISTED_CATEGORIES: + extra = len(categories) - _MAX_LISTED_CATEGORIES + text.append(f" {sign}{extra} more", style="dim") + return text + + +def _stacked(columns: Sequence[ColumnStatDiff]) -> RenderableType: + """Render each column as its own small card, for a narrow terminal. + + A side-by-side table squeezed into forty cells stops being one, so below + the threshold the comparison is turned on its side: one heading per column, + then one line per measurement that moved. + + Args: + columns: The columns to show. + + Returns: + The stacked cards. + """ + grid = Table.grid(padding=(0, 1)) + grid.add_column(no_wrap=True) + grid.add_column(style="dim", no_wrap=True) + grid.add_column() + + for position, column in enumerate(columns): + if position: + grid.add_row("", "", "") + grid.add_row( + Text( + _SEVERITY_GLYPHS[column.severity], + style=_SEVERITY_STYLES[column.severity], + ), + _column_name(column), + Text(column.kind.value, style="dim"), + ) + for metric in column.metrics: + if metric.severity is Severity.UNCHANGED: + continue + grid.add_row("", _label(metric.key), _pair(metric)) + if column.distribution_shift is not None and ( + column.distribution_shift.score is not None + ): + grid.add_row("", Text("shift"), _shift(column.distribution_shift)) + return grid + + +# --------------------------------------------------------------------------- +# Summary and one-line form +# --------------------------------------------------------------------------- + + +def _summary(diff: SnapshotDiff) -> Text: + """Render the headline counts. + + Args: + diff: The comparison. + + Returns: + A single line naming what changed, or a note that nothing did. + """ + summary = diff.summary + text = Text() + parts: list[tuple[str, str]] = [] + if summary.row_delta: + parts.append((f"{summary.row_delta:+,} rows", "bold")) + if summary.columns_added: + parts.append((f"+{summary.columns_added} columns", "green")) + if summary.columns_removed: + parts.append((f"-{summary.columns_removed} columns", "red")) + if summary.columns_type_changed: + parts.append((f"{summary.columns_type_changed} columns retyped", "yellow")) + if summary.columns_drifted: + parts.append((f"{summary.columns_drifted} columns drifted", "yellow")) + + if not parts: + return Text("No column changed materially.", style="dim") + + for position, (label, style) in enumerate(parts): + if position: + text.append(" ") + text.append(label, style=style) + return text + + +def _oneline(diff: SnapshotDiff, *, now: datetime) -> Text: + """Render the whole comparison as one line. + + Args: + diff: The comparison. + now: Unused, kept so the two render paths take the same arguments. + + Returns: + The two ids, the dataset, what changed, and the overall severity. + """ + del now + line = Text() + line.append(_short(diff.old.snapshot_id), style="bold yellow") + line.append(f" {_ARROW} ", style="dim") + line.append(_short(diff.new.snapshot_id), style="bold yellow") + line.append(" ") + line.append(diff.new.dataset_name or _MISSING, style="bold") + line.append(" ") + line.append_text(_summary(diff)) + line.append(" ") + line.append_text(_badge(diff.severity)) + return line + + +# --------------------------------------------------------------------------- +# Formatting +# --------------------------------------------------------------------------- + + +def _label(key: str) -> Text: + """Render a metric key as a human-readable label. + + Args: + key: The metric's stable name, possibly dotted. + + Returns: + The label to show, with the nesting dropped and underscores spaced. + """ + _, _, leaf = key.rpartition(".") + return Text(leaf.replace("_", " ")) + + +def _value(value: float | None) -> str: + """Format a metric value for display. + + Args: + value: The value, or ``None`` if it was not recorded. + + Returns: + An em-dash for a missing value; an integer without a decimal point when + the number is whole, since a row count reading ``1200.0`` is noise. + """ + if value is None: + return _MISSING + if float(value).is_integer() and abs(value) < 1e15: + return f"{int(value):,}" + return f"{value:,.4g}" + + +def _count(value: int | None) -> str: + """Format a recorded count. + + Args: + value: The count, or ``None`` if it was not recorded. + + Returns: + The count with thousands separators, or an em-dash. + """ + return _MISSING if value is None else f"{value:,}" + + +def _short(snapshot_id: str | None) -> str: + """Return the leading characters of a snapshot id, as git shows a commit.""" + return _MISSING if snapshot_id is None else snapshot_id[:_SHORT_ID_LEN] + + +def _hash(value: str | None) -> str: + """Return a truncated content hash. + + Args: + value: The recorded hash, which is empty for a non-file source. + + Returns: + Its leading characters followed by an ellipsis, or an em-dash when + there is no hash to show. + """ + if not value: + return _MISSING + return f"{value[:_HASH_LEN]}{_ELLIPSIS}" diff --git a/src/dstrack/_diff_report.py b/src/dstrack/_diff_report.py new file mode 100644 index 0000000..73786a9 --- /dev/null +++ b/src/dstrack/_diff_report.py @@ -0,0 +1,1016 @@ +"""A printable report of a snapshot comparison. + +The terminal renderer and this one answer different questions. On screen a diff +is read once, by the person who ran the command, who already knows what they +compared. A report is read later and elsewhere, often by someone who did not +run anything, so it carries the context the terminal leaves implicit: what was +compared, when, by whom, and what the thresholds were that called a change +significant. + +`reportlab` is imported inside +[write_pdf_report][dstrack._diff_report.write_pdf_report] rather than at module +scope. It is an optional dependency, and importing it eagerly would make the +whole module unimportable wherever the ``report`` extra is not installed -- +which is most places, since most runs never write a report. + +The counterpart of the terminal renderer's `Text` rule applies here. +`Paragraph` parses a small HTML dialect, so a column named ``a`` would end +the build with a parse error rather than printing. Every user-derived string +therefore reaches a paragraph through [_p][dstrack._diff_report._p], which +escapes unconditionally. +""" + +import io +from collections.abc import Sequence +from datetime import datetime +from pathlib import Path +from typing import Any, Final +from xml.sax.saxutils import escape + +from dstrack import __version__ +from dstrack.diff import ( + ColumnKind, + ColumnStatDiff, + MetricDiff, + SetChange, + Severity, + ShiftMethod, + SnapshotDiff, +) +from dstrack.errors import MissingDependencyError + +_LOGO_RESOURCE: Final = "assets/dstrack-logo.png" + +# The brand palette, read off docs/assets/dstrack-logo.svg. +_PURPLE_LIGHT: Final = "#a99cff" +_PURPLE: Final = "#7060e0" +_PURPLE_DARK: Final = "#4a3cc0" +_INK: Final = "#1d1568" +_MUTED: Final = "#6b7280" +_ROW_TINT: Final = "#f5f4ff" +_RULE: Final = "#dedcf5" +_OK: Final = "#2e7d32" +_WARN: Final = "#b26a00" +_BAD: Final = "#c62828" +_PAPER: Final = "#ffffff" + +_SEVERITY_COLORS: Final[dict[Severity, str]] = { + Severity.UNCHANGED: _MUTED, + Severity.INFO: _PURPLE, + Severity.WARNING: _WARN, + Severity.CRITICAL: _BAD, +} + +_MISSING: Final = "—" +_ARROW: Final = "→" +_SHORT_ID_LEN: Final = 8 +_HASH_LEN: Final = 16 +_MAX_LISTED_CATEGORIES: Final = 5 + +_KIND_TITLES: Final[dict[ColumnKind, str]] = { + ColumnKind.NUMERIC: "Numeric columns", + ColumnKind.STRING: "Text columns", + ColumnKind.DATETIME: "Datetime columns", + ColumnKind.OTHER: "Other columns", + ColumnKind.UNKNOWN: "Unrecognized columns", +} +_CHANGE_LABELS: Final[tuple[tuple[str, str, str], ...]] = ( + ("added", "Added", _OK), + ("removed", "Removed", _BAD), + ("type_changed", "Type changed", _WARN), + ("nullability_changed", "Nullability", _MUTED), +) +_SHIFT_LABELS: Final[dict[ShiftMethod, str]] = { + ShiftMethod.PSI: "PSI", + ShiftMethod.PERCENTILE_SHIFT: "Percentile shift", + ShiftMethod.TOP_VALUES_TVD: "Total variation", + ShiftMethod.NONE: "", +} + + +def write_pdf_report(diff: SnapshotDiff, path: Path, *, now: datetime) -> None: + """Write a comparison report to `path` as a PDF. + + Args: + diff: The comparison to report. + path: Where to write the report. Its parent must exist. + now: The moment to stamp the report with. + + Raises: + MissingDependencyError: If `reportlab` is not installed. + OSError: If `path` cannot be written. + """ + kit = _import_reportlab() + + document = kit.SimpleDocTemplate( + str(path), + pagesize=kit.A4, + leftMargin=18 * kit.mm, + rightMargin=18 * kit.mm, + topMargin=16 * kit.mm, + bottomMargin=20 * kit.mm, + title="dstrack dataset diff report", + author=f"dstrack {__version__}", + subject=_plain_subject(diff), + ) + + story: list[Any] = [] + story += _banner(kit, diff) + story += _identity(kit, diff) + story += _summary(kit, diff) + story += _schema(kit, diff) + story += _metrics(kit, diff) + story += _columns(kit, diff) + + document.build(story, canvasmaker=_numbered_canvas(kit, now=now)) + + +# --------------------------------------------------------------------------- +# The optional dependency +# --------------------------------------------------------------------------- + + +class _Kit: + """The reportlab names this module uses, gathered by a single import. + + Passing one object around keeps every builder below free of its own import + block, and keeps the import itself in one place where its failure can be + explained rather than raised as an `ImportError` from somewhere deep. + + Attributes: + colors: The `reportlab.lib.colors` module. + A4: The A4 page size, in points. + mm: One millimetre, in points. + canvas: The `reportlab.pdfgen.canvas` module. + Image: Flowable for an embedded image. + KeepTogether: Flowable wrapper that keeps its content on one page. + Paragraph: Flowable for styled text. + SimpleDocTemplate: The single-template document builder. + Spacer: Flowable for vertical whitespace. + Table: Flowable for a grid of cells. + TableStyle: Styling commands for a `Table`. + HRFlowable: Flowable for a horizontal rule. + title: Style for the report title. + subtitle: Style for the line under the title. + heading: Style for section headings. + subheading: Style for headings within a section. + body: Style for running text. + cell: Style for text inside table cells. + mono: Style for code-like values inside table cells. + note: Style for muted explanatory text. + """ + + def __init__(self) -> None: + from reportlab.lib import colors + from reportlab.lib.enums import TA_LEFT + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.units import mm + from reportlab.pdfgen import canvas + from reportlab.platypus import ( + Image, + KeepTogether, + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + from reportlab.platypus.flowables import HRFlowable + + self.colors = colors + self.A4 = A4 + self.mm = mm + self.canvas = canvas + self.Image = Image + self.KeepTogether = KeepTogether + self.Paragraph = Paragraph + self.SimpleDocTemplate = SimpleDocTemplate + self.Spacer = Spacer + self.Table = Table + self.TableStyle = TableStyle + self.HRFlowable = HRFlowable + + self.title = ParagraphStyle( + "dsTitle", + fontName="Helvetica-Bold", + fontSize=20, + leading=24, + textColor=colors.HexColor(_PURPLE_DARK), + alignment=TA_LEFT, + ) + self.subtitle = ParagraphStyle( + "dsSubtitle", + fontName="Helvetica", + fontSize=10, + leading=14, + textColor=colors.HexColor(_MUTED), + ) + self.heading = ParagraphStyle( + "dsHeading", + fontName="Helvetica-Bold", + fontSize=13, + leading=16, + spaceBefore=10, + spaceAfter=4, + textColor=colors.HexColor(_INK), + ) + self.subheading = ParagraphStyle( + "dsSubheading", + fontName="Helvetica-Bold", + fontSize=9.5, + leading=12, + spaceBefore=6, + spaceAfter=2, + textColor=colors.HexColor(_PURPLE_DARK), + ) + self.body = ParagraphStyle( + "dsBody", + fontName="Helvetica", + fontSize=9, + leading=12.5, + textColor=colors.HexColor(_INK), + ) + self.cell = ParagraphStyle( + "dsCell", parent=self.body, fontSize=8.2, leading=10.5 + ) + self.mono = ParagraphStyle( + "dsMono", parent=self.cell, fontName="Courier", fontSize=7.8 + ) + self.note = ParagraphStyle( + "dsNote", + parent=self.body, + fontSize=8.5, + textColor=colors.HexColor(_MUTED), + ) + + +def _import_reportlab() -> _Kit: + """Load reportlab, explaining how to get it if it is not there. + + Returns: + The names this module builds a document from. + + Raises: + MissingDependencyError: If `reportlab` is not installed. + """ + try: + return _Kit() + except ImportError as e: + raise MissingDependencyError( + "Writing a PDF report needs `reportlab`, which is not installed.\n" + "Install it with: pip install 'dstrack[report]'\n" + " or: uv add 'dstrack[report]'" + ) from e + + +# --------------------------------------------------------------------------- +# Sections +# --------------------------------------------------------------------------- + + +def _banner(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the report's masthead. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The logo, title, subtitle and rule. + """ + heading = [ + kit.Paragraph("Dataset Diff Report", kit.title), + kit.Paragraph(escape(_plain_subject(diff)), kit.subtitle), + ] + + logo = _logo(kit) + masthead: Any + if logo is None: + masthead = kit.Table([[heading]], colWidths=[None]) + else: + masthead = kit.Table( + [[logo, heading]], colWidths=[20 * kit.mm, None], hAlign="LEFT" + ) + masthead.setStyle( + kit.TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (0, 0), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ] + ) + ) + return [ + masthead, + kit.Spacer(1, 5 * kit.mm), + kit.HRFlowable( + width="100%", thickness=1.5, color=kit.colors.HexColor(_PURPLE_LIGHT) + ), + kit.Spacer(1, 3 * kit.mm), + _chip(kit, diff.severity.value.upper(), _SEVERITY_COLORS[diff.severity]), + ] + + +def _logo(kit: _Kit) -> Any | None: + """Return the brand logo as a flowable. + + The image is read as bytes rather than as a path. `importlib.resources` + only guarantees a real file inside an `as_file` block, and the file is gone + once that block exits -- which would be long before `doc.build` gets round + to drawing it. + + Args: + kit: The loaded reportlab names. + + Returns: + The logo, or ``None`` if it could not be read. A broken install should + still produce a report: a missing logo is a cosmetic loss, not a reason + to fail the command. + """ + from importlib import resources + + try: + data = resources.files("dstrack").joinpath(_LOGO_RESOURCE).read_bytes() + except (FileNotFoundError, ModuleNotFoundError, OSError): + return None + return kit.Image(io.BytesIO(data), width=16 * kit.mm, height=16 * kit.mm) + + +def _identity(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the table naming what was compared. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The section's flowables. + """ + rows: list[list[Any]] = [ + [ + _p(kit, "Field", kit.cell, bold=True), + _p(kit, "Before", kit.cell, bold=True), + _p(kit, "After", kit.cell, bold=True), + ] + ] + rows.append( + [ + _p(kit, "Snapshot", kit.cell), + _p(kit, _short(diff.old.snapshot_id), kit.mono), + _p(kit, _short(diff.new.snapshot_id), kit.mono), + ] + ) + if not diff.same_dataset: + rows.append( + [ + _p(kit, "Dataset", kit.cell), + _p(kit, diff.old.dataset_id or _MISSING, kit.mono), + _p(kit, diff.new.dataset_id or _MISSING, kit.mono), + ] + ) + for label, before, after in ( + ("Name", diff.old.dataset_name, diff.new.dataset_name), + ("Path", diff.old.dataset_path, diff.new.dataset_path), + ("Taken", _timestamp(diff.old.created_at), _timestamp(diff.new.created_at)), + ("By", diff.old.created_by, diff.new.created_by), + ): + rows.append( + [ + _p(kit, label, kit.cell), + _p(kit, before or _MISSING, kit.cell), + _p(kit, after or _MISSING, kit.cell), + ] + ) + rows.append( + [ + _p(kit, "Rows", kit.cell), + _p(kit, _count(diff.old.num_rows), kit.cell), + _p(kit, _count(diff.new.num_rows), kit.cell), + ] + ) + rows.append( + [ + _p(kit, "Columns", kit.cell), + _p(kit, _count(diff.old.num_columns), kit.cell), + _p(kit, _count(diff.new.num_columns), kit.cell), + ] + ) + rows.append( + [ + _p(kit, "Source hash", kit.cell), + _p(kit, _hash(diff.old.source_hash), kit.mono), + _p(kit, _hash(diff.new.source_hash), kit.mono), + ] + ) + + section: list[Any] = [ + kit.Paragraph("What was compared", kit.heading), + ] + if not diff.same_dataset: + section.append( + _callout( + kit, + "These snapshots belong to two different datasets. Column-level " + "comparisons still apply, but a difference here is not " + "necessarily drift.", + ) + ) + section.append(_table(kit, rows, widths=[32 * kit.mm, None, None])) + return section + + +def _summary(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the plain-language summary of what changed. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The section's flowables. + """ + summary = diff.summary + points: list[str] = [] + + if diff.identical: + points.append("Nothing that is compared differs between the two snapshots.") + if summary.row_delta: + points.append( + f"Row count moved by {summary.row_delta:+,}, " + f"from {_count(diff.old.num_rows)} to {_count(diff.new.num_rows)}." + ) + if summary.columns_added: + points.append(f"{summary.columns_added} column(s) added.") + if summary.columns_removed: + points.append( + f"{summary.columns_removed} column(s) removed, which breaks anything " + "reading them by name." + ) + if summary.columns_type_changed: + points.append(f"{summary.columns_type_changed} column(s) changed type.") + if summary.columns_drifted: + points.append( + f"{summary.columns_drifted} column(s) moved enough statistically to " + "be worth a look." + ) + if diff.old.source_hash and diff.new.source_hash: + points.append( + "The underlying bytes are identical." + if diff.source_hash_equal + else "The underlying file changed." + ) + if not points: + points.append("No change reached the configured thresholds.") + + section: list[Any] = [kit.Paragraph("Summary", kit.heading)] + section += [_p(kit, f"• {point}", kit.body) for point in points] + section.append(kit.Spacer(1, 2 * kit.mm)) + section.append( + _p( + kit, + f"Thresholds: a relative change of {diff.options.warn_pct_change:.0%} " + f"warns and {diff.options.critical_pct_change:.0%} is critical; for " + "values already expressed as a fraction, " + f"{diff.options.warn_fraction_shift:.1%} and " + f"{diff.options.critical_fraction_shift:.1%} absolute. Population " + f"stability index warns at {diff.options.warn_psi:.2f} and is " + f"critical at {diff.options.critical_psi:.2f}.", + kit.note, + ) + ) + return section + + +def _schema(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the schema-change table. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The section's flowables. + """ + section: list[Any] = [kit.Paragraph("Schema changes", kit.heading)] + schema = diff.schema + + if not schema.changed: + section.append(_p(kit, "The schema is unchanged.", kit.body)) + if schema.reordered: + section.append( + _p( + kit, + f"{len(schema.reordered)} column(s) appear in a different " + "order. The schema hash is order-independent, so this is " + "the only place a reorder shows.", + kit.note, + ) + ) + return section + + rows: list[list[Any]] = [ + [ + _p(kit, "Change", kit.cell, bold=True), + _p(kit, "Column", kit.cell, bold=True), + _p(kit, "Before", kit.cell, bold=True), + _p(kit, "After", kit.cell, bold=True), + ] + ] + tints: list[tuple[int, str]] = [] + for attribute, label, color in _CHANGE_LABELS: + for change in getattr(schema, attribute): + tints.append((len(rows), color)) + rows.append( + [ + _p(kit, label, kit.cell, color=color, bold=True), + _p(kit, change.name, kit.cell), + _p(kit, _dtype(change.old_dtype, change.old_nullable), kit.cell), + _p(kit, _dtype(change.new_dtype, change.new_nullable), kit.cell), + ] + ) + + section.append( + _table(kit, rows, widths=[26 * kit.mm, None, 38 * kit.mm, 38 * kit.mm]) + ) + return section + + +def _metrics(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the dataset-level metric table. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The section's flowables, empty if no dataset metric was recorded. + """ + if not diff.metrics and not diff.sets: + return [] + + rows: list[list[Any]] = [ + [ + _p(kit, "Metric", kit.cell, bold=True), + _p(kit, "Before", kit.cell, bold=True), + _p(kit, "After", kit.cell, bold=True), + _p(kit, "Change", kit.cell, bold=True), + _p(kit, "Relative", kit.cell, bold=True), + ] + ] + for metric in diff.metrics: + color = _SEVERITY_COLORS[metric.severity] + rows.append( + [ + _p(kit, _label(metric.key), kit.cell), + _p(kit, _value(metric.old), kit.cell), + _p(kit, _value(metric.new), kit.cell, color=color), + _p(kit, _delta(metric), kit.cell, color=color), + _p( + kit, + f"{metric.pct_change:+.1%}" if metric.pct_change else _MISSING, + kit.cell, + color=color, + ), + ] + ) + + section: list[Any] = [ + kit.Paragraph("Dataset metrics", kit.heading), + _table(kit, rows, widths=[46 * kit.mm, None, None, None, None]), + ] + section.extend(_p(kit, _set_change(change), kit.note) for change in diff.sets) + return section + + +def _set_change(change: SetChange) -> str: + """Describe how a set-valued field's membership moved. + + Args: + change: The membership change. + + Returns: + A sentence naming what joined the set and what left it. + """ + parts = [] + if change.added: + parts.append(f"joined by {', '.join(change.added)}") + if change.removed: + parts.append(f"left by {', '.join(change.removed)}") + return f"{_label(change.key)}: {', '.join(parts)}" + + +def _columns(kit: _Kit, diff: SnapshotDiff) -> list[Any]: + """Build the per-column detail, grouped by statistics variant. + + Args: + kit: The loaded reportlab names. + diff: The comparison. + + Returns: + The section's flowables, empty if no column changed. + """ + changed = [ + column for column in diff.columns if column.severity is not Severity.UNCHANGED + ] + if not changed: + return [] + + # Each heading is bound to the first block beneath it, so a page never ends + # on a title whose content starts overleaf. + section: list[Any] = [] + pending: list[Any] = [kit.Paragraph("Column detail", kit.heading)] + for kind, title in _KIND_TITLES.items(): + group = [column for column in changed if column.kind is kind] + if not group: + continue + pending.append(kit.Paragraph(escape(title), kit.subheading)) + blocks = [_column_block(kit, column) for column in group] + section.append(kit.KeepTogether([*pending, blocks[0]])) + section.extend(blocks[1:]) + pending = [] + return section + + +def _column_block(kit: _Kit, column: ColumnStatDiff) -> Any: + """Build one column's heading and metric table as an unbreakable unit. + + Args: + kit: The loaded reportlab names. + column: The column's comparison. + + Returns: + A flowable that will not be split across a page boundary, so a + column's numbers are never separated from the name they belong to. + """ + heading = _p( + kit, + _column_title(column), + kit.body, + bold=True, + color=_SEVERITY_COLORS[column.severity], + ) + + rows: list[list[Any]] = [ + [ + _p(kit, "Statistic", kit.cell, bold=True), + _p(kit, "Before", kit.cell, bold=True), + _p(kit, "After", kit.cell, bold=True), + _p(kit, "Change", kit.cell, bold=True), + ] + ] + for metric in column.metrics: + if metric.severity is Severity.UNCHANGED: + continue + color = _SEVERITY_COLORS[metric.severity] + rows.append( + [ + _p(kit, _label(metric.key), kit.cell), + _p(kit, _value(metric.old), kit.cell), + _p(kit, _value(metric.new), kit.cell, color=color), + _p(kit, _delta(metric), kit.cell, color=color), + ] + ) + + block: list[Any] = [heading] + if len(rows) > 1: + block.append(_table(kit, rows, widths=[46 * kit.mm, None, None, None])) + else: + block.append(_p(kit, "Only the column's presence changed.", kit.note)) + + # The shift is a property of the pair, not a value either side recorded, so + # it is stated rather than tabulated: a row of it under Before and After + # would read as a statistic that went from nothing to 0.23. + shift = column.distribution_shift + if shift is not None and shift.score is not None: + block.append( + _p( + kit, + f"{_SHIFT_LABELS[shift.method]}: {shift.score:.3f}" + + (f" {categories}" if (categories := _categories(shift)) else ""), + kit.note, + color=_SEVERITY_COLORS[shift.severity], + ) + ) + block.append(kit.Spacer(1, 2 * kit.mm)) + return kit.KeepTogether(block) + + +# --------------------------------------------------------------------------- +# Page furniture +# --------------------------------------------------------------------------- + + +def _numbered_canvas(kit: _Kit, *, now: datetime) -> Any: + """Build the canvas class that stamps a footer on every page. + + "Page 3 of 7" cannot be drawn while page 3 is being laid out, because the + total is not known until the last page is done. The pages are therefore + buffered and their footers drawn at the end, when the count is settled. + + Args: + kit: The loaded reportlab names. + now: The moment to stamp the report with. + + Returns: + A canvas class for `doc.build` to instantiate. + """ + stamp = now.strftime("%Y-%m-%d %H:%M UTC") + + class _NumberedCanvas(kit.canvas.Canvas): # type: ignore[misc,name-defined] + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._pages: list[dict[str, Any]] = [] + + def showPage(self) -> None: + self._pages.append(dict(self.__dict__)) + self._startPage() + + def save(self) -> None: + total = len(self._pages) + for state in self._pages: + self.__dict__.update(state) + self._footer(total) + super().showPage() + super().save() + + def _footer(self, total: int) -> None: + width, _ = self._pagesize + self.setStrokeColor(kit.colors.HexColor(_RULE)) + self.setLineWidth(0.5) + self.line(18 * kit.mm, 14 * kit.mm, width - 18 * kit.mm, 14 * kit.mm) + + self.setFont("Helvetica", 7.5) + self.setFillColor(kit.colors.HexColor(_MUTED)) + self.drawString( + 18 * kit.mm, + 10 * kit.mm, + f"dstrack {__version__} · generated {stamp}", + ) + self.drawRightString( + width - 18 * kit.mm, + 10 * kit.mm, + f"Page {self._pageNumber} of {total}", + ) + + return _NumberedCanvas + + +# --------------------------------------------------------------------------- +# Building blocks +# --------------------------------------------------------------------------- + + +def _p( + kit: _Kit, + content: str, + style: Any, + *, + bold: bool = False, + color: str | None = None, +) -> Any: + """Build a paragraph from text that may contain anything. + + The single place user-derived text becomes a flowable, and so the single + place the escaping has to be right. `Paragraph` reads its input as markup, + so a column named ``a`` would abort the build. + + Args: + kit: The loaded reportlab names. + content: The text, escaped here rather than by the caller. + style: The paragraph style to use. + bold: Render in the bold face. + color: Override the style's text color. + + Returns: + The paragraph. + """ + markup = escape(content) + if bold: + markup = f"{markup}" + if color is not None: + markup = f'{markup}' + return kit.Paragraph(markup, style) + + +def _table(kit: _Kit, rows: Sequence[Sequence[Any]], *, widths: Sequence[Any]) -> Any: + """Build a table in the report's house style. + + Args: + kit: The loaded reportlab names. + rows: The cells, with the header as the first row. + widths: Column widths, ``None`` for a column that takes what is left. + + Returns: + The styled table, with its header repeated on every page it spans. + """ + table = kit.Table(list(rows), colWidths=list(widths), repeatRows=1, hAlign="LEFT") + style = [ + ("BACKGROUND", (0, 0), (-1, 0), kit.colors.HexColor(_PURPLE_DARK)), + ("TEXTCOLOR", (0, 0), (-1, 0), kit.colors.HexColor(_PAPER)), + ("GRID", (0, 0), (-1, -1), 0.25, kit.colors.HexColor(_RULE)), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ] + style += [ + ("BACKGROUND", (0, row), (-1, row), kit.colors.HexColor(_ROW_TINT)) + for row in range(2, len(rows), 2) + ] + table.setStyle(kit.TableStyle(style)) + return table + + +def _chip(kit: _Kit, label: str, color: str) -> Any: + """Build a small filled badge. + + Args: + kit: The loaded reportlab names. + label: The badge's text. + color: Its fill color. + + Returns: + A one-cell table standing in for a rounded label. + """ + chip = kit.Table( + [[_p(kit, label, kit.cell, bold=True, color=_PAPER)]], + colWidths=[26 * kit.mm], + hAlign="LEFT", + ) + chip.setStyle( + kit.TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), kit.colors.HexColor(color)), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ] + ) + ) + return chip + + +def _callout(kit: _Kit, message: str) -> Any: + """Build a tinted note that should not be read as part of a table. + + Args: + kit: The loaded reportlab names. + message: The text to show. + + Returns: + The callout. + """ + box = kit.Table([[_p(kit, message, kit.note)]], colWidths=["100%"], hAlign="LEFT") + box.setStyle( + kit.TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), kit.colors.HexColor(_ROW_TINT)), + ("LINEBEFORE", (0, 0), (0, -1), 2, kit.colors.HexColor(_WARN)), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (-1, -1), 7), + ] + ) + ) + return box + + +# --------------------------------------------------------------------------- +# Formatting +# --------------------------------------------------------------------------- + + +def _plain_subject(diff: SnapshotDiff) -> str: + """Describe what the report is about, in one line. + + Args: + diff: The comparison. + + Returns: + The dataset name or names, and the two short snapshot ids. + """ + old_name = diff.old.dataset_name or _MISSING + new_name = diff.new.dataset_name or _MISSING + subject = old_name if old_name == new_name else f"{old_name} {_ARROW} {new_name}" + return ( + f"{subject} · {_short(diff.old.snapshot_id)} {_ARROW} " + f"{_short(diff.new.snapshot_id)}" + ) + + +def _column_title(column: ColumnStatDiff) -> str: + """Name a column, saying what happened to it. + + Args: + column: The column's comparison. + + Returns: + The name, annotated when the column appeared, disappeared, or changed + the kind of value it holds. + """ + if column.old_kind is not None: + return f"{column.name} ({column.old_kind.value} {_ARROW} {column.kind.value})" + if not column.present_in_new: + return f"{column.name} (dropped)" + if not column.present_in_old: + return f"{column.name} (new)" + return column.name + + +def _categories(shift: Any) -> str: + """Summarize the categories a text column gained and lost. + + Args: + shift: The column's distribution shift. + + Returns: + A short list of each, or an em-dash when nothing came or went. + """ + parts = [] + for sign, values in ( + ("+", shift.added_categories), + ("-", shift.removed_categories), + ): + if not values: + continue + shown = ", ".join(values[:_MAX_LISTED_CATEGORIES]) + if len(values) > _MAX_LISTED_CATEGORIES: + shown += f", +{len(values) - _MAX_LISTED_CATEGORIES} more" + label = "gained" if sign == "+" else "lost" + parts.append(f"{label} {shown}") + return "; ".join(parts) + + +def _label(key: str) -> str: + """Render a metric key as a human-readable label.""" + _, _, leaf = key.rpartition(".") + return leaf.replace("_", " ").capitalize() + + +def _delta(metric: MetricDiff) -> str: + """Render how far a metric moved. + + Args: + metric: The comparison. + + Returns: + The signed change, or an em-dash when there is none to state. + """ + if metric.delta is None or metric.delta == 0: + return _MISSING + return f"{metric.delta:+,.4g}" + + +def _value(value: float | None) -> str: + """Format a metric value for a table cell.""" + if value is None: + return _MISSING + if float(value).is_integer() and abs(value) < 1e15: + return f"{int(value):,}" + return f"{value:,.4g}" + + +def _count(value: int | None) -> str: + """Format a recorded count.""" + return _MISSING if value is None else f"{value:,}" + + +def _dtype(dtype: str | None, nullable: bool | None) -> str: + """Render a column's type and nullability.""" + if dtype is None and nullable is None: + return _MISSING + suffix = "" if nullable is None else (", nullable" if nullable else ", not null") + return f"{dtype or _MISSING}{suffix}" + + +def _timestamp(value: str | None) -> str: + """Render a recorded timestamp for a reader rather than for a machine. + + Args: + value: The ISO-8601 timestamp, as the snapshot recorded it. + + Returns: + The moment to the minute, or the raw string if it cannot be parsed -- + an unreadable timestamp is still worth showing, and is not a reason to + fail a report. + """ + if not value: + return _MISSING + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + return parsed.strftime("%Y-%m-%d %H:%M %Z").strip() + + +def _short(snapshot_id: str | None) -> str: + """Return the leading characters of a snapshot id.""" + return _MISSING if snapshot_id is None else snapshot_id[:_SHORT_ID_LEN] + + +def _hash(value: str | None) -> str: + """Return a truncated content hash, or an em-dash if there is none.""" + return f"{value[:_HASH_LEN]}…" if value else _MISSING diff --git a/src/dstrack/_log.py b/src/dstrack/_log.py index 68efe15..c17f70d 100644 --- a/src/dstrack/_log.py +++ b/src/dstrack/_log.py @@ -1,9 +1,6 @@ """The ``dstrack log`` command: show a tracked dataset's history.""" -import os import sqlite3 -import sys -import uuid from datetime import UTC, datetime from itertools import pairwise from pathlib import Path @@ -11,8 +8,9 @@ import typer -from dstrack import cache, console, store +from dstrack import cache, console from dstrack._log_render import render_history +from dstrack._resolve import ensure_index, resolve_target from dstrack.errors import ( DatasetNotFoundError, StoreCorruptionError, @@ -80,7 +78,7 @@ def log( console.error(str(e)) raise typer.Exit(code=1) from e - _ensure_index(store_root, yes=yes, rebuild=rebuild) + ensure_index(store_root, yes=yes, rebuild=rebuild) try: # Sync before resolving: the index does not travel with the repository, @@ -89,7 +87,7 @@ def log( if reason is not None: console.warning(f"Rebuilt the snapshot index: {reason}.") - dataset_id = _resolve_target(target, store_root=store_root, root=root) + dataset_id = resolve_target(target, store_root=store_root, root=root) history = cache.query_history(dataset_id, store_root=store_root) except ( DatasetNotFoundError, @@ -125,144 +123,3 @@ def log( oneline=oneline, ) ) - - -def _ensure_index(store_root: Path, *, yes: bool, rebuild: bool) -> None: - """Make sure the snapshot index may be built, asking first if need be. - - Building parses every dataset's log, so it is offered rather than done - silently. Consent is only about the first build: an index that exists but - cannot be used is rebuilt without asking, since it holds nothing that the - logs do not. - - Args: - store_root: Path to the ``.dstrack/`` directory. - yes: Consent given up front, so nothing is asked. - rebuild: Discard any existing index, which implies consent. - - Raises: - typer.Exit: Code 0 if the user declines to build it; code 1 if there - is no index and no terminal to ask at. - """ - path = cache.index_path(store_root) - if rebuild: - path.unlink(missing_ok=True) - console.info("Rebuilding the snapshot index from the dataset logs...") - return - if cache.index_exists(store_root): - return - - if not yes: - if not sys.stdin.isatty(): - console.error( - f"No snapshot index at `{path}`, and there is no terminal to " - "ask whether to build one. Re-run with `--yes` to build it." - ) - raise typer.Exit(code=1) - console.info(f"No snapshot index at `{path}` yet.") - if not typer.confirm("Build it now from the dataset logs?"): - console.warning( - "Not building the index; there is nothing to show without it." - ) - raise typer.Exit(code=0) - - console.info("Building the snapshot index from the dataset logs...") - - -def _resolve_target(target: str, *, store_root: Path, root: Path | None) -> str: - """Resolve a user-supplied dataset id or path to a dataset id. - - A ``dataset_id`` is a random UUID4, so nothing about it can be - derived from a path: a path is resolved by matching it against what each - dataset last recorded. `target` is read as an id first and as a path - second, but only when the id names a dataset that exists. Reading it as a - path first would let any file in the current directory shadow a real id, - and would send a mistyped path into the id lookup, which can only report - that no dataset goes by that name. - - Args: - target: A dataset id, or a path to a tracked dataset file. - store_root: Path to the ``.dstrack/`` directory. - root: Path root a `target` path is made relative to, or ``None`` to - use the store root. - - Returns: - The id of the dataset `target` names. - - Raises: - DatasetNotFoundError: If `target` is an id no dataset goes by, or a - path no dataset last recorded. - ValueError: If `target` and the path root are on different drives, so - no relative path exists between them. - """ - canonical = _canonical_uuid(target) - if canonical is not None and store.dataset_exists(canonical, store_root=store_root): - return canonical - - path_root = root if root is not None else store_root.parent - dataset_path = Path( - os.path.relpath(Path(target).resolve(), Path(path_root).resolve()) - ).as_posix() - - matched = cache.find_dataset_by_path(dataset_path, store_root=store_root) - if matched is not None: - return matched - - if canonical is not None: - raise DatasetNotFoundError( - f"No dataset {target!r} in the store.\n{_known_datasets(store_root)}" - ) - raise DatasetNotFoundError( - f"No tracked dataset at {target!r} (recorded as {dataset_path!r} " - f"relative to {path_root}).\n" - "If the file was renamed or moved, its recorded path no longer " - "matches; pass the dataset id instead. Use `--root` to change the " - "path root the recorded path is computed against.\n" - f"{_known_datasets(store_root)}" - ) - - -def _canonical_uuid(value: str) -> str | None: - """Return `value` as a canonical UUID string, or ``None`` if it is not one. - - Datasets are named by ``str(uuid.uuid4())``, but a user may paste an id - uppercased, braced, or without its dashes, all of which name the same - dataset. Canonicalizing accepts those spellings, and makes the result - provably free of path separators before it is joined onto the store. - - Args: - value: The string to interpret. - - Returns: - The canonical form of `value` if it is a UUID in any spelling, else - ``None``. - """ - try: - return str(uuid.UUID(value)) - except (ValueError, AttributeError, TypeError): - return None - - -def _known_datasets(store_root: Path) -> str: - """List the store's datasets, for an error message to end on. - - A dataset id says nothing on its own, so each is shown with the name and - path its latest snapshot recorded: that is what lets a reader recognize - the dataset they meant and copy its id. - - Args: - store_root: Path to the ``.dstrack/`` directory. - - Returns: - An indented listing of every dataset, or a note that there are none. - """ - datasets = cache.list_datasets(store_root=store_root) - lines = [ - f" {d.dataset_id} {(d.head.dataset_name if d.head else None) or '-'} " - f"{(d.head.dataset_path if d.head else None) or '-'}" - for d in datasets - ] - if not lines: - return "Known datasets:\n (none)\nRun `dstrack track ` to record one." - listing = "\n".join(lines) - return f"Known datasets:\n{listing}" diff --git a/src/dstrack/_resolve.py b/src/dstrack/_resolve.py new file mode 100644 index 0000000..a236757 --- /dev/null +++ b/src/dstrack/_resolve.py @@ -0,0 +1,555 @@ +"""Turning what a user typed into something the store can be asked about. + +Commands take a dataset or a snapshot the way a person refers to it -- a path, +an id, the first few characters of one -- and the store only answers to exact +ids. This module holds that translation, and the consent flow that must run +before the index it relies on is built. + +It lives here rather than in `dstrack.store` or `dstrack.cache` because +resolution is a *command's* concern: it prompts, prints, and exits, none of +which a data-layer module may do. And it lives in one place rather than in each +command because the precedence between an id and a path is a promise made to +the user, and two copies of it would drift until `dstrack log` and +`dstrack diff` answered the same argument differently. +""" + +import os +import sys +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final + +import typer + +from dstrack import cache, console, store +from dstrack.errors import ( + AmbiguousSnapshotError, + DatasetNotFoundError, + SnapshotNotFoundError, + StoreCorruptionError, +) + +# Shortest prefix accepted as a snapshot id. Below this almost any id matches, +# and the argument is far more likely to be a path. +_MIN_PREFIX_LEN: Final = 4 +# Longest, i.e. a full canonical UUID. +_MAX_PREFIX_LEN: Final = 36 +_HEX_CHARS: Final = frozenset("0123456789abcdefABCDEF-") +_SHORT_ID_LEN: Final = 8 +# Candidates listed back when a prefix is ambiguous. Enough to recognize the +# one that was meant, not a dump of the store. +_MAX_CANDIDATES: Final = 10 +# A comparison has exactly two sides. +_PAIR: Final = 2 + + +@dataclass(frozen=True) +class ResolvedSnapshot: + """One command-line argument, resolved to a snapshot and its payload. + + Attributes: + dataset_id: Dataset the snapshot belongs to. + snapshot_id: The snapshot itself. + payload: Its full stored payload. + spec: What the user typed, kept for error messages that follow. + """ + + dataset_id: str + snapshot_id: str + payload: dict[str, Any] + spec: str + + +def ensure_index( + store_root: Path, *, yes: bool, rebuild: bool, quiet: bool = False +) -> None: + """Make sure the snapshot index may be built, asking first if need be. + + Building parses every dataset's log, so it is offered rather than done + silently. Consent is only about the first build: an index that exists but + cannot be used is rebuilt without asking, since it holds nothing that the + logs do not. + + Args: + store_root: Path to the ``.dstrack/`` directory. + yes: Consent given up front, so nothing is asked. + rebuild: Discard any existing index, which implies consent. + quiet: Suppress the progress notes, for a command whose standard + output is a document rather than a report to a person. Errors are + still printed: a failure the caller has to act on is not noise. + + Raises: + typer.Exit: Code 0 if the user declines to build it; code 1 if there + is no index and no terminal to ask at. + """ + path = cache.index_path(store_root) + if rebuild: + path.unlink(missing_ok=True) + if not quiet: + console.info("Rebuilding the snapshot index from the dataset logs...") + return + if cache.index_exists(store_root): + return + + if not yes: + if not sys.stdin.isatty(): + console.error( + f"No snapshot index at `{path}`, and there is no terminal to " + "ask whether to build one. Re-run with `--yes` to build it." + ) + raise typer.Exit(code=1) + console.info(f"No snapshot index at `{path}` yet.") + if not typer.confirm("Build it now from the dataset logs?"): + console.warning( + "Not building the index; there is nothing to show without it." + ) + raise typer.Exit(code=0) + + if not quiet: + console.info("Building the snapshot index from the dataset logs...") + + +def resolve_target(target: str, *, store_root: Path, root: Path | None) -> str: + """Resolve a user-supplied dataset id or path to a dataset id. + + A ``dataset_id`` is a random UUID4, so nothing about it can be + derived from a path: a path is resolved by matching it against what each + dataset last recorded. `target` is read as an id first and as a path + second, but only when the id names a dataset that exists. Reading it as a + path first would let any file in the current directory shadow a real id, + and would send a mistyped path into the id lookup, which can only report + that no dataset goes by that name. + + Args: + target: A dataset id, or a path to a tracked dataset file. + store_root: Path to the ``.dstrack/`` directory. + root: Path root a `target` path is made relative to, or ``None`` to + use the store root. + + Returns: + The id of the dataset `target` names. + + Raises: + DatasetNotFoundError: If `target` is an id no dataset goes by, or a + path no dataset last recorded. + ValueError: If `target` and the path root are on different drives, so + no relative path exists between them. + """ + canonical = canonical_uuid(target) + if canonical is not None and store.dataset_exists(canonical, store_root=store_root): + return canonical + + dataset_path = recorded_path(target, store_root=store_root, root=root) + + matched = cache.find_dataset_by_path(dataset_path, store_root=store_root) + if matched is not None: + return matched + + raise dataset_not_found( + target, + canonical=canonical, + dataset_path=dataset_path, + store_root=store_root, + root=root, + ) + + +def recorded_path(target: str, *, store_root: Path, root: Path | None) -> str: + """Return the relative POSIX path a dataset at `target` would be recorded as. + + Mirrors what `dstrack track` stores, so that a path typed on the command + line and a path read out of a snapshot can be compared as strings. + + Args: + target: A path to a dataset file, as the user typed it. + store_root: Path to the ``.dstrack/`` directory. + root: Path root the result is made relative to, or ``None`` to use the + store root, i.e. the directory containing ``.dstrack/``. + + Returns: + The path relative to the path root, in POSIX form. + + Raises: + ValueError: If `target` and the path root are on different drives, so + no relative path exists between them. + """ + path_root = root if root is not None else store_root.parent + return Path( + os.path.relpath(Path(target).resolve(), Path(path_root).resolve()) + ).as_posix() + + +def dataset_not_found( + target: str, + *, + canonical: str | None, + dataset_path: str, + store_root: Path, + root: Path | None, +) -> Exception: + """Build the error for a `target` that names no dataset. + + An id-shaped argument and a path-shaped one fail for different reasons and + are fixed in different ways, so they are reported differently. Both end on + the store's contents, which is what lets a reader recognize the dataset + they meant. + + Args: + target: The argument as the user typed it. + canonical: `target` as a canonical UUID, or ``None`` if it is not one. + dataset_path: What `target` was matched as, when read as a path. + store_root: Path to the ``.dstrack/`` directory. + root: Path root `dataset_path` was computed against, or ``None``. + + Returns: + The exception to raise. Returned rather than raised so the caller's + traceback starts at the failure, not inside this helper. + """ + if canonical is not None: + return DatasetNotFoundError( + f"No dataset {target!r} in the store.\n{known_datasets(store_root)}" + ) + path_root = root if root is not None else store_root.parent + return DatasetNotFoundError( + f"No tracked dataset at {target!r} (recorded as {dataset_path!r} " + f"relative to {path_root}).\n" + "If the file was renamed or moved, its recorded path no longer " + "matches; pass the dataset id instead. Use `--root` to change the " + "path root the recorded path is computed against.\n" + f"{known_datasets(store_root)}" + ) + + +def canonical_uuid(value: str) -> str | None: + """Return `value` as a canonical UUID string, or ``None`` if it is not one. + + Datasets are named by ``str(uuid.uuid4())``, but a user may paste an id + uppercased, braced, or without its dashes, all of which name the same + dataset. Canonicalizing accepts those spellings, and makes the result + provably free of path separators before it is joined onto the store. + + Args: + value: The string to interpret. + + Returns: + The canonical form of `value` if it is a UUID in any spelling, else + ``None``. + """ + try: + return str(uuid.UUID(value)) + except (ValueError, AttributeError, TypeError): + return None + + +def known_datasets(store_root: Path) -> str: + """List the store's datasets, for an error message to end on. + + A dataset id says nothing on its own, so each is shown with the name and + path its latest snapshot recorded: that is what lets a reader recognize + the dataset they meant and copy its id. + + Args: + store_root: Path to the ``.dstrack/`` directory. + + Returns: + An indented listing of every dataset, or a note that there are none. + """ + datasets = cache.list_datasets(store_root=store_root) + lines = [ + f" {d.dataset_id} {(d.head.dataset_name if d.head else None) or '-'} " + f"{(d.head.dataset_path if d.head else None) or '-'}" + for d in datasets + ] + if not lines: + return "Known datasets:\n (none)\nRun `dstrack track ` to record one." + listing = "\n".join(lines) + return f"Known datasets:\n{listing}" + + +def resolve_snapshot_ref( + spec: str, *, store_root: Path, root: Path | None +) -> ResolvedSnapshot: + """Resolve one user-supplied reference to a snapshot. + + A reference is read in the order that keeps each reading from shadowing + the one before it: + + 1. a full id, which names either a snapshot outright or a dataset whose + latest snapshot is meant; + 2. the path of a tracked file, matched as `dstrack log` matches it; + 3. the leading characters of a snapshot id, the way git takes a short + commit hash. + + The prefix is tried *after* the path so that a file whose name happens to + be eight hex characters keeps the meaning `dstrack log` already gives it, + and *before* the failure so that a mistyped path is reported as a path + rather than as an unknown id. + + Args: + spec: The reference, as the user typed it. + store_root: Path to the ``.dstrack/`` directory. + root: Path root a `spec` path is made relative to, or ``None`` to use + the store root. + + Returns: + The snapshot it names, with its payload already read. + + Raises: + AmbiguousSnapshotError: If a prefix matches more than one snapshot. + DatasetNotFoundError: If `spec` names nothing in the store. + SnapshotNotFoundError: If it names a dataset that has no snapshots. + StoreCorruptionError: If the payload cannot be read back. + ValueError: If `spec` and the path root are on different drives. + """ + canonical = canonical_uuid(spec) + if canonical is not None: + located = _match_prefix(canonical, spec, store_root=store_root) + if located is not None: + return _load(located, spec, store_root=store_root) + if store.dataset_exists(canonical, store_root=store_root): + return _head_of(canonical, spec, store_root=store_root) + + dataset_path = recorded_path(spec, store_root=store_root, root=root) + matched = cache.find_dataset_by_path(dataset_path, store_root=store_root) + if matched is not None: + return _head_of(matched, spec, store_root=store_root) + + if canonical is None and _looks_like_prefix(spec): + located = _match_prefix(spec, spec, store_root=store_root) + if located is not None: + return _load(located, spec, store_root=store_root) + + raise dataset_not_found( + spec, + canonical=canonical, + dataset_path=dataset_path, + store_root=store_root, + root=root, + ) + + +def resolve_pair( + specs: Sequence[str], + *, + from_ref: str | None, + to_ref: str | None, + store_root: Path, + root: Path | None, +) -> tuple[ResolvedSnapshot, ResolvedSnapshot]: + """Resolve the two sides of a comparison from what the command was given. + + Two spellings are accepted and cannot be mixed: positional arguments, or + the explicit `--from` / `--to` options. Naming one side leaves the other + implied -- a single snapshot is compared against the one it succeeded, + which is the question `dstrack diff` is usually asked. + + Args: + specs: Positional references, in the order they were given. + from_ref: The baseline, named explicitly. + to_ref: The later snapshot, named explicitly. + store_root: Path to the ``.dstrack/`` directory. + root: Path root a path reference is made relative to. + + Returns: + The baseline and the later snapshot, in that order. + + Raises: + typer.BadParameter: If the two spellings are mixed, if more than two + positional references are given, or if none is given at all. + SnapshotNotFoundError: If an implied side does not exist, e.g. the + parent of a dataset's first snapshot. + """ + explicit = from_ref is not None or to_ref is not None + if explicit and specs: + raise typer.BadParameter( + "Name the snapshots either positionally or with --from/--to, not both." + ) + if len(specs) > _PAIR: + raise typer.BadParameter( + f"Expected at most two snapshots to compare, got {len(specs)}." + ) + if not explicit and not specs: + raise typer.BadParameter("Name at least one snapshot or dataset to compare.") + + def resolve(spec: str) -> ResolvedSnapshot: + return resolve_snapshot_ref(spec, store_root=store_root, root=root) + + if from_ref is not None and to_ref is not None: + return resolve(from_ref), resolve(to_ref) + if to_ref is not None: + new = resolve(to_ref) + return _parent_of(new, store_root=store_root), new + if from_ref is not None: + # Only the baseline was named, so the question is "what has happened + # since?", which the dataset's latest snapshot answers. + old = resolve(from_ref) + return old, _head_of(old.dataset_id, from_ref, store_root=store_root) + + if len(specs) == _PAIR: + return resolve(specs[0]), resolve(specs[1]) + + new = resolve(specs[0]) + return _parent_of(new, store_root=store_root), new + + +def _parent_of(snapshot: ResolvedSnapshot, *, store_root: Path) -> ResolvedSnapshot: + """Return the snapshot a resolved one succeeded. + + The parent is read from the payload already in hand rather than looked up + again: a snapshot records the one it followed, which makes it the + authoritative answer and a free one. + + Args: + snapshot: The snapshot whose predecessor is wanted. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The predecessor, with its payload read. + + Raises: + SnapshotNotFoundError: If the snapshot has no parent, i.e. it is the + first its dataset recorded. + """ + parent = snapshot.payload.get("parent_snapshot_id") + if not isinstance(parent, str) or not parent: + raise SnapshotNotFoundError( + f"Snapshot {snapshot.snapshot_id[:_SHORT_ID_LEN]} is the first " + f"snapshot of dataset {snapshot.dataset_id}; there is nothing to " + "compare it against. Name a second snapshot explicitly." + ) + return _load( + store.SnapshotLocation(dataset_id=snapshot.dataset_id, snapshot_id=parent), + snapshot.spec, + store_root=store_root, + ) + + +def _head_of(dataset_id: str, spec: str, *, store_root: Path) -> ResolvedSnapshot: + """Return a dataset's latest snapshot. + + Args: + dataset_id: The dataset. + spec: What the user typed to name it, for error messages. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The snapshot the dataset's ``HEAD`` names. + + Raises: + SnapshotNotFoundError: If the dataset has never been snapshotted. + """ + head = store.read_head(dataset_id, store_root=store_root) + if head is None: + raise SnapshotNotFoundError( + f"Dataset {dataset_id} has no snapshots yet, so there is nothing " + "to compare. Run `dstrack track ` first." + ) + return _load( + store.SnapshotLocation(dataset_id=dataset_id, snapshot_id=head), + spec, + store_root=store_root, + ) + + +def _load( + location: store.SnapshotLocation, spec: str, *, store_root: Path +) -> ResolvedSnapshot: + """Read a located snapshot's payload. + + Args: + location: Which dataset and snapshot to read. + spec: What the user typed, carried through for error messages. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The snapshot with its payload. + + Raises: + SnapshotNotFoundError: If the payload is not on disk. + StoreCorruptionError: If it cannot be read back as a JSON object. + """ + return ResolvedSnapshot( + dataset_id=location.dataset_id, + snapshot_id=location.snapshot_id, + payload=store.read_snapshot( + location.dataset_id, location.snapshot_id, store_root=store_root + ), + spec=spec, + ) + + +def _looks_like_prefix(spec: str) -> bool: + """Return whether `spec` could be the leading characters of a snapshot id. + + Args: + spec: The reference, as the user typed it. + + Returns: + ``True`` if it is hex-and-dashes and of a plausible length. A shorter + string would match almost anything, and a longer one is not a UUID at + all. + """ + return _MIN_PREFIX_LEN <= len(spec) <= _MAX_PREFIX_LEN and set(spec) <= _HEX_CHARS + + +def _match_prefix( + prefix: str, spec: str, *, store_root: Path +) -> store.SnapshotLocation | None: + """Find the one snapshot whose id starts with `prefix`. + + Args: + prefix: The canonical id or leading characters to match. + spec: What the user typed, for the error message. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The single match, or ``None`` if nothing matched. + + Raises: + AmbiguousSnapshotError: If more than one snapshot matched. + """ + matches = cache.find_snapshots_by_prefix( + prefix, store_root=store_root, limit=_MAX_CANDIDATES + ) + if not matches: + return None + if len(matches) == 1: + return matches[0] + raise AmbiguousSnapshotError( + f"Snapshot id {spec!r} is ambiguous; it matches {len(matches)} " + f"snapshots:\n{_candidates(matches, store_root=store_root)}\n" + "Give more characters of the id." + ) + + +def _candidates(matches: Sequence[store.SnapshotLocation], *, store_root: Path) -> str: + """Describe ambiguous matches well enough to tell them apart. + + A bare list of ids is no help in choosing between them, so each is shown + with what its snapshot recorded. A payload that cannot be read is still + listed by id: it is a candidate whether or not it describes itself. + + Args: + matches: The snapshots a prefix matched. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + One indented line per candidate. + """ + lines = [] + for match in matches: + try: + payload = store.read_snapshot( + match.dataset_id, match.snapshot_id, store_root=store_root + ) + except (OSError, SnapshotNotFoundError, StoreCorruptionError): + payload = {} + name = payload.get("dataset_name") or "-" + created = payload.get("created_at") or "-" + lines.append( + f" {match.snapshot_id[:_SHORT_ID_LEN]} {name} {created} " + f"dataset {match.dataset_id}" + ) + return "\n".join(lines) diff --git a/src/dstrack/assets/diff-report.png b/src/dstrack/assets/diff-report.png new file mode 100644 index 0000000..8458f1a Binary files /dev/null and b/src/dstrack/assets/diff-report.png differ diff --git a/src/dstrack/assets/dstrack-logo.png b/src/dstrack/assets/dstrack-logo.png new file mode 100644 index 0000000..ae0d18b Binary files /dev/null and b/src/dstrack/assets/dstrack-logo.png differ diff --git a/src/dstrack/cache.py b/src/dstrack/cache.py index 547cda7..a9f6f3b 100644 --- a/src/dstrack/cache.py +++ b/src/dstrack/cache.py @@ -28,13 +28,13 @@ import contextlib import logging import sqlite3 -from collections.abc import Iterator +from collections.abc import Generator from pathlib import Path from typing import Final from dstrack import store from dstrack.errors import IndexUnusable -from dstrack.store import DatasetSummary, LogEntry +from dstrack.store import DatasetSummary, LogEntry, SnapshotLocation _log = logging.getLogger(__name__) @@ -45,6 +45,9 @@ # than querying it with the wrong shape. _SCHEMA_VERSION: Final = "1" +# Escape character for SQL ``LIKE`` patterns built from user-supplied text. +_LIKE_ESCAPE: Final = "\\" + _SCHEMA: Final = """ CREATE TABLE meta ( key TEXT PRIMARY KEY, @@ -222,6 +225,62 @@ def find_dataset_by_path(dataset_path: str, *, store_root: Path) -> str | None: return dataset_id +def find_snapshots_by_prefix( + prefix: str, *, store_root: Path, limit: int = 10 +) -> list[SnapshotLocation]: + """Return the snapshots whose id starts with `prefix`, sorted by id. + + Snapshot ids are UUID4s, which nobody types in full, so a user names one by + its leading characters the way git takes a short commit hash. The lookup + spans every dataset: a snapshot id says nothing about which dataset holds + it, so which one does is part of the answer rather than part of the + question. + + Args: + prefix: Leading characters of a snapshot id. Matched literally -- + SQL's ``LIKE`` wildcards are escaped, so a prefix containing ``_`` + or ``%`` matches only ids that really contain them. + store_root: Path to the ``.dstrack/`` directory. + limit: Cap on how many matches are returned. Reporting an ambiguous + prefix needs enough candidates to list back to the user, not all + of them. + + Returns: + Every matching snapshot, sorted by id and capped at `limit`. Empty if + the prefix matches nothing, or the index holds no snapshots yet. + """ + with _connect(store_root) as conn: + rows = conn.execute( + "SELECT dataset_id, snapshot_id FROM snapshots " + f"WHERE snapshot_id LIKE ? ESCAPE '{_LIKE_ESCAPE}' " + "ORDER BY snapshot_id LIMIT ?", + (f"{_escape_like(prefix)}%", limit), + ).fetchall() + return [ + SnapshotLocation(dataset_id=row["dataset_id"], snapshot_id=row["snapshot_id"]) + for row in rows + ] + + +def _escape_like(value: str) -> str: + """Escape a string for use as a literal prefix in a SQL ``LIKE`` pattern. + + ``_`` matches any single character and ``%`` any run of them, so a prefix + carrying either would silently match ids the user did not name. The escape + character itself has to go first, or escaping the others would re-escape + the backslashes just introduced. + + Args: + value: The literal text to match. + + Returns: + `value` with every ``LIKE`` meta character escaped. + """ + for char in (_LIKE_ESCAPE, "%", "_"): + value = value.replace(char, f"{_LIKE_ESCAPE}{char}") + return value + + def list_datasets(*, store_root: Path) -> list[DatasetSummary]: """Return every indexed dataset, described by its latest snapshot. @@ -248,7 +307,7 @@ def list_datasets(*, store_root: Path) -> list[DatasetSummary]: @contextlib.contextmanager -def _connect(store_root: Path) -> Iterator[sqlite3.Connection]: +def _connect(store_root: Path) -> Generator[sqlite3.Connection]: """Open the index, creating its parent directory if needed. Args: diff --git a/src/dstrack/console.py b/src/dstrack/console.py index c19eec2..98ee323 100644 --- a/src/dstrack/console.py +++ b/src/dstrack/console.py @@ -27,6 +27,16 @@ def display(renderable: RenderableType) -> None: console.print(renderable) +def width() -> int: + """Return the console's current width in cells. + + For output whose layout has to adapt to the terminal. Reading it here + rather than inside a renderer keeps the renderer a pure function of its + arguments, and so testable at any width. + """ + return console.width + + def success(message: str) -> None: """Print a success message prefixed with a check mark.""" console.print(f"[bold green]\N{HEAVY CHECK MARK}[/bold green] {escape(message)}") diff --git a/src/dstrack/diff/__init__.py b/src/dstrack/diff/__init__.py new file mode 100644 index 0000000..65e7eaa --- /dev/null +++ b/src/dstrack/diff/__init__.py @@ -0,0 +1,103 @@ +"""Comparison of two dataset snapshots. + +Takes two snapshot payloads -- the plain dicts +[SnapshotBuilder][dstrack.snapshot.SnapshotBuilder] produces and +[write_snapshot][dstrack.store.write_snapshot] stores -- and reports what +changed between them: schema, dataset-level metrics, and per-column +statistics, each classified by how much it should worry the reader. + +Neither input has to come from a store. Every field is read defensively and +anything missing degrades to "not recorded", so a dict assembled by hand, or +one written by a newer dstrack, is compared as far as it can be rather than +refused. + +Layers +------ +- [diff_snapshots][dstrack.diff.diff_snapshots] is the whole public entry + point. +- [SnapshotDiff.to_dict][dstrack.diff.SnapshotDiff.to_dict] renders that result + as plain JSON-serializable data, for a pipeline to consume. +- [DiffOptions][dstrack.diff.DiffOptions] carries the thresholds that decide + unchanged from changed from significant; [preset][dstrack.diff.preset] + returns the named sensitivities the CLI offers. + +Examples: + ```python + >>> from dstrack.diff import diff_snapshots + >>> old = { + ... "dataset_name": "customers", + ... "num_rows": 100, + ... "columns": [{"name": "age", "dtype": "int64", "nullable": True}], + ... "column_stats": { + ... "age": {"null_count": 0, "null_fraction": 0.0, "mean": 30.0, "std": 5.0} + ... }, + ... } + >>> new = { + ... "dataset_name": "customers", + ... "num_rows": 150, + ... "columns": [{"name": "age", "dtype": "float64", "nullable": True}], + ... "column_stats": { + ... "age": {"null_count": 15, "null_fraction": 0.1, "mean": 42.0, "std": 5.0} + ... }, + ... } + >>> result = diff_snapshots(old, new) + >>> result.severity.value + 'critical' + >>> [change.name for change in result.schema.type_changed] + ['age'] + >>> result.summary.row_delta + 50 + >>> sorted(result.to_dict())[:3] + ['columns', 'diff_format_version', 'identical'] + + ``` + + Comparing two snapshots already in a store means reading their payloads + first, with [read_snapshot][dstrack.store.read_snapshot]. +""" + +from dstrack.diff._engine import diff_snapshots +from dstrack.diff._model import ( + DEFAULT_OPTIONS, + DIFF_FORMAT_VERSION, + PRESETS, + ColumnChangeKind, + ColumnKind, + ColumnSchemaChange, + ColumnStatDiff, + DiffOptions, + DiffSummary, + DistributionShift, + MetricDiff, + SchemaDiff, + SetChange, + Severity, + ShiftMethod, + SnapshotDiff, + SnapshotRef, + preset, + worst, +) + +__all__ = [ + "DEFAULT_OPTIONS", + "DIFF_FORMAT_VERSION", + "PRESETS", + "ColumnChangeKind", + "ColumnKind", + "ColumnSchemaChange", + "ColumnStatDiff", + "DiffOptions", + "DiffSummary", + "DistributionShift", + "MetricDiff", + "SchemaDiff", + "SetChange", + "Severity", + "ShiftMethod", + "SnapshotDiff", + "SnapshotRef", + "diff_snapshots", + "preset", + "worst", +] diff --git a/src/dstrack/diff/_engine.py b/src/dstrack/diff/_engine.py new file mode 100644 index 0000000..632e294 --- /dev/null +++ b/src/dstrack/diff/_engine.py @@ -0,0 +1,1080 @@ +"""Comparing two snapshot payloads and classifying what changed. + +Three kinds of comparison happen here, and they are kept apart on purpose. + +*Structural* comparison is exact: a column is in both column lists or it is +not, and its dtype either matches or it does not. Nothing is inferred and +nothing is thresholded. + +*Scalar* comparison asks how far a number moved, and needs to know what kind of +number it is. A count is judged by its relative change; a quantity already +expressed as a fraction of one is judged by its absolute shift, because +relative change is meaningless near zero -- a null fraction going from 0.001 to +0.002 has doubled and means nothing. + +*Distributional* comparison asks whether a column's values still fall where +they used to, which no single scalar answers. Each column kind gets the measure +its recorded statistics can support, and every score is reported alongside the +[ShiftMethod][dstrack.diff._model.ShiftMethod] that produced it, since the +scales are not comparable to each other. +""" + +import math +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any, Final + +from dstrack.diff import _extract +from dstrack.diff._model import ( + DEFAULT_OPTIONS, + ColumnChangeKind, + ColumnKind, + ColumnSchemaChange, + ColumnStatDiff, + DiffOptions, + DiffSummary, + DistributionShift, + MetricDiff, + SchemaDiff, + SetChange, + Severity, + ShiftMethod, + SnapshotDiff, + SnapshotRef, + worst, +) +from dstrack.errors import IncompatibleSnapshotError +from dstrack.snapshot._version import SUPPORTED_FORMAT_VERSIONS + +# Metrics that are already fractions of one, and so are judged by how far they +# moved in absolute terms rather than by their relative change. +_FRACTION_METRICS: Final[frozenset[str]] = frozenset( + { + "null_fraction", + "duplicate_row_fraction", + "top_values_coverage", + "near_duplicate_estimate", + } +) + +# Dataset-level scalars, in the order they are rendered. +_DATASET_METRICS: Final[tuple[str, ...]] = ( + "num_rows", + "num_columns", + "duplicate_row_fraction", + "near_duplicate_estimate", +) + +# Set-valued dataset fields. Joining one is a quality signal worth acting on; +# leaving it is good news, and is reported without raising the alarm. +_SET_FIELDS: Final[tuple[str, ...]] = ("constant_columns", "high_null_columns") + +_NULL_METRICS: Final[tuple[str, ...]] = ("null_fraction", "null_count") +_PERCENTILES: Final[tuple[str, ...]] = ("p5", "p25", "p50", "p75", "p95", "p99") +_NUMERIC_METRICS: Final[tuple[str, ...]] = ( + *_NULL_METRICS, + "num_unique", + "mean", + "std", + "min", + "max", +) +_STRING_METRICS: Final[tuple[str, ...]] = ( + *_NULL_METRICS, + "num_unique", + "top_values_coverage", + "avg_char_length", + "min_char_length", + "max_char_length", + "avg_token_count", + "min_token_count", + "max_token_count", +) +_DATETIME_METRICS: Final[tuple[str, ...]] = (*_NULL_METRICS, "range_days") + +# Smoothing for PSI. A bucket empty on one side makes the log term infinite, so +# every bucket is credited half an observation before the counts are turned into +# proportions -- the Jeffreys prior. A fixed floor would do as well at keeping +# the arithmetic finite, but not at keeping the answer meaningful: it treats an +# empty bucket in a four-row snapshot as being as certainly empty as one in a +# four-million-row snapshot, and reports the first as near-total drift. Half an +# observation is instead a large share of a tiny sample and a negligible one of +# a large sample, which is exactly how confident the emptiness is. +_PSI_PRIOR: Final = 0.5 +# The bucket standing for everything outside a string column's top values, so +# that a shift confined to the untracked tail is still visible. +_OTHER_BUCKET: Final = "\x00other" +_MIN_HISTOGRAM_EDGES: Final = 2 +# Below this a measured shift is floating-point noise, not movement. Two +# identical histograms can score a few multiples of epsilon rather than a +# clean zero, and that must still read as "nothing changed". +_NEGLIGIBLE: Final = 1e-9 + + +def diff_snapshots( + old: Mapping[str, Any], + new: Mapping[str, Any], + *, + options: DiffOptions | None = None, + old_dataset_id: str | None = None, + new_dataset_id: str | None = None, +) -> SnapshotDiff: + """Compare two snapshot payloads. + + Args: + old: The earlier snapshot's payload, i.e. the baseline. + new: The later snapshot's payload. + options: Thresholds deciding what counts as a significant change. + Defaults to [DEFAULT_OPTIONS][dstrack.diff._model.DEFAULT_OPTIONS]. + old_dataset_id: Dataset the baseline belongs to, if known. Never + present in a payload, so it can only come from whoever read it. + new_dataset_id: Dataset the later snapshot belongs to, if known. + + Returns: + Everything that differs between them, each difference classified by + severity. + + Raises: + IncompatibleSnapshotError: If either payload records a snapshot format + version this dstrack cannot read. Any supported version is accepted, + including two different ones, and a payload that records none is + compared as far as its fields allow. + """ + active = options if options is not None else DEFAULT_OPTIONS + + old_ref = _extract.snapshot_ref(old, dataset_id=old_dataset_id) + new_ref = _extract.snapshot_ref(new, dataset_id=new_dataset_id) + _check_format_version(old_ref) + _check_format_version(new_ref) + + schema = _diff_schema(old, new, old_ref=old_ref, new_ref=new_ref) + metrics = _diff_dataset_metrics(old, new, options=active) + sets = _diff_sets(old, new) + columns = _diff_columns(old, new, options=active) + + severity = worst( + [ + schema.severity, + worst(m.severity for m in metrics), + worst(s.severity for s in sets), + worst(c.severity for c in columns), + ] + ) + return SnapshotDiff( + old=old_ref, + new=new_ref, + identical=severity is Severity.UNCHANGED and not schema.changed, + same_dataset=_same_dataset(old_ref, new_ref), + source_hash_equal=_hashes_equal(old_ref.source_hash, new_ref.source_hash), + schema=schema, + metrics=metrics, + sets=sets, + columns=columns, + summary=_summarize(schema, columns, metrics, severity=severity), + severity=severity, + options=active, + ) + + +def _check_format_version(ref: SnapshotRef) -> None: + """Raise if a snapshot records a format version that cannot be compared. + + Only a version that is *recorded and unknown* is refused. Each side is + checked against every version this dstrack reads, not against the one it + writes, so snapshots taken before an upgrade stay comparable -- with each + other and with snapshots taken after it. A payload recording none is a + hand-built dict, which is a supported input; refusing it would make the + library unusable for anything but stored snapshots. + + Args: + ref: The identity fields read from one side. + + Raises: + IncompatibleSnapshotError: If the recorded version is not one this + dstrack can read, typically because a newer dstrack wrote it. + """ + if ( + ref.format_version is not None + and ref.format_version not in SUPPORTED_FORMAT_VERSIONS + ): + supported = ", ".join(repr(v) for v in sorted(SUPPORTED_FORMAT_VERSIONS)) + raise IncompatibleSnapshotError( + f"Snapshot {ref.snapshot_id or ''} is in format version " + f"{ref.format_version!r}, which this dstrack cannot read " + f"(supported: {supported}). It may have been written by a newer " + "dstrack." + ) + + +def _same_dataset(old: SnapshotRef, new: SnapshotRef) -> bool: + """Return whether both sides are known to belong to the same dataset. + + Args: + old: Identity of the baseline. + new: Identity of the later snapshot. + + Returns: + ``True`` when both carry the same dataset id, or when neither carries + one at all -- two hand-built payloads are taken at face value as two + versions of one thing, which is what a caller comparing them means. + """ + if old.dataset_id is None and new.dataset_id is None: + return True + return old.dataset_id == new.dataset_id + + +def _hashes_equal(old: str | None, new: str | None) -> bool: + """Return whether two recorded source hashes prove identical bytes. + + Args: + old: The baseline's ``source_hash``. + new: The later snapshot's ``source_hash``. + + Returns: + ``True`` only if both are present, non-empty, and equal. An empty hash + is what a non-file source records, and two of them say nothing about + whether the sources matched. + """ + return bool(old) and bool(new) and old == new + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +def _diff_schema( + old: Mapping[str, Any], + new: Mapping[str, Any], + *, + old_ref: SnapshotRef, + new_ref: SnapshotRef, +) -> SchemaDiff: + """Compare the two snapshots' column lists. + + A renamed column reads as one removal and one addition. Snapshots carry no + row-level identity, so the only way to guess otherwise is "same dtype, + similar statistics", which cannot tell a rename from a genuine drop-and-add + of two similar features -- and a wrong guess silently corrupts the + per-column drift table, which is the part a reader acts on. + + Args: + old: The baseline payload. + new: The later payload. + old_ref: Identity fields already read from `old`. + new_ref: Identity fields already read from `new`. + + Returns: + Every schema-level difference, classified. + """ + old_columns = _extract.column_schema(old) + new_columns = _extract.column_schema(new) + + added = tuple( + ColumnSchemaChange( + name=name, + kind=ColumnChangeKind.ADDED, + old_dtype=None, + new_dtype=entry.dtype, + old_nullable=None, + new_nullable=entry.nullable, + severity=Severity.WARNING, + ) + for name, entry in new_columns.items() + if name not in old_columns + ) + removed = tuple( + ColumnSchemaChange( + name=name, + kind=ColumnChangeKind.REMOVED, + old_dtype=entry.dtype, + new_dtype=None, + old_nullable=entry.nullable, + new_nullable=None, + # Critical rather than merely notable: anything reading this + # dataset by name breaks outright, where an addition it ignores. + severity=Severity.CRITICAL, + ) + for name, entry in old_columns.items() + if name not in new_columns + ) + + type_changed: list[ColumnSchemaChange] = [] + nullability_changed: list[ColumnSchemaChange] = [] + reordered: list[str] = [] + for name, new_entry in new_columns.items(): + old_entry = old_columns.get(name) + if old_entry is None: + continue + if old_entry.dtype != new_entry.dtype: + type_changed.append( + ColumnSchemaChange( + name=name, + kind=ColumnChangeKind.TYPE_CHANGED, + old_dtype=old_entry.dtype, + new_dtype=new_entry.dtype, + old_nullable=old_entry.nullable, + new_nullable=new_entry.nullable, + severity=Severity.CRITICAL, + ) + ) + elif old_entry.nullable != new_entry.nullable: + nullability_changed.append( + ColumnSchemaChange( + name=name, + kind=ColumnChangeKind.NULLABILITY_CHANGED, + old_dtype=old_entry.dtype, + new_dtype=new_entry.dtype, + old_nullable=old_entry.nullable, + new_nullable=new_entry.nullable, + severity=Severity.INFO, + ) + ) + if old_entry.position != new_entry.position: + reordered.append(name) + + changes = (*added, *removed, *type_changed, *nullability_changed) + return SchemaDiff( + added=added, + removed=removed, + type_changed=tuple(type_changed), + nullability_changed=tuple(nullability_changed), + reordered=tuple(reordered), + schema_hash_equal=_schema_hashes_equal(old_ref, new_ref, changes), + severity=worst(c.severity for c in changes), + ) + + +def _schema_hashes_equal( + old: SnapshotRef, new: SnapshotRef, changes: Sequence[ColumnSchemaChange] +) -> bool: + """Return whether both sides describe the same schema. + + Args: + old: Identity of the baseline. + new: Identity of the later snapshot. + changes: The column-level differences already found. + + Returns: + The recorded hashes' equality when both sides recorded one, else + whether any column-level difference was found. The hash is only a + shortcut; the column lists are what the rest of the diff is built from. + """ + if old.schema_hash and new.schema_hash: + return old.schema_hash == new.schema_hash + return not changes + + +# --------------------------------------------------------------------------- +# Scalars +# --------------------------------------------------------------------------- + + +def _diff_dataset_metrics( + old: Mapping[str, Any], new: Mapping[str, Any], *, options: DiffOptions +) -> tuple[MetricDiff, ...]: + """Compare the dataset-level scalars both payloads carry. + + Args: + old: The baseline payload. + new: The later payload. + options: The active thresholds. + + Returns: + One entry per metric at least one side recorded, in render order. A + metric neither side recorded is omitted entirely, which is how fields + the current builders never produce -- ``near_duplicate_estimate`` and + the row sketches -- are tolerated without being special-cased. + """ + return tuple( + diff + for key in _DATASET_METRICS + if ( + diff := _compare( + key, + _extract.number(old.get(key)), + _extract.number(new.get(key)), + options=options, + ) + ) + is not None + ) + + +def _compare( + key: str, old: float | None, new: float | None, *, options: DiffOptions +) -> MetricDiff | None: + """Compare one scalar and classify how far it moved. + + Args: + key: The metric's stable name, which decides how it is judged. + old: The baseline value, or ``None`` if not recorded. + new: The later value, or ``None`` if not recorded. + options: The active thresholds. + + Returns: + The comparison, or ``None`` if neither side recorded the metric and + there is therefore nothing to say about it. + """ + if old is None and new is None: + return None + if old is None or new is None: + # A metric appearing or disappearing is worth showing, but it is a + # change in what was recorded, not a change in the data. + return MetricDiff( + key=key, + old=old, + new=new, + delta=None, + pct_change=None, + severity=Severity.INFO, + ) + + if math.isclose( + old, new, rel_tol=options.rel_tolerance, abs_tol=options.abs_tolerance + ): + return MetricDiff(key, old, new, 0.0, 0.0, Severity.UNCHANGED) + + delta = new - old + pct_change = delta / abs(old) if old != 0 else None + return MetricDiff( + key=key, + old=old, + new=new, + delta=delta, + pct_change=pct_change, + severity=_classify(key, delta=delta, pct_change=pct_change, options=options), + ) + + +def _classify( + key: str, *, delta: float, pct_change: float | None, options: DiffOptions +) -> Severity: + """Grade a change that is already known to be real. + + Args: + key: The metric's stable name. + delta: How far the value moved. + pct_change: The move as a fraction of the baseline, or ``None`` when + the baseline was zero. + options: The active thresholds. + + Returns: + The severity to report. + """ + if key in _FRACTION_METRICS: + return _band( + abs(delta), options.warn_fraction_shift, options.critical_fraction_shift + ) + if pct_change is None: + # Zero to something. Structurally notable -- no rows to some rows, no + # nulls to some nulls -- and on no relative scale that could grade it. + return Severity.WARNING + return _band(abs(pct_change), options.warn_pct_change, options.critical_pct_change) + + +def _band(magnitude: float, warn: float, critical: float) -> Severity: + """Place a magnitude in a severity band. + + Args: + magnitude: The absolute size of the change. + warn: Where ``WARNING`` starts. + critical: Where ``CRITICAL`` starts. + + Returns: + The band it falls in. Anything real but below `warn` is ``INFO``: it + happened, and the reader decides whether it matters. A magnitude at + the floor is ``UNCHANGED``, which is what keeps a snapshot compared + against itself reporting as identical -- the distribution measures are + graded here directly, and for them zero means zero rather than "too + small to have been filtered out already". + """ + if magnitude <= _NEGLIGIBLE: + return Severity.UNCHANGED + if magnitude >= critical: + return Severity.CRITICAL + if magnitude >= warn: + return Severity.WARNING + return Severity.INFO + + +def _diff_sets(old: Mapping[str, Any], new: Mapping[str, Any]) -> tuple[SetChange, ...]: + """Compare the set-valued dataset fields. + + Args: + old: The baseline payload. + new: The later payload. + + Returns: + One entry per field whose membership changed. A column *joining* + ``constant_columns`` or ``high_null_columns`` is a warning -- a feature + just went dead or mostly-null -- while one leaving is reported as + information, since a column that started varying again is good news. + """ + changes: list[SetChange] = [] + for key in _SET_FIELDS: + before = set(_extract.string_tuple(old.get(key))) + after = set(_extract.string_tuple(new.get(key))) + entered = tuple(sorted(after - before)) + left = tuple(sorted(before - after)) + if not entered and not left: + continue + changes.append( + SetChange( + key=key, + added=entered, + removed=left, + severity=Severity.WARNING if entered else Severity.INFO, + ) + ) + return tuple(changes) + + +# --------------------------------------------------------------------------- +# Columns +# --------------------------------------------------------------------------- + + +def _diff_columns( + old: Mapping[str, Any], new: Mapping[str, Any], *, options: DiffOptions +) -> tuple[ColumnStatDiff, ...]: + """Compare every column's statistics. + + Args: + old: The baseline payload. + new: The later payload. + options: The active thresholds. + + Returns: + One entry per column either side recorded statistics for, ordered by + the new schema and then by name, so the output reads in the order the + dataset's columns actually appear. + """ + old_stats = _extract.column_stats(old) + new_stats = _extract.column_stats(new) + old_schema = _extract.column_schema(old) + new_schema = _extract.column_schema(new) + + ordered = list(new_schema) + ordered += sorted(set(new_stats) - set(ordered)) + ordered += sorted((set(old_stats) | set(old_schema)) - set(ordered)) + + return tuple( + _diff_column( + name, + old_stats.get(name), + new_stats.get(name), + old_dtype=old_schema[name].dtype if name in old_schema else None, + new_dtype=new_schema[name].dtype if name in new_schema else None, + old_rows=_extract.integer(old.get("num_rows")), + new_rows=_extract.integer(new.get("num_rows")), + options=options, + ) + for name in ordered + if name in old_stats or name in new_stats + ) + + +def _diff_column( + name: str, + old: Mapping[str, Any] | None, + new: Mapping[str, Any] | None, + *, + old_dtype: str | None, + new_dtype: str | None, + old_rows: int | None, + new_rows: int | None, + options: DiffOptions, +) -> ColumnStatDiff: + """Compare one column's statistics. + + Args: + name: The column's name. + old: Its baseline statistics, or ``None`` if the column is new. + new: Its later statistics, or ``None`` if the column was dropped. + old_dtype: Its baseline dtype, used only as a hint about which + statistics variant to expect. + new_dtype: Its later dtype. + old_rows: The baseline's row count, for normalizing category counts. + new_rows: The later row count. + options: The active thresholds. + + Returns: + The column's comparison. When the two sides hold different statistics + variants, only the null counts are comparable, and the change is + reported as critical: the column now holds a different kind of value. + """ + old_kind = _extract.column_kind(old, old_dtype) if old is not None else None + new_kind = _extract.column_kind(new, new_dtype) if new is not None else None + kind = new_kind if new_kind is not None else (old_kind or ColumnKind.UNKNOWN) + + kind_changed = ( + old_kind is not None and new_kind is not None and old_kind != new_kind + ) + keys = _NULL_METRICS if kind_changed else _metric_keys(kind) + + metrics = tuple( + diff + for key in keys + if ( + diff := _compare( + key, + _stat(old, key), + _stat(new, key), + options=options, + ) + ) + is not None + ) + shift = ( + None + if kind_changed or old is None or new is None + else _distribution_shift( + kind, + old, + new, + old_rows=old_rows, + new_rows=new_rows, + options=options, + ) + ) + + severities = [m.severity for m in metrics] + if shift is not None: + severities.append(shift.severity) + if kind_changed: + severities.append(Severity.CRITICAL) + elif old is None or new is None: + # The schema diff already reports the add or drop with its own + # severity; repeating it here would double-count it in the roll-up. + severities.append(Severity.UNCHANGED) + + return ColumnStatDiff( + name=name, + kind=kind, + old_kind=old_kind if kind_changed else None, + present_in_old=old is not None, + present_in_new=new is not None, + metrics=metrics, + distribution_shift=shift, + severity=worst(severities), + ) + + +def _metric_keys(kind: ColumnKind) -> tuple[str, ...]: + """Return the scalars worth comparing for a statistics variant. + + Args: + kind: The variant the column's entry holds. + + Returns: + The metric keys, in render order. + """ + if kind is ColumnKind.NUMERIC: + return (*_NUMERIC_METRICS, *(f"percentiles.{p}" for p in _PERCENTILES)) + if kind is ColumnKind.STRING: + return _STRING_METRICS + if kind is ColumnKind.DATETIME: + return _DATETIME_METRICS + return _NULL_METRICS + + +def _stat(stats: Mapping[str, Any] | None, key: str) -> float | None: + """Read one statistic out of a column's entry. + + Args: + stats: The column's statistics, or ``None``. + key: The metric's name, dotted for a nested field such as + ``"percentiles.p50"``. + + Returns: + The value as a float, or ``None`` if it is absent or unusable. + Datetime bounds are ISO strings rather than numbers, and are converted + to epoch seconds so they compare like any other scalar. + """ + if stats is None: + return None + + head, _, tail = key.partition(".") + if tail: + nested = stats.get(head) + return ( + _extract.number(nested.get(tail)) if isinstance(nested, Mapping) else None + ) + + value = stats.get(key) + if key in ("min", "max") and isinstance(value, str): + return _epoch_seconds(value) + return _extract.number(value) + + +def _epoch_seconds(value: str) -> float | None: + """Parse an ISO-8601 timestamp into epoch seconds. + + Args: + value: The recorded timestamp, as a datetime column's bound records it. + + Returns: + The moment as epoch seconds, or ``None`` if it cannot be parsed. A + trailing ``Z`` is translated first, matching how the stats computer + reads these values back. + """ + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Distributions +# --------------------------------------------------------------------------- + + +def _distribution_shift( + kind: ColumnKind, + old: Mapping[str, Any], + new: Mapping[str, Any], + *, + old_rows: int | None, + new_rows: int | None, + options: DiffOptions, +) -> DistributionShift | None: + """Measure how far a column's value distribution moved. + + Args: + kind: The statistics variant, which decides the measure. + old: The column's baseline statistics. + new: The column's later statistics. + old_rows: The baseline's row count. + new_rows: The later row count. + options: The active thresholds. + + Returns: + The shift, or ``None`` for a variant that records no distribution at + all -- a datetime or unrecognized column has bounds, not a shape. + """ + if kind is ColumnKind.NUMERIC: + return _numeric_shift(old, new, options=options) + if kind is ColumnKind.STRING: + return _string_shift( + old, new, old_rows=old_rows, new_rows=new_rows, options=options + ) + return None + + +def _numeric_shift( + old: Mapping[str, Any], new: Mapping[str, Any], *, options: DiffOptions +) -> DistributionShift: + """Measure a numeric column's shift, preferring PSI over its histogram. + + Args: + old: The column's baseline statistics. + new: The column's later statistics. + options: The active thresholds. + + Returns: + A PSI score when both histograms are usable, else a percentile-based + fallback, else a ``NONE`` shift saying nothing could be measured. + """ + psi = _population_stability_index(old.get("histogram"), new.get("histogram")) + if psi is not None: + return DistributionShift( + method=ShiftMethod.PSI, + score=psi, + severity=_band(psi, options.warn_psi, options.critical_psi), + ) + + fallback = _percentile_shift(old.get("percentiles"), new.get("percentiles")) + if fallback is not None: + return DistributionShift( + method=ShiftMethod.PERCENTILE_SHIFT, + score=fallback, + severity=_band( + fallback, + options.warn_percentile_shift, + options.critical_percentile_shift, + ), + detail="histogram unusable; compared on percentiles", + ) + + return DistributionShift( + method=ShiftMethod.NONE, + score=None, + severity=Severity.UNCHANGED, + detail="no comparable distribution recorded", + ) + + +def _population_stability_index(old: Any, new: Any) -> float | None: + """Return the PSI between two histograms, or ``None`` if it cannot be computed. + + The two histograms rarely share bin edges, since each snapshot's edges are + derived from its own minimum and maximum. Counts cannot be re-binned + exactly, so each source bin's count is spread across the bins of a shared + grid in proportion to how much of it they overlap -- the piecewise-uniform + density assumption, which is the standard choice and needs no dependency. + + Args: + old: The baseline ``histogram`` mapping. + new: The later ``histogram`` mapping. + + Returns: + The PSI, or ``None`` when either histogram is unusable: too few edges, + a count list that does not match its edges, no observations at all, or + the single degenerate bin a constant column produces, none of which + describe a shape that can be compared. + """ + old_grid = _histogram(old) + new_grid = _histogram(new) + if old_grid is None or new_grid is None: + return None + + old_edges, old_counts = old_grid + new_edges, new_counts = new_grid + + lower = min(old_edges[0], new_edges[0]) + upper = max(old_edges[-1], new_edges[-1]) + union = sorted({e for e in (*old_edges, *new_edges) if lower <= e <= upper}) + union = [lower, *[e for e in union if lower < e < upper], upper] + if len(union) < _MIN_HISTOGRAM_EDGES: + return None + + old_p = _proportions(_redistribute(old_edges, old_counts, union)) + new_p = _proportions(_redistribute(new_edges, new_counts, union)) + + return sum( + (after - before) * math.log(after / before) + for before, after in zip(old_p, new_p, strict=True) + ) + + +def _proportions(counts: Sequence[float]) -> list[float]: + """Turn bin counts into smoothed proportions summing to one. + + Args: + counts: One count per bin of the shared grid. + + Returns: + The proportions, every one of them strictly positive so the PSI's + logarithms are defined. See + [_PSI_PRIOR][dstrack.diff._engine._PSI_PRIOR] for why the smoothing is + a share of an observation rather than a fixed floor. + """ + total = sum(counts) + _PSI_PRIOR * len(counts) + return [(count + _PSI_PRIOR) / total for count in counts] + + +def _histogram(raw: Any) -> tuple[list[float], list[float]] | None: + """Read a histogram, rejecting one that cannot describe a distribution. + + Args: + raw: The recorded ``histogram`` mapping. + + Returns: + Its edges and counts, or ``None`` if it is unusable. A single bin is + rejected along with the malformed cases: `StatsComputer` emits one for + a constant column, and it carries no shape to compare. + """ + if not isinstance(raw, Mapping): + return None + edges = _extract.float_list(raw.get("bin_edges")) + counts = _extract.float_list(raw.get("counts")) + if edges is None or counts is None: + return None + if len(edges) < _MIN_HISTOGRAM_EDGES or len(counts) != len(edges) - 1: + return None + if len(counts) < _MIN_HISTOGRAM_EDGES or sum(counts) <= 0: + return None + if edges[-1] <= edges[0]: + return None + return edges, counts + + +def _redistribute( + edges: Sequence[float], counts: Sequence[float], union: Sequence[float] +) -> list[float]: + """Spread a histogram's counts onto a shared grid. + + Args: + edges: The histogram's own bin edges, ascending. + counts: Its per-bin counts. + union: The shared grid's edges, ascending and spanning both histograms. + + Returns: + One count per bin of `union`, together holding the same total as + `counts` (up to floating-point error). + """ + spread = [0.0] * (len(union) - 1) + + for index, count in enumerate(counts): + low, high = edges[index], edges[index + 1] + span = high - low + for target in range(len(spread)): + overlap = min(high, union[target + 1]) - max(low, union[target]) + if overlap <= 0: + continue + # A zero-width source bin cannot be subdivided; its whole count + # belongs to whichever target bin contains it. + spread[target] += count * (overlap / span) if span > 0 else count + + return spread + + +def _percentile_shift(old: Any, new: Any) -> float | None: + """Return the largest percentile move, relative to the baseline's spread. + + Args: + old: The baseline ``percentiles`` mapping. + new: The later ``percentiles`` mapping. + + Returns: + The move as a fraction of the baseline's p5-p95 spread, or ``None`` if + the two share no comparable percentile. Falling back to ``|p50|`` and + then to 1.0 keeps the measure defined for a column whose middle 90% is + a single value. + """ + if not isinstance(old, Mapping) or not isinstance(new, Mapping): + return None + + moves = [ + abs(after - before) + for key in _PERCENTILES + if (before := _extract.number(old.get(key))) is not None + and (after := _extract.number(new.get(key))) is not None + ] + if not moves: + return None + + p5 = _extract.number(old.get("p5")) + p95 = _extract.number(old.get("p95")) + p50 = _extract.number(old.get("p50")) + spread = (p95 - p5) if p5 is not None and p95 is not None else 0.0 + scale = spread or abs(p50 or 0.0) or 1.0 + return max(moves) / scale + + +def _string_shift( + old: Mapping[str, Any], + new: Mapping[str, Any], + *, + old_rows: int | None, + new_rows: int | None, + options: DiffOptions, +) -> DistributionShift: + """Measure a string column's shift as total variation distance. + + Only the top values are recorded, so the tail they do not cover is carried + as one synthetic bucket. Without it, a column whose top values held steady + while everything else churned would score zero. + + Args: + old: The column's baseline statistics. + new: The column's later statistics. + old_rows: The baseline's row count, for turning counts into shares. + new_rows: The later row count. + options: The active thresholds. + + Returns: + The distance, in ``[0, 1]``, with the categories that came and went. + """ + old_values = _extract.counts(old.get("top_values")) + new_values = _extract.counts(new.get("top_values")) + if not old_values and not new_values: + return DistributionShift( + method=ShiftMethod.NONE, + score=None, + severity=Severity.UNCHANGED, + detail="no top values recorded", + ) + + old_share, old_note = _category_shares(old, old_values, rows=old_rows) + new_share, new_note = _category_shares(new, new_values, rows=new_rows) + + keys = set(old_share) | set(new_share) + distance = 0.5 * sum( + abs(new_share.get(key, 0.0) - old_share.get(key, 0.0)) for key in keys + ) + return DistributionShift( + method=ShiftMethod.TOP_VALUES_TVD, + score=distance, + severity=_band(distance, options.warn_tvd, options.critical_tvd), + added_categories=tuple(sorted(set(new_values) - set(old_values))), + removed_categories=tuple(sorted(set(old_values) - set(new_values))), + detail=old_note or new_note, + ) + + +def _category_shares( + stats: Mapping[str, Any], values: Mapping[str, float], *, rows: int | None +) -> tuple[dict[str, float], str | None]: + """Turn recorded category counts into shares of the column's values. + + Args: + stats: The column's statistics, for its null fraction and coverage. + values: Its ``top_values`` counts. + rows: The snapshot's row count, or ``None`` if not recorded. + + Returns: + The shares -- including the bucket standing for everything outside the + top values -- and a note when the normalization had to be weakened + because the row count was missing. + """ + counted = sum(values.values()) + null_fraction = _extract.number(stats.get("null_fraction")) or 0.0 + coverage = _extract.number(stats.get("top_values_coverage")) + + note: str | None = None + non_null = float(rows) * (1.0 - null_fraction) if rows is not None else 0.0 + if non_null <= 0: + non_null = counted + note = "row count unavailable; shares normalized over the top values only" + + shares = ( + {key: count / non_null for key, count in values.items()} if non_null else {} + ) + covered = ( + coverage if coverage is not None else (counted / non_null if non_null else 1.0) + ) + shares[_OTHER_BUCKET] = max(0.0, 1.0 - covered) + return shares, note + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def _summarize( + schema: SchemaDiff, + columns: Sequence[ColumnStatDiff], + metrics: Sequence[MetricDiff], + *, + severity: Severity, +) -> DiffSummary: + """Reduce the whole comparison to the handful of numbers a reader scans first. + + Args: + schema: The schema-level differences. + columns: The per-column comparisons. + metrics: The dataset-level comparisons. + severity: The diff's overall severity. + + Returns: + The headline counts. + """ + rows = next((m for m in metrics if m.key == "num_rows"), None) + drifted = sum( + 1 + for column in columns + if column.severity in (Severity.WARNING, Severity.CRITICAL) + ) + return DiffSummary( + columns_added=len(schema.added), + columns_removed=len(schema.removed), + columns_type_changed=len(schema.type_changed), + columns_drifted=drifted, + row_delta=int(rows.delta) + if rows is not None and rows.delta is not None + else None, + severity=severity, + ) diff --git a/src/dstrack/diff/_extract.py b/src/dstrack/diff/_extract.py new file mode 100644 index 0000000..b262b21 --- /dev/null +++ b/src/dstrack/diff/_extract.py @@ -0,0 +1,305 @@ +"""Reading a raw snapshot payload without trusting it. + +The engine works on two plain mappings, and this is the only layer that touches +them directly. Everything is read with ``.get()`` and checked at the boundary: +a field of the wrong type, or one a payload never carried, degrades to "not +recorded" rather than raising. That is the same stance +[store][dstrack.store] takes when it reads a log line -- a command that +compares two snapshots must not die on a field it was only going to print. + +The other job here is deciding which statistics variant a column holds. A +payload is a dict, so the frozen dataclasses +[StatsComputer][dstrack.snapshot.StatsComputer] produced are long gone by the +time the engine sees them, and which arithmetic is valid has to be recovered. +""" + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Final + +from dstrack.diff._model import ColumnKind, SnapshotRef + +# The dtype vocabulary `StatsComputer` dispatches on when it picks an +# accumulator. Kept in step with `dstrack.snapshot._stats._NUMERIC_DTYPES`. +_NUMERIC_DTYPES: Final[frozenset[str]] = frozenset( + {"int8", "int16", "int32", "int64", "float16", "float32", "float64", "bool"} +) +_STRING_DTYPES: Final[frozenset[str]] = frozenset({"string", "str", "object"}) +_DATETIME_DTYPES: Final[frozenset[str]] = frozenset({"datetime64", "datetime"}) + +# Keys that only one variant of the stats dataclasses ever carries. Checked in +# this order, so a payload carrying several is read as the most specific. +_KIND_MARKERS: Final[tuple[tuple[ColumnKind, frozenset[str]], ...]] = ( + (ColumnKind.STRING, frozenset({"top_values", "avg_char_length"})), + (ColumnKind.DATETIME, frozenset({"range_days"})), + (ColumnKind.NUMERIC, frozenset({"histogram", "percentiles", "mean", "std"})), +) + + +@dataclass(frozen=True, slots=True) +class SchemaEntry: + """One column as a snapshot's ``columns`` list described it. + + Attributes: + name: The column's name. + dtype: Its recorded dtype, or ``None`` if the payload did not say. + nullable: Whether it was recorded as nullable, or ``None``. + position: Its index in the ``columns`` list, which is what makes a + reorder visible. + """ + + name: str + dtype: str | None + nullable: bool | None + position: int + + +def snapshot_ref( + payload: Mapping[str, Any], *, dataset_id: str | None = None +) -> SnapshotRef: + """Read a payload's identity fields. + + Args: + payload: The snapshot payload. + dataset_id: Which dataset holds the snapshot. Never present in the + payload itself, so it is supplied by whoever read it out of a store. + + Returns: + The identity of that side of the comparison, with anything the payload + did not record left as ``None``. + """ + return SnapshotRef( + snapshot_id=text(payload.get("snapshot_id")), + dataset_id=dataset_id, + parent_snapshot_id=text(payload.get("parent_snapshot_id")), + dataset_name=text(payload.get("dataset_name")), + dataset_path=text(payload.get("dataset_path")), + created_at=text(payload.get("created_at")), + created_by=text(payload.get("created_by")), + source_type=text(payload.get("source_type")), + source_hash=text(payload.get("source_hash")), + schema_hash=text(payload.get("schema_hash")), + format_version=text(payload.get("format_version")), + num_rows=integer(payload.get("num_rows")), + num_columns=integer(payload.get("num_columns")), + ) + + +def column_schema(payload: Mapping[str, Any]) -> dict[str, SchemaEntry]: + """Read a payload's ``columns`` list, keyed by column name. + + Args: + payload: The snapshot payload. + + Returns: + One entry per named column, in the order the payload listed them. A + malformed entry, or one with no usable name, is skipped; a name listed + twice keeps its first position, since that is the one a reorder is + measured against. + """ + entries: dict[str, SchemaEntry] = {} + raw = payload.get("columns") + if not isinstance(raw, list): + return entries + + for position, item in enumerate(raw): + if not isinstance(item, Mapping): + continue + name = text(item.get("name")) + if name is None or name in entries: + continue + nullable = item.get("nullable") + entries[name] = SchemaEntry( + name=name, + dtype=text(item.get("dtype")), + nullable=nullable if isinstance(nullable, bool) else None, + position=position, + ) + return entries + + +def column_stats(payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: + """Read a payload's ``column_stats`` mapping. + + Args: + payload: The snapshot payload. + + Returns: + Each column's statistics, keyed by column name. Empty if the payload + recorded none, or recorded something that is not a mapping. + """ + raw = payload.get("column_stats") + if not isinstance(raw, Mapping): + return {} + return { + str(name): stats for name, stats in raw.items() if isinstance(stats, Mapping) + } + + +def column_kind(stats: Mapping[str, Any] | None, dtype: str | None) -> ColumnKind: + """Decide which statistics variant a column's entry holds. + + Two sources are consulted, and they are not equally trusted. The declared + dtype is a hint; the keys actually present are the evidence. When they + disagree the keys win, because the stats are what is about to be compared + arithmetically, and a column declared ``float64`` whose entry carries + ``top_values`` would otherwise have a dict read as a number. + + Args: + stats: The column's ``column_stats`` entry, or ``None`` if it has none. + dtype: The dtype the ``columns`` list declared, or ``None``. + + Returns: + The variant to compare the column as. ``OTHER`` when only null counts + are present, and ``UNKNOWN`` when neither source says anything, in + which case only null counts are compared either way. + """ + if stats is not None: + for kind, markers in _KIND_MARKERS: + if markers & stats.keys(): + return kind + + declared = _kind_from_dtype(dtype) + if declared is not None: + return declared + + if stats is not None: + # An entry carrying nothing but null counts is what `OtherColumnStats` + # is: a column whose dtype the reader did not recognize. + return ColumnKind.OTHER + return ColumnKind.UNKNOWN + + +def _kind_from_dtype(dtype: str | None) -> ColumnKind | None: + """Map a declared dtype to a statistics variant. + + Args: + dtype: The recorded dtype, or ``None``. + + Returns: + The variant that dtype implies, or ``None`` if there is no dtype to go + on. An unrecognized dtype maps to ``OTHER``, matching what + `StatsComputer` does with it. + """ + if dtype is None: + return None + lowered = dtype.lower() + if lowered in _NUMERIC_DTYPES: + return ColumnKind.NUMERIC + if lowered in _STRING_DTYPES: + return ColumnKind.STRING + if lowered.startswith("datetime") or lowered in _DATETIME_DTYPES: + return ColumnKind.DATETIME + return ColumnKind.OTHER + + +def number(value: Any) -> float | None: + """Read a value as a float, or ``None`` if it is not one. + + Args: + value: The recorded value. + + Returns: + The value as a float. Booleans are rejected: `bool` is a subclass of + `int`, and a flag that slipped into a numeric field would otherwise be + averaged as 0 or 1. Non-finite floats are rejected too, since every + comparison downstream would propagate the NaN rather than report it. + """ + if isinstance(value, bool) or not isinstance(value, int | float): + return None + result = float(value) + return result if math.isfinite(result) else None + + +def integer(value: Any) -> int | None: + """Read a value as an int, or ``None`` if it is not one. + + Args: + value: The recorded value. + + Returns: + The value as an int, or ``None``. Booleans are rejected for the same + reason [number][dstrack.diff._extract.number] rejects them; a float + carrying an exact integer is accepted, since JSON does not distinguish + ``3`` from ``3.0``. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def text(value: Any) -> str | None: + """Read a value as a string, or ``None`` if it is not one. + + Args: + value: The recorded value. + + Returns: + The string, or ``None``. A value of another type is *not* coerced: a + `dataset_name` that came back as a list is a payload that lied, and + showing ``"[1, 2]"`` as the name would hide that. + """ + return value if isinstance(value, str) else None + + +def string_tuple(value: Any) -> tuple[str, ...]: + """Read a value as a tuple of strings. + + Args: + value: The recorded value, expected to be a list of names. + + Returns: + Every string it holds, in order. Empty if it is not a list, so a + malformed ``constant_columns`` reads as "no constant columns" rather + than stopping the comparison. + """ + if not isinstance(value, list): + return () + return tuple(item for item in value if isinstance(item, str)) + + +def counts(value: Any) -> dict[str, float]: + """Read a value as a mapping of category name to count. + + Args: + value: The recorded value, expected to be a ``top_values`` mapping. + + Returns: + Each entry whose key is a string and whose count is a usable number. + Empty if the value is not a mapping. + """ + if not isinstance(value, Mapping): + return {} + result: dict[str, float] = {} + for key, raw in value.items(): + parsed = number(raw) + if isinstance(key, str) and parsed is not None: + result[key] = parsed + return result + + +def float_list(value: Any) -> list[float] | None: + """Read a value as a list of floats. + + Args: + value: The recorded value, e.g. a histogram's ``bin_edges``. + + Returns: + The floats it holds, or ``None`` if it is not a list or any element is + unusable. Unlike the other readers this is all-or-nothing: a histogram + missing one edge is not a shorter histogram, it is an unusable one. + """ + if not isinstance(value, list): + return None + parsed: list[float] = [] + for item in value: + as_float = number(item) + if as_float is None: + return None + parsed.append(as_float) + return parsed diff --git a/src/dstrack/diff/_model.py b/src/dstrack/diff/_model.py new file mode 100644 index 0000000..d08d530 --- /dev/null +++ b/src/dstrack/diff/_model.py @@ -0,0 +1,504 @@ +"""Result types for a snapshot comparison, and the knobs that shape it. + +Pure data: nothing here computes a difference, and nothing here knows how one +is displayed. The engine fills these in, the renderers read them, and +[SnapshotDiff.to_dict][dstrack.diff._model.SnapshotDiff.to_dict] turns them +into the JSON a pipeline consumes. + +Every quantity is compared through one type, +[MetricDiff][dstrack.diff._model.MetricDiff], whether it is a dataset's row +count or a single column's mean. One type means renderers and serializers have +one code path rather than one per metric family, and a metric added later needs +no new plumbing. +""" + +import dataclasses +from collections.abc import Iterable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Final + +# Bumped only when a key already emitted changes meaning or disappears. Adding +# a key is not a version change: consumers are expected to ignore what they do +# not recognize. +DIFF_FORMAT_VERSION: Final = "1" +# Relative thresholds, for counts and other unbounded magnitudes. +DEFAULT_REL_TOLERANCE: Final = 1e-3 +DEFAULT_ABS_TOLERANCE: Final = 1e-12 +DEFAULT_WARN_PCT_CHANGE: Final = 0.10 +DEFAULT_CRITICAL_PCT_CHANGE: Final = 0.50 +# Absolute thresholds, for quantities that are already fractions in [0, 1]. +DEFAULT_WARN_FRACTION_SHIFT: Final = 0.02 +DEFAULT_CRITICAL_FRACTION_SHIFT: Final = 0.10 +# The industry-standard PSI bands: below 0.1 no real shift, above 0.25 a major one. +DEFAULT_WARN_PSI: Final = 0.10 +DEFAULT_CRITICAL_PSI: Final = 0.25 +DEFAULT_WARN_TVD: Final = 0.10 +DEFAULT_CRITICAL_TVD: Final = 0.25 +DEFAULT_WARN_PERCENTILE_SHIFT: Final = 0.10 +DEFAULT_CRITICAL_PERCENTILE_SHIFT: Final = 0.25 + + +class Severity(StrEnum): + """How much a single comparison should worry the reader.""" + + UNCHANGED = "unchanged" + INFO = "info" + WARNING = "warning" + CRITICAL = "critical" + + +# Ascending, so `worst()` can fold a group of severities into the highest one. +_SEVERITY_ORDER: Final[tuple[Severity, ...]] = ( + Severity.UNCHANGED, + Severity.INFO, + Severity.WARNING, + Severity.CRITICAL, +) + + +def worst(severities: Iterable[Severity]) -> Severity: + """Return the highest severity in `severities`, or ``UNCHANGED`` if empty. + + How a whole diff is classified: a group is exactly as serious as its most + serious member, applied at every level from a column's metrics up to the + diff itself. + + Args: + severities: The severities to fold. + + Returns: + The most serious of them. + """ + return max(severities, key=_SEVERITY_ORDER.index, default=Severity.UNCHANGED) + + +class ColumnChangeKind(StrEnum): + """What happened to a column between the two schemas.""" + + ADDED = "added" + REMOVED = "removed" + TYPE_CHANGED = "type_changed" + NULLABILITY_CHANGED = "nullability_changed" + + +class ColumnKind(StrEnum): + """Which statistics variant a column's ``column_stats`` entry holds.""" + + NUMERIC = "numeric" + STRING = "string" + DATETIME = "datetime" + OTHER = "other" + UNKNOWN = "unknown" + + +class ShiftMethod(StrEnum): + """How a column's distribution shift was measured. + + Each measure has its own scale and its own thresholds, so a score is only + meaningful alongside the method that produced it. + """ + + PSI = "psi" + PERCENTILE_SHIFT = "percentile_shift" + TOP_VALUES_TVD = "top_values_tvd" + NONE = "none" + + +@dataclass(frozen=True, slots=True) +class SnapshotRef: + """Identity of one side of the comparison, as its payload recorded it. + + Every field is optional. `diff_snapshots` accepts dicts that never touched + a store, and a payload written by another version of dstrack may omit a + field entirely; neither is a reason to refuse to compare. + + Attributes: + snapshot_id: Unique identifier of the snapshot. + dataset_id: Which dataset holds the snapshot. Never present in a + payload -- it is a store concept -- so it is supplied by the caller + or left unset. + parent_snapshot_id: Identifier of the snapshot this one was derived + from, if any. + dataset_name: Human-readable name of the dataset. + dataset_path: Path of the tracked data, relative to the root it was + tracked against. + created_at: Timestamp at which the snapshot was created. + created_by: Who or what created the snapshot. + source_type: Kind of source the snapshot was taken from (e.g. `file`). + source_hash: Content hash of the source data. + schema_hash: Hash of the dataset's schema. + format_version: Version of the snapshot payload format. + num_rows: Number of rows in the dataset. + num_columns: Number of columns in the dataset. + """ + + snapshot_id: str | None = None + dataset_id: str | None = None + parent_snapshot_id: str | None = None + dataset_name: str | None = None + dataset_path: str | None = None + created_at: str | None = None + created_by: str | None = None + source_type: str | None = None + source_hash: str | None = None + schema_hash: str | None = None + format_version: str | None = None + num_rows: int | None = None + num_columns: int | None = None + + +@dataclass(frozen=True, slots=True) +class MetricDiff: + """One scalar quantity as it stood on each side. + + Attributes: + key: Stable machine name, e.g. ``"num_rows"`` or ``"percentiles.p50"``. + old: Value on the left side, or ``None`` if it was not recorded. + new: Value on the right side, or ``None`` if it was not recorded. + delta: ``new - old``, or ``None`` when either side is missing. + pct_change: ``delta / abs(old)``, or ``None`` when `old` is zero or + either side is missing. A fraction, not a percentage. + severity: How the change classifies under the active options. + """ + + key: str + old: float | None + new: float | None + delta: float | None + pct_change: float | None + severity: Severity + + +@dataclass(frozen=True, slots=True) +class ColumnSchemaChange: + """One column's schema entry, compared between snapshots. + + Attributes: + name: Column name. + kind: How the column changed (added, removed, retyped, ...). + old_dtype: Data type on the old side, or ``None`` if absent or not + recorded. + new_dtype: Data type on the new side, or ``None`` if absent or not + recorded. + old_nullable: Nullability on the old side, or ``None`` if absent or not + recorded. + new_nullable: Nullability on the new side, or ``None`` if absent or not + recorded. + severity: How the change classifies under the active options. + """ + + name: str + kind: ColumnChangeKind + old_dtype: str | None + new_dtype: str | None + old_nullable: bool | None + new_nullable: bool | None + severity: Severity + + +@dataclass(frozen=True, slots=True) +class SchemaDiff: + """Every difference between the two snapshots' column lists. + + Attributes: + added: Columns present only on the new side. + removed: Columns present only on the old side. + type_changed: Columns on both sides whose data type differs. + nullability_changed: Columns on both sides whose nullability differs. + reordered: Names present on both sides at a different position. + ``schema_hash`` is computed over *sorted* pairs, so a pure reorder + leaves the two hashes equal; this is the only field that shows it. + schema_hash_equal: Whether both sides recorded the same + ``schema_hash``. Display-only: the explicit column lists are what + the other fields are derived from. + severity: The worst severity among the schema changes. + """ + + added: tuple[ColumnSchemaChange, ...] = () + removed: tuple[ColumnSchemaChange, ...] = () + type_changed: tuple[ColumnSchemaChange, ...] = () + nullability_changed: tuple[ColumnSchemaChange, ...] = () + reordered: tuple[str, ...] = () + schema_hash_equal: bool = True + severity: Severity = Severity.UNCHANGED + + @property + def changed(self) -> bool: + """Whether any column was added, removed, or redefined.""" + return bool( + self.added or self.removed or self.type_changed or self.nullability_changed + ) + + +@dataclass(frozen=True, slots=True) +class DistributionShift: + """How far a column's value distribution moved. + + Attributes: + method: Which measure `score` is on. Always read the two together. + score: The measure's value, or ``None`` when `method` is ``NONE``. + severity: How the shift classifies under the active options. + added_categories: Values the new top-values list carries that the old + one did not. User-derived: renderers must build these with + `rich.text.Text`, and the PDF must escape them. + removed_categories: The mirror image. + detail: A short note on how the score was arrived at, e.g. why a + fallback measure was used. + """ + + method: ShiftMethod + score: float | None + severity: Severity + added_categories: tuple[str, ...] = () + removed_categories: tuple[str, ...] = () + detail: str | None = None + + +@dataclass(frozen=True, slots=True) +class ColumnStatDiff: + """One column's statistics, compared between snapshots. + + Attributes: + name: Column name. + kind: The statistics variant on the new side, or on the old side when + the column was removed. + old_kind: The variant on the old side, when it differs from `kind`. A + column that changed variant has no comparable statistics beyond its + null counts, which is why such a change is always ``CRITICAL``. + present_in_old: Whether the column has statistics on the old side. + present_in_new: Whether the column has statistics on the new side. + metrics: Every scalar at least one side recorded, in a stable order. + distribution_shift: ``None`` for kinds that carry no distribution. + severity: The worst severity among the column's metrics and shift. + """ + + name: str + kind: ColumnKind + old_kind: ColumnKind | None + present_in_old: bool + present_in_new: bool + metrics: tuple[MetricDiff, ...] + distribution_shift: DistributionShift | None + severity: Severity + + +@dataclass(frozen=True, slots=True) +class SetChange: + """A set-valued dataset field, e.g. ``constant_columns``. + + Attributes: + key: Name of the field that changed. + added: Members present only on the new side. + removed: Members present only on the old side. + severity: How the change classifies under the active options. + """ + + key: str + added: tuple[str, ...] + removed: tuple[str, ...] + severity: Severity + + +@dataclass(frozen=True, slots=True) +class DiffSummary: + """Headline counts, for a one-line render and a report's summary block. + + Attributes: + columns_added: Number of columns present only on the new side. + columns_removed: Number of columns present only on the old side. + columns_type_changed: Number of columns whose data type changed. + columns_drifted: Columns whose own severity reached ``WARNING`` or + above, counting statistical movement rather than schema edits. + row_delta: Change in row count (new minus old), or ``None`` when either + side did not record one. + severity: The overall severity of the comparison. + """ + + columns_added: int + columns_removed: int + columns_type_changed: int + columns_drifted: int + row_delta: int | None + severity: Severity + + +@dataclass(frozen=True, slots=True) +class DiffOptions: + """Thresholds deciding unchanged vs. changed vs. significant. + + Every threshold is a fraction, never a percentage. + + Relative thresholds apply to counts and unbounded magnitudes. Absolute + *shift* thresholds apply to quantities that are already fractions in + ``[0, 1]``, where relative change is meaningless near zero: a null fraction + going from 0.001 to 0.002 is a 100% relative change and nothing at all. + + Attributes: + rel_tolerance: Relative difference below which two numbers count as + equal. + abs_tolerance: Absolute difference below which two numbers count as + equal; covers comparisons against zero, where a relative + tolerance is meaningless. + warn_pct_change: Relative change in a count or magnitude at which it + is flagged as a warning. + critical_pct_change: Relative change in a count or magnitude at which + it is flagged as critical. + warn_fraction_shift: Absolute shift in a ``[0, 1]`` fraction (e.g. a + null fraction) at which it is flagged as a warning. + critical_fraction_shift: Absolute shift in a ``[0, 1]`` fraction at + which it is flagged as critical. + warn_psi: Population stability index (PSI) at which a numeric + distribution shift is a warning. + critical_psi: Population stability index at which a numeric + distribution shift is critical. + warn_tvd: Total variation distance at which a categorical + distribution shift is a warning. + critical_tvd: Total variation distance at which a categorical + distribution shift is critical. + warn_percentile_shift: Largest percentile move, as a fraction of the + baseline's p5-p95 spread, at which a shift is a warning. Used + when histograms are unusable for PSI. + critical_percentile_shift: Same measure, at which a shift is + critical. + """ + + rel_tolerance: float = DEFAULT_REL_TOLERANCE + abs_tolerance: float = DEFAULT_ABS_TOLERANCE + warn_pct_change: float = DEFAULT_WARN_PCT_CHANGE + critical_pct_change: float = DEFAULT_CRITICAL_PCT_CHANGE + warn_fraction_shift: float = DEFAULT_WARN_FRACTION_SHIFT + critical_fraction_shift: float = DEFAULT_CRITICAL_FRACTION_SHIFT + warn_psi: float = DEFAULT_WARN_PSI + critical_psi: float = DEFAULT_CRITICAL_PSI + warn_tvd: float = DEFAULT_WARN_TVD + critical_tvd: float = DEFAULT_CRITICAL_TVD + warn_percentile_shift: float = DEFAULT_WARN_PERCENTILE_SHIFT + critical_percentile_shift: float = DEFAULT_CRITICAL_PERCENTILE_SHIFT + + +DEFAULT_OPTIONS: Final = DiffOptions() + +# Named sensitivities, so the CLI can offer a choice without exposing twelve +# float flags. `low` is for datasets that move constantly and where only a +# structural break matters; `high` is for a curated table that should not move +# at all. +PRESETS: Final[dict[str, DiffOptions]] = { + "low": DiffOptions( + warn_pct_change=0.25, + critical_pct_change=1.00, + warn_fraction_shift=0.05, + critical_fraction_shift=0.20, + warn_psi=0.25, + critical_psi=0.50, + warn_tvd=0.25, + critical_tvd=0.50, + warn_percentile_shift=0.25, + critical_percentile_shift=0.50, + ), + "normal": DEFAULT_OPTIONS, + "high": DiffOptions( + warn_pct_change=0.02, + critical_pct_change=0.10, + warn_fraction_shift=0.005, + critical_fraction_shift=0.02, + warn_psi=0.05, + critical_psi=0.10, + warn_tvd=0.05, + critical_tvd=0.10, + warn_percentile_shift=0.05, + critical_percentile_shift=0.10, + ), +} + + +def preset(name: str) -> DiffOptions: + """Return a named sensitivity preset. + + Args: + name: One of the keys of [PRESETS][dstrack.diff._model.PRESETS]. + + Returns: + The thresholds that preset stands for. + + Raises: + KeyError: If no preset goes by that name. + """ + try: + return PRESETS[name] + except KeyError: + known = ", ".join(sorted(PRESETS)) + raise KeyError( + f"Unknown sensitivity {name!r}. Known presets: {known}." + ) from None + + +@dataclass(frozen=True, slots=True) +class SnapshotDiff: + """Everything that changed between two snapshots. + + Attributes: + identical: Whether every comparison came back ``UNCHANGED`` and no + column was added, removed, or redefined. Not the same as + `source_hash_equal`: two snapshots of one unchanged file still + differ in ``created_at`` and ``snapshot_id``, which are identity + rather than content and are never compared. + same_dataset: Whether both sides belong to the same dataset. ``False`` + is legitimate -- comparing two datasets is a supported use -- and + is surfaced rather than raised on. + source_hash_equal: Whether both sides recorded the same non-empty + source hash, i.e. the bytes behind them are known to be identical. + old: The baseline snapshot. + new: The snapshot compared against the baseline. + schema: Column additions, removals and changes between the schemas. + metrics: Dataset-level scalar quantities (e.g. row count), compared. + sets: Changes to set-valued dataset fields, e.g. ``constant_columns``. + columns: Per-column statistics, compared. + summary: Counts of changes by kind and severity. + severity: The highest severity across every comparison. + options: The thresholds the diff was classified with. + """ + + old: SnapshotRef + new: SnapshotRef + identical: bool + same_dataset: bool + source_hash_equal: bool + schema: SchemaDiff + metrics: tuple[MetricDiff, ...] + sets: tuple[SetChange, ...] + columns: tuple[ColumnStatDiff, ...] + summary: DiffSummary + severity: Severity + options: DiffOptions = field(default=DEFAULT_OPTIONS) + + def to_dict(self) -> dict[str, Any]: + """Return the diff as plain JSON-serializable data. + + Enum members are flattened to their string values and tuples become + lists, so the result survives `json.dumps` with no custom encoder and + compares equal to a literal in a test. + + Returns: + A dict carrying ``diff_format_version`` plus every field of this + object. Keys are stable within a + [DIFF_FORMAT_VERSION][dstrack.diff._model.DIFF_FORMAT_VERSION]: + new ones may appear, existing ones are not renamed or removed. + """ + return { + "diff_format_version": DIFF_FORMAT_VERSION, + **dataclasses.asdict(self, dict_factory=_plain), + } + + +def _plain(items: list[tuple[str, Any]]) -> dict[str, Any]: + """`dict_factory` that unwraps enum members to their string values. + + Args: + items: The field name/value pairs `dataclasses.asdict` collected. + + Returns: + The same pairs as a dict, with every `StrEnum` replaced by its value so + the result is JSON-serializable without a custom encoder. + """ + return {key: (v.value if isinstance(v, StrEnum) else v) for key, v in items} diff --git a/src/dstrack/errors.py b/src/dstrack/errors.py index c6e493b..9a187a8 100644 --- a/src/dstrack/errors.py +++ b/src/dstrack/errors.py @@ -14,9 +14,25 @@ class DatasetNotFoundError(Exception): """Raised when a named dataset does not exist in the local store.""" +class SnapshotNotFoundError(Exception): + """Raised when a snapshot cannot be located in the local store.""" + + +class AmbiguousSnapshotError(Exception): + """Raised when a short snapshot id prefix matches more than one snapshot.""" + + +class IncompatibleSnapshotError(Exception): + """Raised when a snapshot's format version cannot be compared.""" + + class InputTooLargeError(Exception): """Raised when a dataset exceeds the configured snapshot row limit.""" class IndexUnusable(Exception): """The index exists but cannot be queried, and must be rebuilt.""" + + +class MissingDependencyError(Exception): + """Raised when an optional feature's dependency is not installed.""" diff --git a/src/dstrack/snapshot/_version.py b/src/dstrack/snapshot/_version.py index 31f65b1..7287d66 100644 --- a/src/dstrack/snapshot/_version.py +++ b/src/dstrack/snapshot/_version.py @@ -3,11 +3,23 @@ Bump [FORMAT_VERSION][dstrack.snapshot._version.FORMAT_VERSION] and [SCHEMA_PATH][dstrack.snapshot._version.SCHEMA_PATH] together whenever [SnapshotMetadata][dstrack.snapshot._metadata.SnapshotMetadata]'s shape changes. + +A bump must not drop the old version from +[SUPPORTED_FORMAT_VERSIONS][dstrack.snapshot._version.SUPPORTED_FORMAT_VERSIONS]: +snapshots already in a store keep the version they were written in, and +removing it would make every one of them unreadable after an upgrade. Add the +new version alongside it, and teach the readers to handle both. """ from pathlib import Path +from typing import Final FORMAT_VERSION = "1" SCHEMA_PATH = ( Path(__file__).parent.parent / "schemas" / f"snapshot_v{FORMAT_VERSION}.json" ) + +# Every format version this dstrack can read, which is the one it writes plus +# each older one it still understands. A version outside this set was written by +# a newer dstrack, or is not a dstrack snapshot at all. +SUPPORTED_FORMAT_VERSIONS: Final[frozenset[str]] = frozenset({FORMAT_VERSION}) diff --git a/src/dstrack/store.py b/src/dstrack/store.py index 42f5cad..8081179 100644 --- a/src/dstrack/store.py +++ b/src/dstrack/store.py @@ -33,12 +33,16 @@ import sys import tempfile import uuid -from collections.abc import Iterator, Mapping +from collections.abc import Generator, Iterator, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Final -from dstrack.errors import DatasetNotFoundError, StoreCorruptionError +from dstrack.errors import ( + DatasetNotFoundError, + SnapshotNotFoundError, + StoreCorruptionError, +) # Lightweight identity fields copied from a snapshot into its `log.jsonl` line. # A subset of ADR-0001's fields: only those the current builders produce. @@ -113,6 +117,23 @@ class DatasetSummary: head: LogEntry | None +@dataclass(frozen=True) +class SnapshotLocation: + """Where a snapshot lives: which dataset owns it, and its own id. + + A ``snapshot_id`` is a random UUID4 that says nothing about which dataset + holds it, but every path into the store starts at ``datasets//``. + Pairing the two is what lets a snapshot found by id be read back. + + Attributes: + dataset_id: Dataset the snapshot belongs to. + snapshot_id: Identifier of the snapshot itself. + """ + + dataset_id: str + snapshot_id: str + + @dataclass(frozen=True) class SnapshotWriteResult: """Outcome of writing one snapshot into the store. @@ -287,6 +308,74 @@ def read_head(dataset_id: str, *, store_root: Path) -> str | None: return _read_head(_resolve_dataset_dir(dataset_id, store_root)) +def snapshot_path(dataset_id: str, snapshot_id: str, *, store_root: Path) -> Path: + """Return the path of a snapshot's full JSON payload. Need not exist. + + Args: + dataset_id: Dataset the snapshot belongs to. + snapshot_id: Snapshot to locate. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The ``datasets//snapshots/.json`` path. + + Raises: + ValueError: If either id resolves to a path outside the store. + """ + snapshots_dir = _resolve_dataset_dir(dataset_id, store_root) / _SNAPSHOTS_DIRNAME + path = snapshots_dir / f"{snapshot_id}.json" + _ensure_direct_child( + base=snapshots_dir, + candidate=path, + kind="snapshot_id", + value=snapshot_id, + ) + return path + + +def read_snapshot( + dataset_id: str, snapshot_id: str, *, store_root: Path +) -> dict[str, Any]: + """Return a snapshot's full payload, as [write_snapshot][dstrack.store.write_snapshot] wrote it. + + The payload is returned as the plain mapping it was stored as, with no + schema validation: what a reader may do with a field it does not recognize + is the reader's concern, and a payload written by a different version of + dstrack is still worth returning. + + Args: + dataset_id: Dataset the snapshot belongs to. + snapshot_id: Snapshot to read. + store_root: Path to the ``.dstrack/`` directory. + + Returns: + The snapshot payload. + + Raises: + ValueError: If either id resolves to a path outside the store. + SnapshotNotFoundError: If the dataset holds no such snapshot. + StoreCorruptionError: If the file is not a JSON object. + OSError: If the file cannot be read. + """ + path = snapshot_path(dataset_id, snapshot_id, store_root=store_root) + if not path.is_file(): + raise SnapshotNotFoundError( + f"No snapshot {snapshot_id!r} in dataset {dataset_id!r} (expected " + f"it at {path})." + ) + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise StoreCorruptionError(f"{path} is not valid JSON: {e}") from e + + if not isinstance(payload, dict): + raise StoreCorruptionError( + f"{path} does not hold a JSON object, but a {type(payload).__name__}." + ) + return payload + + def dataset_log_path(dataset_id: str, *, store_root: Path) -> Path: """Return the path of a dataset's ``log.jsonl``. Need not exist. @@ -713,7 +802,7 @@ def _atomic_write(path: Path, text: str) -> None: @contextlib.contextmanager -def _store_lock(store_root: Path) -> Iterator[None]: +def _store_lock(store_root: Path) -> Generator[None]: """Hold an exclusive, cross-process lock on the store for the block's body. Serializes writers across processes so the resolve-parent-then-write diff --git a/tests/test_diff_cli.py b/tests/test_diff_cli.py new file mode 100644 index 0000000..7309af0 --- /dev/null +++ b/tests/test_diff_cli.py @@ -0,0 +1,346 @@ +"""Tests for the `dstrack diff` command in src/dstrack/_diff.py.""" + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dstrack import cache, store +from dstrack._cli import app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create an empty local store under a fresh cwd and return its root.""" + monkeypatch.delenv("DSTRACK_ROOT_PATH", raising=False) + monkeypatch.chdir(tmp_path) + root = tmp_path / ".dstrack" + (root / "datasets").mkdir(parents=True) + return root + + +def _write_csv(path: Path, rows: int = 3, extra_column: bool = False) -> Path: + """Write a small, valid CSV file and return its path.""" + header = "a,b,c" if extra_column else "a,b" + lines = [header] + [ + f"{i},{i * 2}" + (f",{i * 3}" if extra_column else "") for i in range(rows) + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def _track(path: Path) -> None: + """Track `path` through the CLI.""" + result = runner.invoke(app, ["track", str(path)]) + assert result.exit_code == 0, result.output + + +def _dataset_id_of(root: Path, path: Path) -> str: + """Return the id of the dataset whose snapshots recorded `path`.""" + for snapshot in root.glob("datasets/*/snapshots/*.json"): + payload = json.loads(snapshot.read_text(encoding="utf-8")) + if Path(payload["dataset_path"]).name == path.name: + return str(snapshot.parent.parent.name) + raise AssertionError(f"no dataset recorded for {path}") + + +@pytest.fixture +def tracked(tmp_path: Path, store_root: Path) -> Path: + """Track one CSV twice, so it has a snapshot and a parent. + + The index is built here rather than by the first command under test, so + that no test's output depends on whether it happened to run first. + """ + csv = _write_csv(tmp_path / "data.csv") + _track(csv) + _write_csv(csv, rows=9, extra_column=True) + _track(csv) + cache.sync(store_root) + return csv + + +def _diff(*args: str, width: str = "160") -> object: + """Invoke `dstrack diff` with `--yes`, at a fixed terminal width.""" + return runner.invoke(app, ["diff", *args, "--yes"], env={"COLUMNS": width}) + + +def _snapshots(root: Path, csv: Path) -> list[str]: + """Return a dataset's snapshot ids, oldest first.""" + dataset_id = _dataset_id_of(root, csv) + entries = store.read_log_entries(dataset_id, store_root=root) + return [entry.snapshot_id for entry in entries] + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_a_path_compares_the_latest_snapshot_to_its_parent( + store_root: Path, tracked: Path +) -> None: + """The default question: what changed in the last snapshot of this file?""" + result = _diff(str(tracked)) + + first, second = _snapshots(store_root, tracked) + assert result.exit_code == 0, result.output + assert first[:8] in result.output + assert second[:8] in result.output + assert "+6" in result.output, "6 rows should have been added" + + +def test_two_snapshot_ids_compare_in_the_order_given( + store_root: Path, tracked: Path +) -> None: + """Naming both sides explicitly is the unambiguous spelling.""" + first, second = _snapshots(store_root, tracked) + + forward = _diff(first, second) + backward = _diff(second, first) + + assert forward.exit_code == 0, forward.output + assert backward.exit_code == 0, backward.output + assert "+6" in forward.output, "first -> second adds 6 rows" + assert "-6" in backward.output, "second -> first removes 6 rows" + + +def test_short_prefixes_work_like_short_commit_hashes( + store_root: Path, tracked: Path +) -> None: + """Nobody types a UUID in full so it should be allowed to pass the beginning of the UUID.""" + first, second = _snapshots(store_root, tracked) + + result_short_uid = _diff(first[:8], second[:8]) + result_long_uid = _diff(first, second) + + assert result_short_uid.exit_code == 0, result_short_uid.output + assert result_short_uid.output == result_long_uid.output, ( + "comparison should be identical" + ) + + +def test_from_and_to_name_the_two_sides(store_root: Path, tracked: Path) -> None: + """The explicit spelling, for when the order matters and should be obvious.""" + first, second = _snapshots(store_root, tracked) + + forward = _diff("--from", first, "--to", second) + backward = _diff("--from", second, "--to", first) + + assert forward.exit_code == 0, forward.output + assert backward.exit_code == 0, backward.output + assert "+6" in forward.output, "--from first --to second adds 6 rows" + assert "-6" in backward.output, "--from second --to first removes 6 rows" + + +def test_to_alone_compares_against_the_snapshot_it_succeeded( + store_root: Path, tracked: Path +) -> None: + """Naming only the later side asks what that snapshot changed.""" + first, second = _snapshots(store_root, tracked) + + result = _diff("--to", second) + + assert result.exit_code == 0, result.output + assert first[:8] in result.output + assert second[:8] in result.output + assert result.output.index(first[:8]) < result.output.index(second[:8]), ( + "the parent should be the 'before' side" + ) + assert "+6 rows" in result.output + + +def test_from_alone_compares_against_where_the_dataset_stands_now( + store_root: Path, tracked: Path +) -> None: + """Naming only the baseline asks what has happened since.""" + first, second = _snapshots(store_root, tracked) + + result = _diff("--from", first) + + assert result.exit_code == 0, result.output + assert first[:8] in result.output + assert second[:8] in result.output + assert result.output.index(first[:8]) < result.output.index(second[:8]), ( + "the baseline should be the 'before' side and the latest the 'after'" + ) + assert "+6 rows" in result.output + + +def test_oneline_condenses_the_comparison(store_root: Path, tracked: Path) -> None: + """The form that fits a CI log line.""" + result = _diff(str(tracked), "--oneline") + + assert result.exit_code == 0, result.output + assert len(result.output.strip().splitlines()) == 1 + + +def test_a_narrow_terminal_stacks_the_columns(store_root: Path, tracked: Path) -> None: + """The layout follows the terminal it is printed to.""" + result = _diff(str(tracked), width="60") + + assert result.exit_code == 0, result.output + assert max(len(line) for line in result.output.splitlines()) <= 60 + + +def test_all_includes_what_did_not_change(store_root: Path, tracked: Path) -> None: + """`--all` is for reading the whole picture.""" + default = _diff(str(tracked)) + everything = _diff(str(tracked), "--all") + + assert everything.exit_code == 0, everything.output + assert len(everything.output.splitlines()) > len(default.output.splitlines()) + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + + +def test_json_output_parses_and_carries_its_version( + store_root: Path, tracked: Path +) -> None: + """A pipeline has to be able to read stdout with `json.loads`.""" + result = _diff(str(tracked), "--json") + + assert result.exit_code == 0, result.output + document = json.loads(result.output) + assert "diff_format_version" in document + assert "schema" in document + assert document["summary"]["row_delta"] == 6 + + +def test_json_output_carries_no_decoration(store_root: Path, tracked: Path) -> None: + """Panels, box drawing and progress notes would all break a parser.""" + result = _diff(str(tracked), "--json") + + assert "\N{BOX DRAWINGS LIGHT VERTICAL}" not in result.output + assert "\x1b[" not in result.output + + +def test_sensitivity_changes_what_is_reported(store_root: Path, tracked: Path) -> None: + """The same movement is graded differently at different sensitivities.""" + low = json.loads(_diff(str(tracked), "--json", "--sensitivity", "low").output) + high = json.loads(_diff(str(tracked), "--json", "--sensitivity", "high").output) + + assert low["options"]["warn_psi"] > high["options"]["warn_psi"] + + +def test_an_unknown_sensitivity_lists_the_ones_that_exist( + store_root: Path, tracked: Path +) -> None: + """A typo should say what to type instead.""" + result = _diff(str(tracked), "--sensitivity", "aggressive") + + assert result.exit_code == 2 + assert "normal" in result.output + + +# --------------------------------------------------------------------------- +# Failures +# --------------------------------------------------------------------------- + + +def test_an_unknown_target_lists_the_known_datasets( + tmp_path: Path, store_root: Path, tracked: Path +) -> None: + """An error a reader can act on names what does exist.""" + result = _diff(str(tmp_path / "nope.csv")) + + assert result.exit_code == 1 + assert "Known datasets" in result.output + + +def test_a_first_snapshot_has_nothing_to_compare_against( + tmp_path: Path, store_root: Path +) -> None: + """One snapshot and no parent is a dead end worth explaining.""" + csv = _write_csv(tmp_path / "only.csv") + _track(csv) + + result = _diff(str(csv)) + + assert result.exit_code == 1 + assert "first snapshot" in result.output + + +def test_mixing_positionals_with_from_and_to_is_refused( + store_root: Path, tracked: Path +) -> None: + """Two spellings of the same thing must not be combined.""" + first, second = _snapshots(store_root, tracked) + + result = runner.invoke(app, ["diff", first, "--to", second, "--yes"]) + + assert result.exit_code == 2 + assert "not both" in result.output + + +def test_more_than_two_references_is_refused(store_root: Path, tracked: Path) -> None: + """A comparison has exactly two sides.""" + first, second = _snapshots(store_root, tracked) + + result = _diff(first, second, first) + + assert result.exit_code == 2 + assert "at most two" in result.output + + +def test_no_reference_at_all_is_refused(store_root: Path, tracked: Path) -> None: + """There is no sensible default for what to compare.""" + result = _diff() + + assert result.exit_code == 2 + assert "at least one" in result.output + + +def test_no_index_and_no_terminal_says_how_to_proceed( + tmp_path: Path, store_root: Path +) -> None: + """A script with no tty cannot be asked, so it is told what to pass.""" + csv = _write_csv(tmp_path / "data.csv") + _track(csv) + + result = runner.invoke(app, ["diff", str(csv)]) + + assert result.exit_code == 1 + assert "--yes" in result.output + + +# --------------------------------------------------------------------------- +# Cross-dataset +# --------------------------------------------------------------------------- + + +def test_comparing_two_datasets_warns_but_succeeds( + tmp_path: Path, store_root: Path +) -> None: + """Comparing across datasets is supported, and the reader is told.""" + first = _write_csv(tmp_path / "a.csv") + second = _write_csv(tmp_path / "b.csv", rows=9) + _track(first) + _track(second) + + result = _diff(str(first), str(second)) + + assert result.exit_code == 0, result.output + assert "two different datasets" in result.output + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +def test_diff_is_listed_in_the_top_level_help() -> None: + """A command nobody can discover is a command nobody uses.""" + result = runner.invoke(app, []) + + assert "diff" in result.output diff --git a/tests/test_diff_engine.py b/tests/test_diff_engine.py new file mode 100644 index 0000000..d808f67 --- /dev/null +++ b/tests/test_diff_engine.py @@ -0,0 +1,564 @@ +"""Tests for the diff engine in src/dstrack/diff/.""" + +import json +from typing import Any + +import pytest + +from dstrack.diff import ( + ColumnKind, + Severity, + ShiftMethod, + diff_snapshots, + preset, +) +from dstrack.diff._engine import _redistribute +from dstrack.diff._model import DiffOptions +from dstrack.errors import IncompatibleSnapshotError + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _numeric(**overrides: Any) -> dict[str, Any]: + """Build a numeric column's statistics entry.""" + stats: dict[str, Any] = { + "null_count": 0, + "null_fraction": 0.0, + "mean": 10.0, + "std": 2.0, + "min": 0.0, + "max": 20.0, + "num_unique": 50, + "percentiles": { + "p5": 2.0, + "p25": 6.0, + "p50": 10.0, + "p75": 14.0, + "p95": 18.0, + "p99": 19.0, + }, + "histogram": { + "bin_edges": [0.0, 5.0, 10.0, 15.0, 20.0], + "counts": [100, 400, 400, 100], + }, + } + stats.update(overrides) + return stats + + +def _string(**overrides: Any) -> dict[str, Any]: + """Build a text column's statistics entry.""" + stats: dict[str, Any] = { + "null_count": 0, + "null_fraction": 0.0, + "num_unique": 3, + "top_values": {"NY": 500, "LA": 300, "SF": 200}, + "top_values_coverage": 1.0, + "avg_char_length": 2.0, + "min_char_length": 2.0, + "max_char_length": 2.0, + "avg_token_count": 1.0, + "min_token_count": 1.0, + "max_token_count": 1.0, + } + stats.update(overrides) + return stats + + +def _payload(**overrides: Any) -> dict[str, Any]: + """Build a minimal but complete snapshot payload.""" + payload: dict[str, Any] = { + "format_version": "1", + "snapshot_id": "11111111-1111-4111-8111-111111111111", + "created_at": "2026-01-01T00:00:00+00:00", + "created_by": "tester", + "dataset_name": "sales", + "dataset_path": "data/sales.csv", + "source_type": "file", + "source_hash": "a" * 64, + "num_rows": 1000, + "num_columns": 2, + "columns": [ + {"name": "price", "dtype": "float64", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + ], + "schema_hash": "b" * 64, + "column_stats": {"price": _numeric(), "city": _string()}, + "duplicate_row_fraction": 0.0, + "constant_columns": [], + "high_null_columns": [], + } + payload.update(overrides) + return payload + + +def _mutate(**changes: Any) -> dict[str, Any]: + """Build a payload that differs from `_payload()` in the given fields.""" + payload = json.loads(json.dumps(_payload())) + payload.update(changes) + return payload + + +# --------------------------------------------------------------------------- +# Identity and the no-change case +# --------------------------------------------------------------------------- + + +def test_a_snapshot_compared_to_itself_is_identical() -> None: + """Nothing is reported when both sides hold the same payload.""" + result = diff_snapshots(_payload(), _payload()) + + assert result.identical is True + assert result.severity is Severity.UNCHANGED + assert not result.schema.changed + assert result.source_hash_equal is True + + +def test_empty_source_hashes_do_not_prove_identical_bytes() -> None: + """Two non-file sources both record "", which says nothing about content.""" + result = diff_snapshots( + _mutate(source_hash=""), _mutate(source_hash="", num_rows=1200) + ) + + assert result.source_hash_equal is False + + +def test_dataset_ids_decide_whether_it_is_one_dataset() -> None: + """Comparing two datasets is surfaced rather than refused.""" + across = diff_snapshots( + _payload(), _payload(), old_dataset_id="ds-a", new_dataset_id="ds-b" + ) + within = diff_snapshots( + _payload(), _payload(), old_dataset_id="ds-a", new_dataset_id="ds-a" + ) + + assert across.same_dataset is False + assert within.same_dataset is True + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +def test_added_column_is_a_warning() -> None: + """A new column is worth knowing about, but nothing downstream breaks.""" + new = _mutate( + columns=[ + {"name": "price", "dtype": "float64", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + {"name": "tier", "dtype": "string", "nullable": True}, + ], + ) + new["column_stats"]["tier"] = _string() + + result = diff_snapshots(_payload(), new) + + assert [c.name for c in result.schema.added] == ["tier"] + assert result.schema.added[0].severity is Severity.WARNING + assert result.summary.columns_added == 1 + + +def test_removed_column_is_critical() -> None: + """Dropping a column breaks anything that reads it by name.""" + new = _mutate(columns=[{"name": "price", "dtype": "float64", "nullable": True}]) + del new["column_stats"]["city"] + + result = diff_snapshots(_payload(), new) + + assert [c.name for c in result.schema.removed] == ["city"] + assert result.severity is Severity.CRITICAL + assert result.summary.columns_removed == 1 + + +def test_type_change_is_critical() -> None: + """A column that changed dtype is reported as a retype, not an add and drop.""" + new = _mutate( + columns=[ + {"name": "price", "dtype": "int64", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + ] + ) + + result = diff_snapshots(_payload(), new) + + assert [c.name for c in result.schema.type_changed] == ["price"] + assert result.schema.type_changed[0].severity is Severity.CRITICAL + assert not result.schema.added + assert not result.schema.removed + + +def test_nullability_change_is_information_only() -> None: + """A column that became nullable is noted without raising the alarm.""" + new = _mutate( + columns=[ + {"name": "price", "dtype": "float64", "nullable": False}, + {"name": "city", "dtype": "string", "nullable": True}, + ] + ) + + result = diff_snapshots(_payload(), new) + + assert [c.name for c in result.schema.nullability_changed] == ["price"] + assert result.schema.severity is Severity.INFO + + +def test_reordered_columns_are_the_only_signal_of_a_reorder() -> None: + """A pure reorder leaves the schema hash equal, so it is reported separately.""" + new = _mutate( + columns=[ + {"name": "city", "dtype": "string", "nullable": True}, + {"name": "price", "dtype": "float64", "nullable": True}, + ] + ) + + result = diff_snapshots(_payload(), new) + + assert set(result.schema.reordered) == {"price", "city"} + assert result.schema.schema_hash_equal is True + assert result.identical is True + + +def test_a_rename_reads_as_a_removal_and_an_addition() -> None: + """No rename is inferred: the engine reports only what it can know.""" + new = _mutate( + columns=[ + {"name": "cost", "dtype": "float64", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + ] + ) + new["column_stats"]["cost"] = new["column_stats"].pop("price") + + result = diff_snapshots(_payload(), new) + + assert [c.name for c in result.schema.added] == ["cost"] + assert [c.name for c in result.schema.removed] == ["price"] + + +# --------------------------------------------------------------------------- +# Scalar classification +# --------------------------------------------------------------------------- + + +def test_a_count_is_judged_by_its_relative_change() -> None: + """A 50% jump in rows is critical; the delta and ratio are both reported.""" + result = diff_snapshots(_payload(), _mutate(num_rows=1500)) + + rows = next(m for m in result.metrics if m.key == "num_rows") + assert rows.delta == 500 + assert rows.pct_change == pytest.approx(0.5) + assert rows.severity is Severity.CRITICAL + assert result.summary.row_delta == 500 + + +def test_growth_from_zero_has_no_relative_scale() -> None: + """Zero to something is notable and cannot be graded as a ratio.""" + result = diff_snapshots(_mutate(num_rows=0), _mutate(num_rows=10)) + + rows = next(m for m in result.metrics if m.key == "num_rows") + assert rows.pct_change is None + assert rows.severity is Severity.WARNING + + +@pytest.mark.parametrize( + ("null_fraction", "expected"), + [ + (0.0, Severity.UNCHANGED), + (0.01, Severity.INFO), + (0.05, Severity.WARNING), + (0.20, Severity.CRITICAL), + ], +) +def test_a_fraction_is_judged_by_its_absolute_shift( + null_fraction: float, expected: Severity +) -> None: + """A null fraction moving 0.001 to 0.002 has doubled and means nothing.""" + new = _mutate() + new["column_stats"]["price"]["null_fraction"] = null_fraction + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "price") + metric = next(m for m in column.metrics if m.key == "null_fraction") + assert metric.severity is expected + + +def test_a_metric_neither_side_recorded_is_not_reported() -> None: + """Fields in the schema that no builder produces are simply absent.""" + result = diff_snapshots(_payload(), _payload()) + + assert not [m for m in result.metrics if m.key == "near_duplicate_estimate"] + + +def test_a_metric_only_one_side_recorded_is_information() -> None: + """A field appearing is a change in what was recorded, not in the data.""" + result = diff_snapshots(_payload(), _mutate(near_duplicate_estimate=0.3)) + + metric = next(m for m in result.metrics if m.key == "near_duplicate_estimate") + assert metric.old is None + assert metric.new == pytest.approx(0.3) + assert metric.severity is Severity.INFO + + +# --------------------------------------------------------------------------- +# Set-valued fields +# --------------------------------------------------------------------------- + + +def test_joining_the_constant_columns_warns_and_leaving_informs() -> None: + """A feature going dead is actionable; one coming back to life is not.""" + joined = diff_snapshots(_payload(), _mutate(constant_columns=["price"])) + left = diff_snapshots(_mutate(constant_columns=["price"]), _payload()) + + assert joined.sets[0].added == ("price",) + assert joined.sets[0].severity is Severity.WARNING + assert left.sets[0].removed == ("price",) + assert left.sets[0].severity is Severity.INFO + + +# --------------------------------------------------------------------------- +# Distribution shift +# --------------------------------------------------------------------------- + + +def test_identical_histograms_score_zero() -> None: + """A distribution that did not move must not read as movement.""" + result = diff_snapshots(_payload(), _payload()) + + column = next(c for c in result.columns if c.name == "price") + assert column.distribution_shift is not None + assert column.distribution_shift.method is ShiftMethod.PSI + assert column.distribution_shift.score == pytest.approx(0.0) + assert column.distribution_shift.severity is Severity.UNCHANGED + + +def test_disjoint_histograms_are_critical() -> None: + """Values that moved to a range they never occupied is the clearest drift.""" + new = _mutate() + new["column_stats"]["price"]["histogram"] = { + "bin_edges": [100.0, 105.0, 110.0, 115.0, 120.0], + "counts": [100, 400, 400, 100], + } + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "price") + assert column.distribution_shift is not None + assert column.distribution_shift.score is not None + assert column.distribution_shift.score > DiffOptions().critical_psi + assert column.distribution_shift.severity is Severity.CRITICAL + + +def test_counts_are_redistributed_onto_a_shared_grid_without_loss() -> None: + """Re-binning conserves the total, or every proportion after it is wrong.""" + spread = _redistribute( + [0.0, 5.0, 10.0, 15.0, 20.0], + [100, 400, 400, 100], + [0.0, 3.0, 5.0, 10.0, 12.0, 15.0, 20.0], + ) + + assert sum(spread) == pytest.approx(1000.0, abs=1e-9) + assert len(spread) == 6 + + +def test_histograms_with_different_edges_still_compare() -> None: + """Each snapshot derives its edges from its own range, so they rarely align.""" + new = _mutate() + new["column_stats"]["price"]["histogram"] = { + "bin_edges": [0.0, 6.0, 12.0, 18.0, 24.0], + "counts": [50, 150, 400, 600], + } + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "price") + assert column.distribution_shift is not None + assert column.distribution_shift.method is ShiftMethod.PSI + assert column.distribution_shift.score is not None + + +def test_a_degenerate_histogram_falls_back_to_percentiles() -> None: + """A constant column's single bin carries no shape to compare.""" + old = _mutate() + new = _mutate() + for payload in (old, new): + payload["column_stats"]["price"]["histogram"] = { + "bin_edges": [3.0, 4.0], + "counts": [1000], + } + new["column_stats"]["price"]["percentiles"]["p50"] = 14.0 + + result = diff_snapshots(old, new) + + column = next(c for c in result.columns if c.name == "price") + assert column.distribution_shift is not None + assert column.distribution_shift.method is ShiftMethod.PERCENTILE_SHIFT + assert column.distribution_shift.detail is not None + + +def test_a_new_dominant_category_shifts_a_text_column() -> None: + """Top-value churn is what drift looks like for a text column.""" + new = _mutate() + new["column_stats"]["city"]["top_values"] = {"MIA": 700, "LA": 300} + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "city") + assert column.distribution_shift is not None + assert column.distribution_shift.method is ShiftMethod.TOP_VALUES_TVD + assert column.distribution_shift.added_categories == ("MIA",) + assert column.distribution_shift.removed_categories == ("NY", "SF") + assert column.distribution_shift.severity is Severity.CRITICAL + + +def test_the_untracked_tail_is_carried_as_its_own_bucket() -> None: + """Coverage falling means the tail grew, even if the top values held.""" + new = _mutate() + new["column_stats"]["city"]["top_values_coverage"] = 0.4 + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "city") + assert column.distribution_shift is not None + assert column.distribution_shift.score is not None + assert column.distribution_shift.score > 0 + + +# --------------------------------------------------------------------------- +# Column kinds +# --------------------------------------------------------------------------- + + +def test_a_column_that_changed_kind_is_critical_and_barely_comparable() -> None: + """Numbers and text share nothing but their null counts.""" + new = _mutate( + columns=[ + {"name": "price", "dtype": "string", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + ] + ) + new["column_stats"]["price"] = _string() + + result = diff_snapshots(_payload(), new) + + column = next(c for c in result.columns if c.name == "price") + assert column.old_kind is ColumnKind.NUMERIC + assert column.kind is ColumnKind.STRING + assert column.severity is Severity.CRITICAL + assert {m.key for m in column.metrics} == {"null_fraction", "null_count"} + assert column.distribution_shift is None + + +def test_the_recorded_keys_outrank_a_declared_dtype() -> None: + """Arithmetic follows the statistics present, not the dtype claimed.""" + payload = _mutate(columns=[{"name": "price", "dtype": "float64", "nullable": True}]) + payload["column_stats"] = {"price": _string()} + + result = diff_snapshots(payload, payload) + + column = next(c for c in result.columns if c.name == "price") + assert column.kind is ColumnKind.STRING + + +# --------------------------------------------------------------------------- +# Options +# --------------------------------------------------------------------------- + + +def test_presets_change_what_counts_as_significant() -> None: + """The same movement is graded differently at different sensitivities.""" + new = _mutate(num_rows=1150) + + assert diff_snapshots(_payload(), new, options=preset("low")).severity is ( + Severity.INFO + ) + assert diff_snapshots(_payload(), new, options=preset("high")).severity is ( + Severity.CRITICAL + ) + + +def test_an_unknown_preset_names_the_ones_that_exist() -> None: + """A typo'd sensitivity should say what to type instead.""" + with pytest.raises(KeyError, match="normal"): + preset("aggressive") + + +# --------------------------------------------------------------------------- +# Format version and malformed input +# --------------------------------------------------------------------------- + + +def test_a_foreign_format_version_is_refused() -> None: + """A payload this dstrack cannot read must not be compared as if it could.""" + with pytest.raises(IncompatibleSnapshotError, match="cannot read"): + diff_snapshots(_mutate(format_version="99"), _payload()) + + +def test_older_supported_format_versions_stay_comparable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An upgrade must not strand snapshots written in an older format.""" + monkeypatch.setattr( + "dstrack.diff._engine.SUPPORTED_FORMAT_VERSIONS", frozenset({"1", "2"}) + ) + + result = diff_snapshots(_payload(), _mutate(format_version="2")) + + assert result.old.format_version == "1" + assert result.new.format_version == "2" + + +def test_a_payload_recording_no_format_version_is_accepted() -> None: + """Hand-built dicts are a supported input and record no version.""" + result = diff_snapshots({"num_rows": 1}, {"num_rows": 2}) + + assert result.summary.row_delta == 1 + + +@pytest.mark.parametrize( + "payload", + [ + {"num_rows": "many"}, + {"column_stats": []}, + {"columns": "not a list"}, + {"constant_columns": {"not": "a list"}}, + {"column_stats": {"a": {"mean": None, "histogram": {"bin_edges": "x"}}}}, + ], +) +def test_a_malformed_payload_degrades_rather_than_raises( + payload: dict[str, Any], +) -> None: + """A display command must not die on a field it was only going to print.""" + result = diff_snapshots(payload, _payload()) + + assert result.severity in tuple(Severity) + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +def test_to_dict_is_json_serializable_and_carries_its_version() -> None: + """The JSON output must survive `json.dumps` with no custom encoder.""" + result = diff_snapshots(_payload(), _mutate(num_rows=1500)) + + document = result.to_dict() + encoded = json.dumps(document) + + assert "diff_format_version" in document + assert json.loads(encoded)["summary"]["row_delta"] == 500 + assert "severity" in document + + +def test_to_dict_holds_no_enum_objects() -> None: + """Enums are flattened to their values, so the result compares to literals.""" + result = diff_snapshots(_payload(), _mutate(num_rows=1500)) + + document = result.to_dict() + + assert document["severity"] == "critical" + assert isinstance(document["severity"], str) + assert document["columns"][0]["kind"] == "numeric" diff --git a/tests/test_diff_render.py b/tests/test_diff_render.py new file mode 100644 index 0000000..46cb905 --- /dev/null +++ b/tests/test_diff_render.py @@ -0,0 +1,224 @@ +"""Tests for the terminal renderer in src/dstrack/_diff_render.py.""" + +from datetime import UTC, datetime +from typing import Any + +import pytest +from rich.console import Console + +from dstrack._diff_render import _MAX_LISTED_COLUMNS, render_diff +from dstrack.diff import SnapshotDiff, diff_snapshots + +NOW = datetime(2026, 1, 2, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _payload(**overrides: Any) -> dict[str, Any]: + """Build a small snapshot payload with one numeric and one text column.""" + payload: dict[str, Any] = { + "snapshot_id": "11111111-1111-4111-8111-111111111111", + "created_at": "2026-01-01T00:00:00+00:00", + "created_by": "tester", + "dataset_name": "sales", + "dataset_path": "data/sales.csv", + "source_hash": "a" * 64, + "num_rows": 1000, + "num_columns": 2, + "columns": [ + {"name": "price", "dtype": "float64", "nullable": True}, + {"name": "city", "dtype": "string", "nullable": True}, + ], + "column_stats": { + "price": { + "null_count": 0, + "null_fraction": 0.0, + "mean": 10.0, + "std": 2.0, + "min": 0.0, + "max": 20.0, + "num_unique": 50, + "percentiles": {"p5": 2.0, "p50": 10.0, "p95": 18.0}, + "histogram": { + "bin_edges": [0.0, 5.0, 10.0, 15.0, 20.0], + "counts": [100, 400, 400, 100], + }, + }, + "city": { + "null_count": 0, + "null_fraction": 0.0, + "num_unique": 3, + "top_values": {"NY": 500, "LA": 300, "SF": 200}, + "top_values_coverage": 1.0, + "avg_char_length": 2.0, + }, + }, + "duplicate_row_fraction": 0.0, + "constant_columns": [], + "high_null_columns": [], + } + payload.update(overrides) + return payload + + +def _changed_diff() -> SnapshotDiff: + """Build a diff with a row change, a null change and a category change.""" + new = _payload( + snapshot_id="22222222-2222-4222-8222-222222222222", + num_rows=1500, + source_hash="b" * 64, + ) + new["column_stats"]["price"]["null_fraction"] = 0.2 + new["column_stats"]["price"]["mean"] = 40.0 + new["column_stats"]["city"]["top_values"] = {"MIA": 900, "LA": 600} + return diff_snapshots(_payload(), new, old_dataset_id="ds", new_dataset_id="ds") + + +def _render(diff: SnapshotDiff, *, width: int = 160, **kwargs: Any) -> str: + """Render a diff at a given width and return its plain text.""" + console = Console( + width=width, record=True, force_terminal=False, legacy_windows=False + ) + console.print(render_diff(diff, width=width, now=NOW, **kwargs)) + return console.export_text() + + +# --------------------------------------------------------------------------- +# Layout bands +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("width", [160, 120, 60]) +def test_the_comparison_renders_at_every_width(width: int) -> None: + """Wide, medium and narrow terminals must all produce output.""" + output = _render(_changed_diff(), width=width) + + assert "sales" in output + assert "price" in output + assert "city" in output + + +def test_a_narrow_terminal_stacks_each_column_instead_of_tabulating() -> None: + """A side-by-side table squeezed into forty cells is not one any more.""" + wide = _render(_changed_diff(), width=160) + narrow = _render(_changed_diff(), width=60) + + # The stacked form spells each statistic out on its own line, so the + # per-column header row that only the tabular form has is gone. + assert "null fraction" in narrow + assert "uniq" in wide + assert max(len(line) for line in narrow.splitlines()) <= 60 + + +def _numeric_header(output: str) -> str: + """Return the header row of the numeric column table.""" + lines = output.splitlines() + start = next(i for i, line in enumerate(lines) if "Numeric columns" in line) + return lines[start + 1] + + +def test_a_wide_terminal_shows_more_statistics_than_a_medium_one() -> None: + """The least informative measurements are dropped first as space runs out.""" + wide = _numeric_header(_render(_changed_diff(), width=160)) + medium = _numeric_header(_render(_changed_diff(), width=120)) + + assert "min" in wide + assert "max" in wide + assert "uniq" in wide + assert "min" not in medium + assert "p50" in medium + + +# --------------------------------------------------------------------------- +# Markup safety +# --------------------------------------------------------------------------- + + +def test_names_that_look_like_markup_are_printed_verbatim() -> None: + """A dataset called `sales[/]report` is not markup, and must not be parsed.""" + old = _payload(dataset_name="sales[/]report") + new = _payload( + dataset_name="sales[/]report", + snapshot_id="22222222-2222-4222-8222-222222222222", + num_rows=1500, + ) + for payload in (old, new): + payload["columns"][0]["name"] = "price[/]usd" + payload["column_stats"]["price[/]usd"] = payload["column_stats"].pop("price") + new["column_stats"]["price[/]usd"]["mean"] = 40.0 + + output = _render(diff_snapshots(old, new)) + + assert "sales[/]report" in output + assert "price[/]usd" in output + + +def test_a_category_that_looks_like_markup_is_printed_verbatim() -> None: + """Categories come straight from the data and are the least trusted of all.""" + new = _payload(snapshot_id="22222222-2222-4222-8222-222222222222") + new["column_stats"]["city"]["top_values"] = {"[bold]NY": 900, "LA": 600} + + output = _render(diff_snapshots(_payload(), new)) + + assert "[bold]NY" in output + + +# --------------------------------------------------------------------------- +# Content +# --------------------------------------------------------------------------- + + +def test_an_unchanged_comparison_says_so_and_shows_no_tables() -> None: + """Two identical snapshots need one sentence, not an empty table.""" + output = _render(diff_snapshots(_payload(), _payload())) + + assert "identical" in output + assert "Schema" not in output + + +def test_show_unchanged_includes_a_column_that_did_not_move() -> None: + """`--all` is for reading the whole picture, not only what changed.""" + diff = _changed_diff() + + default = _render(diff) + everything = _render(diff, show_unchanged=True) + + assert len(everything.splitlines()) > len(default.splitlines()) + + +def test_oneline_is_a_single_line() -> None: + """The condensed form has to fit a shell prompt or a CI log line.""" + output = _render(_changed_diff(), oneline=True).strip() + + assert len(output.splitlines()) == 1 + assert "CRITICAL" in output + + +def test_a_long_list_of_schema_changes_is_capped() -> None: + """A cross-dataset diff can add hundreds of columns; the table stays readable.""" + total = _MAX_LISTED_COLUMNS + 5 + new = _payload(snapshot_id="22222222-2222-4222-8222-222222222222") + new["columns"] = [ + {"name": f"col{i}", "dtype": "float64", "nullable": True} for i in range(total) + ] + new["column_stats"] = { + f"col{i}": {"null_count": 0, "null_fraction": 0.0} for i in range(total) + } + + output = _render(diff_snapshots(_payload(), new)) + + assert "and 7 more" in output + + +def test_comparing_two_datasets_is_called_out(tmp_path: object) -> None: + """A cross-dataset comparison is legitimate, but the reader should know.""" + diff = diff_snapshots( + _payload(), _payload(num_rows=1500), old_dataset_id="a", new_dataset_id="b" + ) + + output = _render(diff) + + assert "two different datasets" in output diff --git a/tests/test_diff_report.py b/tests/test_diff_report.py new file mode 100644 index 0000000..04d4a46 --- /dev/null +++ b/tests/test_diff_report.py @@ -0,0 +1,254 @@ +"""Tests for the PDF report in src/dstrack/_diff_report.py.""" + +import base64 +import hashlib +import re +import sys +import zlib +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + +from dstrack.diff import SnapshotDiff, diff_snapshots +from dstrack.errors import MissingDependencyError + +pytest.importorskip("reportlab", reason="the `report` extra is not installed") + +from dstrack._diff_report import write_pdf_report + +NOW = datetime(2026, 1, 2, 12, 30, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +def _payload(columns: int = 2, **overrides: Any) -> dict[str, Any]: + """Build a snapshot payload with `columns` numeric columns.""" + payload: dict[str, Any] = { + "snapshot_id": "11111111-1111-4111-8111-111111111111", + "created_at": "2026-01-01T00:00:00+00:00", + "created_by": "tester", + "dataset_name": "sales", + "dataset_path": "data/sales.csv", + "source_hash": "a" * 64, + "num_rows": 1000, + "num_columns": columns, + "columns": [ + {"name": f"col{i}", "dtype": "float64", "nullable": True} + for i in range(columns) + ], + "column_stats": { + f"col{i}": { + "null_count": 0, + "null_fraction": 0.0, + "mean": 10.0, + "std": 2.0, + "min": 0.0, + "max": 20.0, + "num_unique": 50, + "percentiles": {"p5": 2.0, "p50": 10.0, "p95": 18.0}, + "histogram": { + "bin_edges": [0.0, 5.0, 10.0, 15.0, 20.0], + "counts": [100, 400, 400, 100], + }, + } + for i in range(columns) + }, + "duplicate_row_fraction": 0.0, + "constant_columns": [], + "high_null_columns": [], + } + payload.update(overrides) + return payload + + +def _diff(columns: int = 2, **overrides: Any) -> SnapshotDiff: + """Build a diff in which every column moved.""" + old = _payload(columns) + new = _payload( + columns, + snapshot_id="22222222-2222-4222-8222-222222222222", + num_rows=1500, + source_hash="b" * 64, + **overrides, + ) + for stats in new["column_stats"].values(): + stats["mean"] = 40.0 + stats["null_fraction"] = 0.2 + return diff_snapshots(old, new, old_dataset_id="ds", new_dataset_id="ds") + + +def _pages(pdf: bytes) -> int: + """Count the pages a PDF declares.""" + return pdf.count(b"/Type /Page") - pdf.count(b"/Type /Pages") + + +def _text(pdf: bytes) -> str: + """Return the text drawn on a reportlab PDF, one line of it per space. + + Reportlab ASCII85-encodes and deflates its page streams, so the words + are not visible in the raw bytes. Decoding them here avoids adding a PDF + parser to the test dependencies. A line can be drawn as several `Tj` + fragments (an escaped `<` gets one of its own), so fragments are joined + as they sit and only the `T*` line breaks between them become spaces. + """ + lines: list[str] = [] + for match in re.finditer(rb"stream\r?\n(.*?)endstream", pdf, re.DOTALL): + data = match.group(1).strip() + try: + data = zlib.decompress(base64.a85decode(data.removesuffix(b"~>"))) + except (ValueError, zlib.error): + continue # an image or font stream, not page content + for block in re.findall(rb"BT(.*?)ET", data, re.DOTALL): + for line in block.split(b"T*"): + fragments = re.findall(rb"\((.*?)(? None: + """The file has to be a PDF before anything else about it matters.""" + path = tmp_path / "diff.pdf" + + write_pdf_report(_diff(), path, now=NOW) + + content = path.read_bytes() + assert content.startswith(b"%PDF-") + assert content.endswith(b"%%EOF\n") + assert len(content) > 1024 + assert _pages(content) >= 1 + + +def test_an_unchanged_comparison_still_produces_a_report(tmp_path: Path) -> None: + """ "Nothing changed" is a result worth reporting, not a reason to fail.""" + path = tmp_path / "same.pdf" + + write_pdf_report(diff_snapshots(_payload(), _payload()), path, now=NOW) + + assert path.read_bytes().startswith(b"%PDF-") + + +def test_many_columns_produce_a_multi_page_report(tmp_path: Path) -> None: + """A wide dataset must paginate rather than overflow one page.""" + path = tmp_path / "wide.pdf" + + write_pdf_report(_diff(columns=30), path, now=NOW) + + assert _pages(path.read_bytes()) > 1 + + +def test_a_cross_dataset_comparison_is_reported(tmp_path: Path) -> None: + """The report carries the caveat the terminal prints as a warning.""" + across = tmp_path / "across.pdf" + within = tmp_path / "within.pdf" + old, new = _payload(), _payload(num_rows=1500) + + write_pdf_report( + diff_snapshots(old, new, old_dataset_id="a", new_dataset_id="b"), + across, + now=NOW, + ) + write_pdf_report( + diff_snapshots(old, new, old_dataset_id="a", new_dataset_id="a"), + within, + now=NOW, + ) + + assert "two different datasets" in _text(across.read_bytes()) + assert "two different datasets" not in _text(within.read_bytes()) + + +# --------------------------------------------------------------------------- +# Escaping +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "markup", ["a", "tom & jerry", 'say "what"', "bold", "x"] +) +def test_text_that_looks_like_markup_is_drawn_verbatim( + tmp_path: Path, markup: str +) -> None: + """`Paragraph` parses a markup dialect, so every string from the data must + be escaped: dataset and column names, and the categories a column gained + and lost, which come straight from the data and are the least trusted. + + Each place gets its own prefix, so one that is drawn correctly cannot + hide another that is not. + """ + path = tmp_path / "escaped.pdf" + dataset, column = f"dataset {markup}", f"column {markup}" + removed, added = f"removed {markup}", f"added {markup}" + old = _payload(columns=1, dataset_name=dataset) + new = _payload( + columns=1, + dataset_name=dataset, + snapshot_id="22222222-2222-4222-8222-222222222222", + ) + for payload, category, count in ((old, removed, 600), (new, added, 900)): + payload["columns"][0]["name"] = column + payload["column_stats"] = { + column: { + "null_count": 0, + "null_fraction": 0.0, + "num_unique": 2, + "top_values": {category: count, "plain": 1000 - count}, + "top_values_coverage": 1.0, + } + } + + write_pdf_report(diff_snapshots(old, new), path, now=NOW) + + text = _text(path.read_bytes()) + for drawn in (dataset, column, removed, added): + assert drawn in text, f"{drawn!r} should be drawn verbatim" + + +# --------------------------------------------------------------------------- +# The optional dependency and the packaged logo +# --------------------------------------------------------------------------- + + +def test_a_missing_reportlab_explains_how_to_install_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An optional feature must name its extra, not raise an ImportError.""" + monkeypatch.setitem(sys.modules, "reportlab", None) + monkeypatch.setitem(sys.modules, "reportlab.lib", None) + + with pytest.raises(MissingDependencyError, match=r"dstrack\[report\]"): + write_pdf_report(_diff(), tmp_path / "nope.pdf", now=NOW) + + +def test_the_packaged_logo_matches_the_one_in_the_docs() -> None: + """Two copies of an asset drift; this is the only thing that stops it.""" + packaged = Path("src/dstrack/assets/dstrack-logo.png").read_bytes() + documented = Path("docs/assets/dstrack-logo.png").read_bytes() + + assert hashlib.sha256(packaged).hexdigest() == ( + hashlib.sha256(documented).hexdigest() + ) + + +def test_a_report_still_builds_without_its_logo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing asset is a cosmetic loss, not a reason to fail the command.""" + from dstrack import _diff_report + + monkeypatch.setattr(_diff_report, "_LOGO_RESOURCE", "assets/not-there.png") + path = tmp_path / "nologo.pdf" + + write_pdf_report(_diff(), path, now=NOW) + + assert path.read_bytes().startswith(b"%PDF-") diff --git a/tests/test_diff_resolve.py b/tests/test_diff_resolve.py new file mode 100644 index 0000000..cdcbc8e --- /dev/null +++ b/tests/test_diff_resolve.py @@ -0,0 +1,427 @@ +"""Tests for reference resolution in src/dstrack/_resolve.py and the store reads it uses.""" + +import json +import uuid +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dstrack import cache, store +from dstrack._cli import app +from dstrack._resolve import resolve_pair, resolve_snapshot_ref +from dstrack.errors import ( + AmbiguousSnapshotError, + DatasetNotFoundError, + SnapshotNotFoundError, + StoreCorruptionError, +) + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create an empty local store under a fresh cwd and return its root.""" + monkeypatch.delenv("DSTRACK_ROOT_PATH", raising=False) + monkeypatch.chdir(tmp_path) + root = tmp_path / ".dstrack" + (root / "datasets").mkdir(parents=True) + return root + + +def _write_csv(path: Path, rows: int = 3) -> Path: + """Write a small, valid CSV file and return its path.""" + lines = ["a,b"] + [f"{i},{i * 2}" for i in range(rows)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def _track(root: Path, path: Path, *args: str) -> str: + """Track `path` through the CLI and return its dataset id.""" + result = runner.invoke(app, ["track", str(path), *args]) + assert result.exit_code == 0, result.output + return _dataset_id_of(root, path) + + +def _dataset_id_of(root: Path, path: Path) -> str: + """Return the id of the dataset whose snapshots recorded `path`.""" + for snapshot in root.glob("datasets/*/snapshots/*.json"): + payload = json.loads(snapshot.read_text(encoding="utf-8")) + if Path(payload["dataset_path"]).name == path.name: + return str(snapshot.parent.parent.name) + raise AssertionError(f"no dataset recorded for {path}") + + +def _synced(root: Path) -> Path: + """Bring the index up to date, so prefix and path lookups can see the store.""" + cache.sync(root) + return root + + +def _resolve(spec: str, root: Path) -> object: + """Resolve one reference against a synced store.""" + return resolve_snapshot_ref(spec, store_root=_synced(root), root=None) + + +def _head(root: Path, dataset_id: str) -> str: + """Return a dataset's latest snapshot id.""" + head = store.read_head(dataset_id, store_root=root) + assert head is not None + return head + + +# --------------------------------------------------------------------------- +# store.snapshot_path and store.read_snapshot +# --------------------------------------------------------------------------- + + +def test_read_snapshot_returns_what_was_written( + tmp_path: Path, store_root: Path +) -> None: + """A stored payload round-trips through the read API unchanged.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + + payload = store.read_snapshot(dataset_id, snapshot_id, store_root=store_root) + + assert payload["snapshot_id"] == snapshot_id + assert payload["dataset_name"] == "data" + assert payload["num_rows"] == 3 + + +@pytest.mark.parametrize( + "snapshot_id", ["../../escape", "nested/id", "/absolute", "../sibling"] +) +def test_a_traversing_snapshot_id_is_refused( + store_root: Path, snapshot_id: str +) -> None: + """An id must name one file directly inside the dataset's snapshots/. + + A bare ``..`` is deliberately not in this list: the ``.json`` suffix makes + it the ordinary filename ``...json``, which stays inside the directory and + is therefore nothing to refuse. + """ + with pytest.raises(ValueError, match="snapshot_id"): + store.snapshot_path(str(uuid.uuid4()), snapshot_id, store_root=store_root) + + +def test_reading_a_snapshot_that_is_not_there_says_so(store_root: Path) -> None: + """A missing payload is reported as missing, not as corruption.""" + dataset_id = str(uuid.uuid4()) + (store_root / "datasets" / dataset_id / "snapshots").mkdir(parents=True) + + with pytest.raises(SnapshotNotFoundError, match="No snapshot"): + store.read_snapshot(dataset_id, str(uuid.uuid4()), store_root=store_root) + + +def test_a_payload_that_is_not_an_object_is_corruption(store_root: Path) -> None: + """The store wrote an object; anything else means the file was damaged.""" + dataset_id = str(uuid.uuid4()) + snapshot_id = str(uuid.uuid4()) + snapshots = store_root / "datasets" / dataset_id / "snapshots" + snapshots.mkdir(parents=True) + (snapshots / f"{snapshot_id}.json").write_text("[1, 2, 3]", encoding="utf-8") + + with pytest.raises(StoreCorruptionError, match="JSON object"): + store.read_snapshot(dataset_id, snapshot_id, store_root=store_root) + + +# --------------------------------------------------------------------------- +# cache.find_snapshots_by_prefix +# --------------------------------------------------------------------------- + + +def test_a_prefix_finds_the_snapshot_and_the_dataset_holding_it( + tmp_path: Path, store_root: Path +) -> None: + """A snapshot id says nothing about its dataset, so the lookup returns both.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + + matches = cache.find_snapshots_by_prefix( + snapshot_id[:8], store_root=_synced(store_root) + ) + + assert len(matches) == 1 + assert matches[0].dataset_id == dataset_id + assert matches[0].snapshot_id == snapshot_id + + +@pytest.mark.parametrize("prefix", ["____", "%", "a_b%"]) +def test_like_metacharacters_in_a_prefix_match_nothing( + tmp_path: Path, store_root: Path, prefix: str +) -> None: + """An unescaped `_` is a wildcard and would match ids the user never named.""" + _track(store_root, _write_csv(tmp_path / "data.csv")) + + matches = cache.find_snapshots_by_prefix(prefix, store_root=_synced(store_root)) + + assert matches == [] + + +# --------------------------------------------------------------------------- +# resolve_snapshot_ref +# --------------------------------------------------------------------------- + + +def test_a_full_snapshot_id_resolves_to_that_snapshot( + tmp_path: Path, store_root: Path +) -> None: + """The most explicit reference there is.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + + resolved = _resolve(snapshot_id, store_root) + + assert resolved.snapshot_id == snapshot_id + assert resolved.dataset_id == dataset_id + assert resolved.payload["snapshot_id"] == snapshot_id + + +def test_a_dataset_id_resolves_to_its_latest_snapshot( + tmp_path: Path, store_root: Path +) -> None: + """Naming a dataset means naming where it stands now.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + _write_csv(csv, rows=5) + _track(store_root, csv) + + resolved = _resolve(dataset_id, store_root) + + assert resolved.snapshot_id == _head(store_root, dataset_id) + assert resolved.payload["num_rows"] == 5 + + +@pytest.mark.parametrize("spelling", ["upper", "nodashes", "braced"]) +def test_an_id_is_recognized_however_it_was_pasted( + tmp_path: Path, store_root: Path, spelling: str +) -> None: + """Uppercase, brace-wrapped and dash-free spellings all name the same thing.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + variants = { + "upper": snapshot_id.upper(), + "nodashes": snapshot_id.replace("-", ""), + "braced": f"{{{snapshot_id}}}", + } + + resolved = _resolve(variants[spelling], store_root) + + assert resolved.snapshot_id == snapshot_id + + +def test_a_short_prefix_resolves_like_a_short_commit_hash( + tmp_path: Path, store_root: Path +) -> None: + """Nobody types a UUID in full.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + + resolved = _resolve(snapshot_id[:8], store_root) + + assert resolved.snapshot_id == snapshot_id + + +def test_an_ambiguous_prefix_lists_what_it_matched( + tmp_path: Path, store_root: Path +) -> None: + """A prefix matching several ids must show which, so one can be chosen.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + snapshot_id = _head(store_root, dataset_id) + + # Plant a second snapshot sharing the first four characters of its id. + twin_id = snapshot_id[:4] + str(uuid.uuid4())[4:] + payload = store.read_snapshot(dataset_id, snapshot_id, store_root=store_root) + payload["snapshot_id"] = twin_id + payload["parent_snapshot_id"] = snapshot_id + snapshots = store_root / "datasets" / dataset_id / "snapshots" + (snapshots / f"{twin_id}.json").write_text(json.dumps(payload), encoding="utf-8") + log = store_root / "datasets" / dataset_id / "log.jsonl" + with log.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({**payload, "snapshot_id": twin_id}) + "\n") + (store_root / "datasets" / dataset_id / "HEAD").write_text( + f"{twin_id}\n", encoding="utf-8" + ) + + with pytest.raises(AmbiguousSnapshotError, match="ambiguous"): + _resolve(snapshot_id[:4], store_root) + + +def test_a_prefix_shorter_than_the_minimum_is_not_treated_as_one( + tmp_path: Path, store_root: Path +) -> None: + """Three characters match almost anything and are far more likely a path.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + prefix = _head(store_root, dataset_id)[:3] + + with pytest.raises(DatasetNotFoundError): + _resolve(prefix, store_root) + + +def test_a_tracked_path_resolves_to_its_latest_snapshot( + tmp_path: Path, store_root: Path +) -> None: + """The reference a user reaches for first.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + + resolved = _resolve(str(csv), store_root) + + assert resolved.dataset_id == dataset_id + + +def test_a_tracked_file_that_was_deleted_still_resolves( + tmp_path: Path, store_root: Path +) -> None: + """History outlives the file, exactly as it does for `dstrack log`.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + csv.unlink() + + resolved = _resolve(str(csv), store_root) + + assert resolved.dataset_id == dataset_id + + +def test_an_unknown_reference_lists_the_store_s_datasets( + tmp_path: Path, store_root: Path +) -> None: + """An error a reader can act on names what does exist.""" + _track(store_root, _write_csv(tmp_path / "data.csv")) + + with pytest.raises(DatasetNotFoundError, match="Known datasets"): + _resolve(str(tmp_path / "nope.csv"), store_root) + + +def test_a_dataset_with_no_snapshots_says_so(store_root: Path) -> None: + """An empty dataset directory is not an error, but it cannot be compared.""" + dataset_id = str(uuid.uuid4()) + (store_root / "datasets" / dataset_id).mkdir(parents=True) + + with pytest.raises(SnapshotNotFoundError, match="no snapshots"): + _resolve(dataset_id, store_root) + + +# --------------------------------------------------------------------------- +# resolve_pair +# --------------------------------------------------------------------------- + + +def _pair( + root: Path, + specs: list[str], + *, + from_ref: str | None = None, + to_ref: str | None = None, +) -> tuple[object, object]: + """Resolve both sides against a synced store.""" + return resolve_pair( + specs, + from_ref=from_ref, + to_ref=to_ref, + store_root=_synced(root), + root=None, + ) + + +def test_one_reference_compares_a_snapshot_against_its_parent( + tmp_path: Path, store_root: Path +) -> None: + """The question `dstrack diff` is usually asked.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + first = _head(store_root, dataset_id) + _write_csv(csv, rows=7) + _track(store_root, csv) + second = _head(store_root, dataset_id) + + old, new = _pair(store_root, [str(csv)]) + + assert old.snapshot_id == first + assert new.snapshot_id == second + + +def test_the_first_snapshot_of_a_dataset_has_nothing_to_compare_against( + tmp_path: Path, store_root: Path +) -> None: + """There is no parent, and saying so beats comparing against nothing.""" + csv = _write_csv(tmp_path / "data.csv") + _track(store_root, csv) + + with pytest.raises(SnapshotNotFoundError, match="first snapshot"): + _pair(store_root, [str(csv)]) + + +def test_two_references_are_taken_in_the_order_given( + tmp_path: Path, store_root: Path +) -> None: + """The first argument is the baseline.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + first = _head(store_root, dataset_id) + _write_csv(csv, rows=7) + _track(store_root, csv) + second = _head(store_root, dataset_id) + + old, new = _pair(store_root, [first, second]) + + assert (old.snapshot_id, new.snapshot_id) == (first, second) + + +def test_from_alone_compares_against_where_the_dataset_stands_now( + tmp_path: Path, store_root: Path +) -> None: + """Naming only a baseline asks what has happened since.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + first = _head(store_root, dataset_id) + _write_csv(csv, rows=7) + _track(store_root, csv) + + old, new = _pair(store_root, [], from_ref=first) + + assert old.snapshot_id == first + assert new.snapshot_id == _head(store_root, dataset_id) + + +def test_to_alone_compares_against_the_snapshot_it_succeeded( + tmp_path: Path, store_root: Path +) -> None: + """Naming only a later snapshot asks what that snapshot changed.""" + csv = _write_csv(tmp_path / "data.csv") + dataset_id = _track(store_root, csv) + first = _head(store_root, dataset_id) + _write_csv(csv, rows=7) + _track(store_root, csv) + second = _head(store_root, dataset_id) + + old, new = _pair(store_root, [], to_ref=second) + + assert (old.snapshot_id, new.snapshot_id) == (first, second) + + +def test_two_datasets_may_be_compared(tmp_path: Path, store_root: Path) -> None: + """Comparing across datasets is a supported use, not an error.""" + a = _write_csv(tmp_path / "a.csv") + b = _write_csv(tmp_path / "b.csv", rows=9) + dataset_a = _track(store_root, a) + dataset_b = _track(store_root, b) + + old, new = _pair(store_root, [str(a), str(b)]) + + assert old.dataset_id == dataset_a + assert new.dataset_id == dataset_b diff --git a/tests/test_log.py b/tests/test_log.py index af3726a..cb86b47 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -12,15 +12,15 @@ import typer from typer.testing import CliRunner -from dstrack import _log, cache +from dstrack import _resolve, cache from dstrack._cli import app -from dstrack._log import ( - _canonical_uuid, - _ensure_index, - _known_datasets, - _resolve_target, -) from dstrack._log_render import _humanize +from dstrack._resolve import ( + canonical_uuid, + ensure_index, + known_datasets, + resolve_target, +) from dstrack.errors import DatasetNotFoundError, StoreCorruptionError runner = CliRunner() @@ -70,14 +70,14 @@ def _dataset_id_of(root: Path, path: Path) -> str: def _pretend_terminal(monkeypatch: pytest.MonkeyPatch) -> None: - """Make `_log` see a terminal on stdin, so it offers to build the index. + """Make `_resolve` see a terminal on stdin, so it offers to build the index. `CliRunner` installs its own `sys.stdin` for the duration of an invocation, which would undo a patch of the real one; the module's own reference to `sys` is replaced instead. Only `stdin.isatty` is ever read through it. """ monkeypatch.setattr( - _log, "sys", SimpleNamespace(stdin=SimpleNamespace(isatty=lambda: True)) + _resolve, "sys", SimpleNamespace(stdin=SimpleNamespace(isatty=lambda: True)) ) @@ -351,14 +351,14 @@ def test_ensure_index_rebuild_discards_the_existing_index( assert _log_cmd(str(csv)).exit_code == 0 assert cache.index_exists(store_root) - _ensure_index(store_root, yes=False, rebuild=True) + ensure_index(store_root, yes=False, rebuild=True) assert not cache.index_exists(store_root) def test_ensure_index_rebuild_on_a_missing_index_is_fine(store_root: Path) -> None: """--rebuild does not require an index to already be there.""" - _ensure_index(store_root, yes=False, rebuild=True) + ensure_index(store_root, yes=False, rebuild=True) assert not cache.index_exists(store_root) @@ -378,7 +378,7 @@ def test_log_rebuild_still_shows_the_history(tmp_path: Path, store_root: Path) - def test_ensure_index_without_a_terminal_raises_exit_one(store_root: Path) -> None: """The no-tty branch exits rather than blocking on a prompt.""" with pytest.raises(typer.Exit) as excinfo: - _ensure_index(store_root, yes=False, rebuild=False) + ensure_index(store_root, yes=False, rebuild=False) assert excinfo.value.exit_code == 1 @@ -404,13 +404,13 @@ def test_canonical_uuid_accepts_alternate_spellings( """Uppercased, braced, dashless and urn ids all name the same dataset.""" value = str(uuid.uuid4()) - assert _canonical_uuid(spell(value)) == value + assert canonical_uuid(spell(value)) == value @pytest.mark.parametrize("value", ["", "data.csv", "not-a-uuid", "../etc/passwd"]) def test_canonical_uuid_rejects_non_uuids(value: str) -> None: """Anything that is not a UUID in some spelling is not an id.""" - assert _canonical_uuid(value) is None + assert canonical_uuid(value) is None def test_resolve_target_prefers_an_existing_id_over_a_path( @@ -423,7 +423,7 @@ def test_resolve_target_prefers_an_existing_id_over_a_path( # A file in the cwd named exactly like the id must not be picked up. _write_csv(tmp_path / dataset_id) - resolved = _resolve_target(dataset_id, store_root=store_root, root=None) + resolved = resolve_target(dataset_id, store_root=store_root, root=None) assert resolved == dataset_id @@ -434,7 +434,7 @@ def test_resolve_target_falls_back_to_a_path(tmp_path: Path, store_root: Path) - dataset_id = _track(store_root, csv) cache.sync(store_root) - assert _resolve_target(str(csv), store_root=store_root, root=None) == dataset_id + assert resolve_target(str(csv), store_root=store_root, root=None) == dataset_id def test_resolve_target_relative_path_matches(tmp_path: Path, store_root: Path) -> None: @@ -443,7 +443,7 @@ def test_resolve_target_relative_path_matches(tmp_path: Path, store_root: Path) dataset_id = _track(store_root, csv) cache.sync(store_root) - assert _resolve_target("data.csv", store_root=store_root, root=None) == dataset_id + assert resolve_target("data.csv", store_root=store_root, root=None) == dataset_id def test_resolve_target_unknown_id_reports_the_id(store_root: Path) -> None: @@ -452,7 +452,7 @@ def test_resolve_target_unknown_id_reports_the_id(store_root: Path) -> None: missing = str(uuid.uuid4()) with pytest.raises(DatasetNotFoundError, match="No dataset") as excinfo: - _resolve_target(missing, store_root=store_root, root=None) + resolve_target(missing, store_root=store_root, root=None) assert missing in str(excinfo.value) @@ -462,7 +462,7 @@ def test_resolve_target_unknown_path_suggests_the_id(store_root: Path) -> None: cache.sync(store_root) with pytest.raises(DatasetNotFoundError, match="No tracked dataset") as excinfo: - _resolve_target("missing.csv", store_root=store_root, root=None) + resolve_target("missing.csv", store_root=store_root, root=None) message = str(excinfo.value) assert "missing.csv" in message @@ -478,9 +478,9 @@ def test_resolve_target_honours_an_explicit_root( dataset_id = _track(store_root, csv, "--root", str(nested)) cache.sync(store_root) - assert _resolve_target(str(csv), store_root=store_root, root=nested) == dataset_id + assert resolve_target(str(csv), store_root=store_root, root=nested) == dataset_id with pytest.raises(DatasetNotFoundError): - _resolve_target(str(csv), store_root=store_root, root=None) + resolve_target(str(csv), store_root=store_root, root=None) # --------------------------------------------------------------------------- @@ -492,7 +492,7 @@ def test_known_datasets_on_an_empty_store(store_root: Path) -> None: """An empty store says so, and points at the command that fills it.""" cache.sync(store_root) - listing = _known_datasets(store_root) + listing = known_datasets(store_root) assert "(none)" in listing assert "dstrack track" in listing @@ -506,7 +506,7 @@ def test_known_datasets_lists_id_name_and_path( dataset_id = _track(store_root, csv, "--name", "clients") cache.sync(store_root) - listing = _known_datasets(store_root) + listing = known_datasets(store_root) assert dataset_id in listing assert "clients" in listing @@ -519,7 +519,7 @@ def test_known_datasets_marks_missing_fields(store_root: Path) -> None: (store_root / "datasets" / dataset_id).mkdir() cache.sync(store_root) - listing = _known_datasets(store_root) + listing = known_datasets(store_root) assert dataset_id in listing assert "-" in listing @@ -536,12 +536,12 @@ def test_known_datasets_drops_a_dataset_deleted_from_the_store( removed_id = _track(store_root, _write_csv(tmp_path / "gone.csv")) kept_id = _track(store_root, _write_csv(tmp_path / "kept.csv")) cache.sync(store_root) - assert removed_id in _known_datasets(store_root) + assert removed_id in known_datasets(store_root) shutil.rmtree(store_root / "datasets" / removed_id) cache.sync(store_root) - listing = _known_datasets(store_root) + listing = known_datasets(store_root) assert removed_id not in listing assert kept_id in listing @@ -596,7 +596,7 @@ def test_log_surfaces_a_corrupt_store( def _boom(*args: object, **kwargs: object) -> bool: raise StoreCorruptionError("log.jsonl is not readable") - monkeypatch.setattr(_log.store, "dataset_exists", _boom) + monkeypatch.setattr(_resolve.store, "dataset_exists", _boom) result = _log_cmd(dataset_id) diff --git a/uv.lock b/uv.lock index 2636cda..709ee02 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -148,6 +222,11 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +report = [ + { name = "reportlab" }, +] + [package.dev-dependencies] dev = [ { name = "jsonschema" }, @@ -159,9 +238,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "reportlab", marker = "extra == 'report'", specifier = ">=4.2" }, { name = "rich", specifier = ">=15.0.0" }, { name = "typer", specifier = ">=0.26.8" }, ] +provides-extras = ["report"] [package.metadata.requires-dev] dev = [ @@ -455,6 +536,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -618,6 +784,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "reportlab" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, +] + [[package]] name = "rich" version = "15.0.0"