pipeverify 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pipeverify-0.2.0 → pipeverify-0.2.2}/PKG-INFO +36 -12
- {pipeverify-0.2.0 → pipeverify-0.2.2}/README.md +35 -11
- pipeverify-0.2.2/metrics/README.md +14 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/__init__.py +1 -1
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/checks.py +12 -12
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/core.py +1 -1
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/source_checks.py +6 -6
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/sources.py +19 -2
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pyproject.toml +1 -1
- {pipeverify-0.2.0 → pipeverify-0.2.2}/.gitignore +0 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/LICENSE +0 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/NOTICE +0 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/cli.py +0 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/explain.py +0 -0
- {pipeverify-0.2.0 → pipeverify-0.2.2}/pipeverify/report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pipeverify
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Check that a pipeline change left the data right, not just the job green.
|
|
5
5
|
Project-URL: Homepage, https://github.com/akshaysharma1088/pipeverify
|
|
6
6
|
Project-URL: Issues, https://github.com/akshaysharma1088/pipeverify/issues
|
|
@@ -29,16 +29,28 @@ Requires-Dist: pandas>=2.0; extra == 'sql'
|
|
|
29
29
|
Requires-Dist: sqlalchemy>=2.0; extra == 'sql'
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="docs/assets/banner.svg" alt="pipeverify" width="720">
|
|
34
|
+
</p>
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
<h3 align="center">The open-source verification step for data pipeline changes.</h3>
|
|
37
|
+
|
|
38
|
+
<p align="center">
|
|
39
|
+
Check that a change left the data right, not just the job green. pipeverify compares tables
|
|
40
|
+
before and after a change, and against the raw source, and returns <b>PASS</b>, <b>REVIEW</b> or <b>BLOCK</b>.
|
|
41
|
+
Works with SQL, Spark, dbt, Airflow, or a fix written by an AI agent.
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
<p align="center">
|
|
45
|
+
<a href="https://pypi.org/project/pipeverify/"><img src="https://img.shields.io/pypi/v/pipeverify?label=pypi&color=38bdf8" alt="PyPI"></a>
|
|
46
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-4ade80" alt="License"></a>
|
|
47
|
+
<a href="https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml"><img src="https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
48
|
+
<a href="https://doi.org/10.5281/zenodo.23542277"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23542277.svg" alt="DOI"></a>
|
|
49
|
+
<a href="https://colab.research.google.com/github/akshaysharma1088/pipeverify/blob/main/examples/notebooks/quickstart.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
---
|
|
35
53
|
|
|
36
|
-
[](https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml)
|
|
37
|
-
[](LICENSE)
|
|
38
|
-

|
|
39
|
-

|
|
40
|
-
[](https://pypi.org/project/pipeverify/)
|
|
41
|
-
[](https://doi.org/10.5281/zenodo.23542278)
|
|
42
54
|
|
|
43
55
|
A pipeline change can pass every test, finish on time and keep the schema intact while quietly
|
|
44
56
|
dropping rows, duplicating keys or changing what a column means. More of these changes are now
|
|
@@ -65,6 +77,8 @@ so any team can run it on every change.
|
|
|
65
77
|
|
|
66
78
|
## Quick start
|
|
67
79
|
|
|
80
|
+
No install needed to try it: [open the 5-minute notebook in Colab](https://colab.research.google.com/github/akshaysharma1088/pipeverify/blob/main/examples/notebooks/quickstart.ipynb).
|
|
81
|
+
|
|
68
82
|
```bash
|
|
69
83
|
pip install pipeverify
|
|
70
84
|
|
|
@@ -107,7 +121,7 @@ Every one of these runs successfully and keeps the same schema.
|
|
|
107
121
|
|
|
108
122
|
| Source | CLI form | Config form |
|
|
109
123
|
|---|---|---|
|
|
110
|
-
| CSV, Parquet, JSON file | `data/orders.parquet` | `path: data/orders.parquet` |
|
|
124
|
+
| CSV, Parquet, JSON file (CSV and JSON can be `.gz` or `.zst` compressed) | `data/orders.parquet` | `path: data/orders.parquet` |
|
|
111
125
|
| DuckDB table | `duckdb:warehouse.db:gold.orders` | `{duckdb: warehouse.db, table: gold.orders}` |
|
|
112
126
|
| DuckDB query | `"duckdb:warehouse.db:SELECT ..."` | `{duckdb: warehouse.db, query: "SELECT ..."}` |
|
|
113
127
|
| Any SQLAlchemy database (Postgres, Snowflake, BigQuery, ...) | | `{url: "postgresql://${PGUSER}@host/db", table: gold.orders}` |
|
|
@@ -116,6 +130,8 @@ Every one of these runs successfully and keeps the same schema.
|
|
|
116
130
|
SQL databases need `pip install "pipeverify[sql]"` plus the driver for your database. Environment
|
|
117
131
|
variables in URLs are expanded, so credentials stay out of the config file.
|
|
118
132
|
|
|
133
|
+
See [SQL source examples](docs/sources.md) for Postgres, Snowflake and BigQuery.
|
|
134
|
+
|
|
119
135
|
## Config
|
|
120
136
|
|
|
121
137
|
Check many tables at once with `pipeverify.yml` (`pipeverify init` writes a starter file):
|
|
@@ -215,7 +231,7 @@ jobs:
|
|
|
215
231
|
steps:
|
|
216
232
|
- uses: actions/checkout@v4
|
|
217
233
|
# ... build the "after" snapshots for this PR here ...
|
|
218
|
-
- uses: akshaysharma1088/pipeverify@v0.
|
|
234
|
+
- uses: akshaysharma1088/pipeverify@v0.2.2
|
|
219
235
|
with:
|
|
220
236
|
config: pipeverify.yml
|
|
221
237
|
fail-on: block # or review, or never
|
|
@@ -250,6 +266,12 @@ This adds a short plain-English explanation of what probably happened and what t
|
|
|
250
266
|
It is off by default. Only the findings are sent (check names, counts, percentages, column names);
|
|
251
267
|
example values from your data are removed first. The verdict never depends on the LLM.
|
|
252
268
|
|
|
269
|
+
## Who uses pipeverify
|
|
270
|
+
|
|
271
|
+
See [ADOPTERS.md](ADOPTERS.md). Using it at work or in a project?
|
|
272
|
+
[Tell us how](https://github.com/akshaysharma1088/pipeverify/issues/new?template=share_usage.yml), even
|
|
273
|
+
in one line. It helps other teams decide whether to try it and shapes what gets built next.
|
|
274
|
+
|
|
253
275
|
## Roadmap
|
|
254
276
|
|
|
255
277
|
- Native adapters for dbt (compare a PR's models against production), Airflow and Spark
|
|
@@ -257,7 +279,9 @@ example values from your data are removed first. The verdict never depends on th
|
|
|
257
279
|
- Rules learned from history: what "normal" change looks like for each table
|
|
258
280
|
- Agent integration: a guardrail that coding and data agents call before applying a change
|
|
259
281
|
|
|
260
|
-
Ideas and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
282
|
+
Ideas and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) and the
|
|
283
|
+
[good first issues](https://github.com/akshaysharma1088/pipeverify/labels/good%20first%20issue).
|
|
284
|
+
Questions go in [Discussions](https://github.com/akshaysharma1088/pipeverify/discussions).
|
|
261
285
|
|
|
262
286
|
## Status
|
|
263
287
|
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/banner.svg" alt="pipeverify" width="720">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h3 align="center">The open-source verification step for data pipeline changes.</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Check that a change left the data right, not just the job green. pipeverify compares tables
|
|
9
|
+
before and after a change, and against the raw source, and returns <b>PASS</b>, <b>REVIEW</b> or <b>BLOCK</b>.
|
|
10
|
+
Works with SQL, Spark, dbt, Airflow, or a fix written by an AI agent.
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://pypi.org/project/pipeverify/"><img src="https://img.shields.io/pypi/v/pipeverify?label=pypi&color=38bdf8" alt="PyPI"></a>
|
|
15
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-4ade80" alt="License"></a>
|
|
16
|
+
<a href="https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml"><img src="https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
17
|
+
<a href="https://doi.org/10.5281/zenodo.23542277"><img src="https://zenodo.org/badge/DOI/10.5281/zenodo.23542277.svg" alt="DOI"></a>
|
|
18
|
+
<a href="https://colab.research.google.com/github/akshaysharma1088/pipeverify/blob/main/examples/notebooks/quickstart.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
---
|
|
4
22
|
|
|
5
|
-
[](https://github.com/akshaysharma1088/pipeverify/actions/workflows/ci.yml)
|
|
6
|
-
[](LICENSE)
|
|
7
|
-

|
|
8
|
-

|
|
9
|
-
[](https://pypi.org/project/pipeverify/)
|
|
10
|
-
[](https://doi.org/10.5281/zenodo.23542278)
|
|
11
23
|
|
|
12
24
|
A pipeline change can pass every test, finish on time and keep the schema intact while quietly
|
|
13
25
|
dropping rows, duplicating keys or changing what a column means. More of these changes are now
|
|
@@ -34,6 +46,8 @@ so any team can run it on every change.
|
|
|
34
46
|
|
|
35
47
|
## Quick start
|
|
36
48
|
|
|
49
|
+
No install needed to try it: [open the 5-minute notebook in Colab](https://colab.research.google.com/github/akshaysharma1088/pipeverify/blob/main/examples/notebooks/quickstart.ipynb).
|
|
50
|
+
|
|
37
51
|
```bash
|
|
38
52
|
pip install pipeverify
|
|
39
53
|
|
|
@@ -76,7 +90,7 @@ Every one of these runs successfully and keeps the same schema.
|
|
|
76
90
|
|
|
77
91
|
| Source | CLI form | Config form |
|
|
78
92
|
|---|---|---|
|
|
79
|
-
| CSV, Parquet, JSON file | `data/orders.parquet` | `path: data/orders.parquet` |
|
|
93
|
+
| CSV, Parquet, JSON file (CSV and JSON can be `.gz` or `.zst` compressed) | `data/orders.parquet` | `path: data/orders.parquet` |
|
|
80
94
|
| DuckDB table | `duckdb:warehouse.db:gold.orders` | `{duckdb: warehouse.db, table: gold.orders}` |
|
|
81
95
|
| DuckDB query | `"duckdb:warehouse.db:SELECT ..."` | `{duckdb: warehouse.db, query: "SELECT ..."}` |
|
|
82
96
|
| Any SQLAlchemy database (Postgres, Snowflake, BigQuery, ...) | | `{url: "postgresql://${PGUSER}@host/db", table: gold.orders}` |
|
|
@@ -85,6 +99,8 @@ Every one of these runs successfully and keeps the same schema.
|
|
|
85
99
|
SQL databases need `pip install "pipeverify[sql]"` plus the driver for your database. Environment
|
|
86
100
|
variables in URLs are expanded, so credentials stay out of the config file.
|
|
87
101
|
|
|
102
|
+
See [SQL source examples](docs/sources.md) for Postgres, Snowflake and BigQuery.
|
|
103
|
+
|
|
88
104
|
## Config
|
|
89
105
|
|
|
90
106
|
Check many tables at once with `pipeverify.yml` (`pipeverify init` writes a starter file):
|
|
@@ -184,7 +200,7 @@ jobs:
|
|
|
184
200
|
steps:
|
|
185
201
|
- uses: actions/checkout@v4
|
|
186
202
|
# ... build the "after" snapshots for this PR here ...
|
|
187
|
-
- uses: akshaysharma1088/pipeverify@v0.
|
|
203
|
+
- uses: akshaysharma1088/pipeverify@v0.2.2
|
|
188
204
|
with:
|
|
189
205
|
config: pipeverify.yml
|
|
190
206
|
fail-on: block # or review, or never
|
|
@@ -219,6 +235,12 @@ This adds a short plain-English explanation of what probably happened and what t
|
|
|
219
235
|
It is off by default. Only the findings are sent (check names, counts, percentages, column names);
|
|
220
236
|
example values from your data are removed first. The verdict never depends on the LLM.
|
|
221
237
|
|
|
238
|
+
## Who uses pipeverify
|
|
239
|
+
|
|
240
|
+
See [ADOPTERS.md](ADOPTERS.md). Using it at work or in a project?
|
|
241
|
+
[Tell us how](https://github.com/akshaysharma1088/pipeverify/issues/new?template=share_usage.yml), even
|
|
242
|
+
in one line. It helps other teams decide whether to try it and shapes what gets built next.
|
|
243
|
+
|
|
222
244
|
## Roadmap
|
|
223
245
|
|
|
224
246
|
- Native adapters for dbt (compare a PR's models against production), Airflow and Spark
|
|
@@ -226,7 +248,9 @@ example values from your data are removed first. The verdict never depends on th
|
|
|
226
248
|
- Rules learned from history: what "normal" change looks like for each table
|
|
227
249
|
- Agent integration: a guardrail that coding and data agents call before applying a change
|
|
228
250
|
|
|
229
|
-
Ideas and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
251
|
+
Ideas and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) and the
|
|
252
|
+
[good first issues](https://github.com/akshaysharma1088/pipeverify/labels/good%20first%20issue).
|
|
253
|
+
Questions go in [Discussions](https://github.com/akshaysharma1088/pipeverify/discussions).
|
|
230
254
|
|
|
231
255
|
## Status
|
|
232
256
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Usage metrics
|
|
2
|
+
|
|
3
|
+
Updated 2026-10-22 by `scripts/collect_metrics.py`. Raw history is in the CSV files in this folder.
|
|
4
|
+
|
|
5
|
+
| Metric | Value |
|
|
6
|
+
|---|---|
|
|
7
|
+
| GitHub stars | 1 |
|
|
8
|
+
| Forks | 0 |
|
|
9
|
+
| Watchers | 0 |
|
|
10
|
+
| Repositories that depend on it | n/a |
|
|
11
|
+
| Repository views since 2026-10-08 | 0 |
|
|
12
|
+
| Repository clones since 2026-10-08 | 162 |
|
|
13
|
+
| PyPI downloads since 2026-10-21 (without mirrors) | 228 |
|
|
14
|
+
| PyPI downloads, last 30 days | 0 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""pipeverify: check that a pipeline change left the data right, not just the job green."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
3
|
+
__version__ = "0.2.2"
|
|
4
4
|
|
|
5
5
|
from .checks import Finding, Thresholds # noqa: E402
|
|
6
6
|
from .core import RunResult, TableResult, run_config, verify # noqa: E402
|
|
@@ -113,8 +113,8 @@ def run_checks(
|
|
|
113
113
|
if k not in sb or k not in sa:
|
|
114
114
|
raise ValueError(f"Key column '{k}' is missing from the before or after table")
|
|
115
115
|
|
|
116
|
-
n_before = con.execute(f"SELECT count(*) FROM {q(before)}").
|
|
117
|
-
n_after = con.execute(f"SELECT count(*) FROM {q(after)}").
|
|
116
|
+
n_before = con.execute(f"SELECT count(*) FROM {q(before)}").fetchall()[0][0]
|
|
117
|
+
n_after = con.execute(f"SELECT count(*) FROM {q(after)}").fetchall()[0][0]
|
|
118
118
|
stats: dict[str, Any] = {"rows_before": n_before, "rows_after": n_after,
|
|
119
119
|
"columns_before": len(sb), "columns_after": len(sa)}
|
|
120
120
|
|
|
@@ -152,9 +152,9 @@ def run_checks(
|
|
|
152
152
|
if key:
|
|
153
153
|
kcols = ", ".join(q(k) for k in key)
|
|
154
154
|
dup_before = con.execute(
|
|
155
|
-
f"SELECT count(*) FROM (SELECT {kcols} FROM {q(before)} GROUP BY ALL HAVING count(*) > 1)").
|
|
155
|
+
f"SELECT count(*) FROM (SELECT {kcols} FROM {q(before)} GROUP BY ALL HAVING count(*) > 1)").fetchall()[0][0]
|
|
156
156
|
dup_after = con.execute(
|
|
157
|
-
f"SELECT count(*) FROM (SELECT {kcols} FROM {q(after)} GROUP BY ALL HAVING count(*) > 1)").
|
|
157
|
+
f"SELECT count(*) FROM (SELECT {kcols} FROM {q(after)} GROUP BY ALL HAVING count(*) > 1)").fetchall()[0][0]
|
|
158
158
|
stats.update(duplicate_keys_before=dup_before, duplicate_keys_after=dup_after)
|
|
159
159
|
if dup_after > dup_before:
|
|
160
160
|
findings.append(Finding("duplicate_keys", BLOCK,
|
|
@@ -167,11 +167,11 @@ def run_checks(
|
|
|
167
167
|
on = " AND ".join(f"b.{q(k)} IS NOT DISTINCT FROM a.{q(k)}" for k in key)
|
|
168
168
|
missing = con.execute(
|
|
169
169
|
f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM {q(before)}) b "
|
|
170
|
-
f"WHERE NOT EXISTS (SELECT 1 FROM {q(after)} a WHERE {on})").
|
|
170
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {q(after)} a WHERE {on})").fetchall()[0][0]
|
|
171
171
|
new = con.execute(
|
|
172
172
|
f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM {q(after)}) a "
|
|
173
|
-
f"WHERE NOT EXISTS (SELECT 1 FROM {q(before)} b WHERE {on})").
|
|
174
|
-
distinct_before = con.execute(f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM {q(before)})").
|
|
173
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM {q(before)} b WHERE {on})").fetchall()[0][0]
|
|
174
|
+
distinct_before = con.execute(f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM {q(before)})").fetchall()[0][0]
|
|
175
175
|
stats.update(missing_keys=missing, new_keys=new)
|
|
176
176
|
if missing:
|
|
177
177
|
pct = missing / max(distinct_before, 1) * 100
|
|
@@ -193,7 +193,7 @@ def run_checks(
|
|
|
193
193
|
for c in common:
|
|
194
194
|
nb, na = con.execute(
|
|
195
195
|
f"SELECT (SELECT avg(CASE WHEN {q(c)} IS NULL THEN 1.0 ELSE 0 END) FROM {q(before)}), "
|
|
196
|
-
f"(SELECT avg(CASE WHEN {q(c)} IS NULL THEN 1.0 ELSE 0 END) FROM {q(after)})").
|
|
196
|
+
f"(SELECT avg(CASE WHEN {q(c)} IS NULL THEN 1.0 ELSE 0 END) FROM {q(after)})").fetchall()[0]
|
|
197
197
|
nb, na = float(nb or 0) * 100, float(na or 0) * 100
|
|
198
198
|
rise = na - nb
|
|
199
199
|
if rise > 0:
|
|
@@ -207,7 +207,7 @@ def run_checks(
|
|
|
207
207
|
|
|
208
208
|
# 5. changed values on matched keys
|
|
209
209
|
if unique_keys_join is not None:
|
|
210
|
-
matched = con.execute(f"SELECT count(*) FROM _pv_ub b JOIN _pv_ua a ON {unique_keys_join}").
|
|
210
|
+
matched = con.execute(f"SELECT count(*) FROM _pv_ub b JOIN _pv_ua a ON {unique_keys_join}").fetchall()[0][0]
|
|
211
211
|
stats["matched_rows"] = matched
|
|
212
212
|
for c in common:
|
|
213
213
|
if c in key:
|
|
@@ -219,7 +219,7 @@ def run_checks(
|
|
|
219
219
|
else:
|
|
220
220
|
diff = f"CAST(b.{q(c)} AS VARCHAR) IS DISTINCT FROM CAST(a.{q(c)} AS VARCHAR)"
|
|
221
221
|
changed = con.execute(
|
|
222
|
-
f"SELECT count(*) FROM _pv_ub b JOIN _pv_ua a ON {unique_keys_join} WHERE {diff}").
|
|
222
|
+
f"SELECT count(*) FROM _pv_ub b JOIN _pv_ua a ON {unique_keys_join} WHERE {diff}").fetchall()[0][0]
|
|
223
223
|
if not changed:
|
|
224
224
|
continue
|
|
225
225
|
pct = changed / max(matched, 1) * 100
|
|
@@ -243,7 +243,7 @@ def run_checks(
|
|
|
243
243
|
continue
|
|
244
244
|
tb, ta = con.execute(
|
|
245
245
|
f"SELECT (SELECT sum(CAST({q(c)} AS DOUBLE)) FROM {q(before)}), "
|
|
246
|
-
f"(SELECT sum(CAST({q(c)} AS DOUBLE)) FROM {q(after)})").
|
|
246
|
+
f"(SELECT sum(CAST({q(c)} AS DOUBLE)) FROM {q(after)})").fetchall()[0]
|
|
247
247
|
if tb is None and ta is None:
|
|
248
248
|
continue
|
|
249
249
|
tb, ta = float(tb or 0), float(ta or 0)
|
|
@@ -263,7 +263,7 @@ def run_checks(
|
|
|
263
263
|
for c in common:
|
|
264
264
|
if c in key or not (_is_text(sb[c]) and _is_text(sa[c])):
|
|
265
265
|
continue
|
|
266
|
-
d = con.execute(f"SELECT count(DISTINCT {q(c)}) FROM {q(before)}").
|
|
266
|
+
d = con.execute(f"SELECT count(DISTINCT {q(c)}) FROM {q(before)}").fetchall()[0][0]
|
|
267
267
|
if d == 0 or d > t.category_max_distinct:
|
|
268
268
|
continue
|
|
269
269
|
new_vals = [r[0] for r in con.execute(
|
|
@@ -79,7 +79,7 @@ def verify(before: Any, after: Any, *, name: str = "table", key: list[str] | str
|
|
|
79
79
|
"No key given, so row-level changes were not compared. Add a key for stronger checks."))
|
|
80
80
|
else:
|
|
81
81
|
findings = []
|
|
82
|
-
stats = {"rows_after": con.execute('SELECT count(*) FROM "after"').
|
|
82
|
+
stats = {"rows_after": con.execute('SELECT count(*) FROM "after"').fetchall()[0][0]}
|
|
83
83
|
if source is not None:
|
|
84
84
|
spec = SourceSpec.from_dict(source)
|
|
85
85
|
load(con, parse_source(spec.data, base_dir), "source")
|
|
@@ -70,8 +70,8 @@ def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, s
|
|
|
70
70
|
kcols = ", ".join(q("_k" + str(i)) for i in range(len(key)))
|
|
71
71
|
|
|
72
72
|
findings: list[Finding] = []
|
|
73
|
-
n_out = con.execute("SELECT count(*) FROM _pv_out").
|
|
74
|
-
n_src = con.execute(f"SELECT count(DISTINCT ({kcols})) FROM _pv_src").
|
|
73
|
+
n_out = con.execute("SELECT count(*) FROM _pv_out").fetchall()[0][0]
|
|
74
|
+
n_src = con.execute(f"SELECT count(DISTINCT ({kcols})) FROM _pv_src").fetchall()[0][0]
|
|
75
75
|
stats = {"source_keys": n_src, "output_rows": n_out}
|
|
76
76
|
|
|
77
77
|
for oc in spec.columns:
|
|
@@ -80,7 +80,7 @@ def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, s
|
|
|
80
80
|
n = con.execute(
|
|
81
81
|
f"SELECT count(*) FROM (SELECT {kcols} FROM _pv_src GROUP BY ALL "
|
|
82
82
|
f"HAVING count(DISTINCT CAST({v} AS VARCHAR)) > 1) s "
|
|
83
|
-
f"WHERE EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").
|
|
83
|
+
f"WHERE EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchall()[0][0]
|
|
84
84
|
if n:
|
|
85
85
|
ex = con.execute(
|
|
86
86
|
f"SELECT {kcols}, list(DISTINCT CAST({v} AS VARCHAR) ORDER BY CAST({v} AS VARCHAR)) FROM _pv_src "
|
|
@@ -99,7 +99,7 @@ def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, s
|
|
|
99
99
|
n = con.execute(
|
|
100
100
|
f"SELECT count(*) FROM _pv_out o WHERE EXISTS (SELECT 1 FROM _pv_src s WHERE {on}) "
|
|
101
101
|
f"AND NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on} AND (({num}) OR {txt} "
|
|
102
|
-
f"OR (o.{q(oc)} IS NULL AND s.{v} IS NULL)))").
|
|
102
|
+
f"OR (o.{q(oc)} IS NULL AND s.{v} IS NULL)))").fetchall()[0][0]
|
|
103
103
|
if n:
|
|
104
104
|
pct = n / max(n_out, 1) * 100
|
|
105
105
|
sev = _grade(pct, t.source_mismatch_review_pct, t.source_mismatch_block_pct)
|
|
@@ -108,14 +108,14 @@ def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, s
|
|
|
108
108
|
oc, {"count": n, "pct": round(pct, 4)}))
|
|
109
109
|
if "missing" in wanted:
|
|
110
110
|
n = con.execute(f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM _pv_src) s "
|
|
111
|
-
f"WHERE NOT EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").
|
|
111
|
+
f"WHERE NOT EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchall()[0][0]
|
|
112
112
|
if n:
|
|
113
113
|
pct = n / max(n_src, 1) * 100
|
|
114
114
|
findings.append(Finding("source_missing", _grade(pct, t.source_missing_review_pct, t.source_missing_block_pct),
|
|
115
115
|
f"{n:,} source key(s) never reached the output ({pct:.2f}%)", None,
|
|
116
116
|
{"count": n, "pct": round(pct, 4)}))
|
|
117
117
|
if "orphan" in wanted:
|
|
118
|
-
n = con.execute(f"SELECT count(*) FROM _pv_out o WHERE NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on})").
|
|
118
|
+
n = con.execute(f"SELECT count(*) FROM _pv_out o WHERE NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on})").fetchall()[0][0]
|
|
119
119
|
if n:
|
|
120
120
|
pct = n / max(n_out, 1) * 100
|
|
121
121
|
findings.append(Finding("source_orphan", REVIEW if pct > 0 else INFO,
|
|
@@ -96,6 +96,17 @@ def _resolve(path: str, base_dir: str) -> str:
|
|
|
96
96
|
return path if os.path.isabs(path) else os.path.normpath(os.path.join(base_dir, path))
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
COMPRESSION_SUFFIXES = (".gz", ".zst")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _split_extension(path: str) -> tuple[str, str]:
|
|
103
|
+
"""Return (file extension, compression suffix), e.g. 'orders.csv.gz' -> ('.csv', '.gz')."""
|
|
104
|
+
base, ext = os.path.splitext(path.lower())
|
|
105
|
+
if ext in COMPRESSION_SUFFIXES:
|
|
106
|
+
return os.path.splitext(base)[1], ext
|
|
107
|
+
return ext, ""
|
|
108
|
+
|
|
109
|
+
|
|
99
110
|
def load(con: duckdb.DuckDBPyConnection, source: Source, name: str) -> None:
|
|
100
111
|
"""Materialize a source as table `name` in the DuckDB connection."""
|
|
101
112
|
if source.kind == "dataframe":
|
|
@@ -107,10 +118,16 @@ def load(con: duckdb.DuckDBPyConnection, source: Source, name: str) -> None:
|
|
|
107
118
|
path = source.path or ""
|
|
108
119
|
if not os.path.exists(path):
|
|
109
120
|
raise SourceError(f"File not found: {path}")
|
|
110
|
-
ext =
|
|
121
|
+
ext, compression = _split_extension(path)
|
|
111
122
|
reader = FILE_READERS.get(ext)
|
|
112
123
|
if reader is None:
|
|
113
|
-
raise SourceError(
|
|
124
|
+
raise SourceError(
|
|
125
|
+
f"Unsupported file type '{ext}{compression}'. "
|
|
126
|
+
"Use csv, parquet or json, optionally compressed with .gz or .zst.")
|
|
127
|
+
if compression and reader == "read_parquet":
|
|
128
|
+
raise SourceError(
|
|
129
|
+
f"Compressed Parquet ('{ext}{compression}') is not supported. Parquet is already "
|
|
130
|
+
f"compressed internally, so use the plain {ext} file.")
|
|
114
131
|
con.execute(f'CREATE OR REPLACE TABLE "{name}" AS SELECT * FROM {reader}(?)', [path])
|
|
115
132
|
return
|
|
116
133
|
if source.kind == "duckdb":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|