@1aboveio/skills 1.0.0 → 1.0.2
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.
- package/README.md +24 -7
- package/bin/select-core.mjs +182 -50
- package/extensions/hide-copies.ts +21 -0
- package/package.json +115 -3
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +1 -1
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +10 -6
- package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +5 -3
- package/skills/app-dev/data-catalog/references/field-definitions.md +15 -7
- package/skills/app-dev/data-catalog/references/format.md +21 -8
- package/skills/app-dev/data-catalog/scripts/data_catalog.py +87 -32
- package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +58 -11
- package/skills/app-dev/data-quality/LICENSE +3 -0
- package/skills/app-dev/data-quality/SKILL.md +96 -0
- package/skills/app-dev/data-quality/agents/openai.yaml +3 -0
- package/skills/app-dev/data-quality/references/checks.md +56 -0
- package/skills/app-dev/data-quality/release-check.mjs +93 -0
- package/skills/app-dev/data-quality/scripts/data-quality.test.mjs +12 -0
- package/skills/app-dev/data-quality/scripts/data_quality.py +405 -0
- package/skills/app-dev/data-quality/scripts/test_data_quality.py +277 -0
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/LICENSE +3 -0
- package/skills/core/plain-language/SKILL.md +57 -0
- package/skills/core/plain-language/release-check.mjs +93 -0
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/polish-cn/references/models.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +12 -12
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +4 -4
- package/skills/engineering/harness-runtime/references/model-catalog-seed.json +4 -4
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +1 -1
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +1 -1
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/report-writing/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: data-quality
|
|
3
|
+
description: "Check a published warehouse table's data against its data-catalog definitions with read-only SELECTs: columns never filled or stuck at one value, counts below zero, ratios outside 0–1, declared values that never appear and undeclared values that do. Then classify each finding as a wrong definition, wrong code, or wrong data. Use when asked whether a table's data is right, to profile or sanity-check a dwd/ads table, to find dead or constant columns, to check a column's values against its documented list, after a pipeline change ships, or when a data-catalog definition and the data might disagree. NOT for validating a Spark change before it ships (pyspark canary and reconciliation), writing definitions (data-catalog), or monitoring dashboards."
|
|
4
|
+
release-version: 0.39.0
|
|
5
|
+
---
|
|
6
|
+
**Updates:** Once/session run `node <skill-base-dir>/release-check.mjs`.
|
|
7
|
+
# Data quality
|
|
8
|
+
|
|
9
|
+
Ask the data whether it means what its definitions say. The definitions come from the
|
|
10
|
+
data-catalog skill; this skill turns them into read-only queries, runs them, and
|
|
11
|
+
reports where table, code, and documentation disagree.
|
|
12
|
+
|
|
13
|
+
## Find your task
|
|
14
|
+
|
|
15
|
+
| Task | Do |
|
|
16
|
+
|---|---|
|
|
17
|
+
| The repo has no `data-catalog.json` or the table has no definitions | Set the repo up and document the table with the data-catalog skill first |
|
|
18
|
+
| Check one or more tables | [Run the checks](#run-the-checks) |
|
|
19
|
+
| Decide what a finding means | [Classify each finding](#classify-each-finding) |
|
|
20
|
+
| What each check does, and how to make a column checkable | [references/checks.md](references/checks.md) |
|
|
21
|
+
| Connect to Presto, Trino, Spark SQL, or another engine | [references/checks.md#runners](references/checks.md#runners) |
|
|
22
|
+
|
|
23
|
+
`<engine>` is `<skill-base-dir>/scripts/data_quality.py`. It reads definitions through
|
|
24
|
+
the repo's own `scripts/data-catalog/data_catalog.py`.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
1. **Read only.** Every query is one SELECT built by the engine; the SQLite runner
|
|
29
|
+
opens the file read-only. Never add a write to a runner, and never run the checks
|
|
30
|
+
with credentials that can write when read-only ones exist.
|
|
31
|
+
2. **Bound the scan.** Pass `--where` on the partition column for a recent range, and
|
|
32
|
+
widen only when a finding needs it.
|
|
33
|
+
Example: `--where "dt >= DATE '2026-10-01'"` scanned 333K rows in 18 seconds.
|
|
34
|
+
3. **Aggregates only.** The checks return counts, distinct counts, minimums, maximums,
|
|
35
|
+
and counts per declared value. Never add a query that returns rows, and never print
|
|
36
|
+
a column's actual value when it could identify a customer.
|
|
37
|
+
4. **A finding is a lead until you classify it.** Read the code that writes the column
|
|
38
|
+
before saying the data, the code, or the definition is wrong.
|
|
39
|
+
Example: `exchange_share` reached 25 while its unit says ratio: read the formula
|
|
40
|
+
before deciding whether the unit or the code is wrong.
|
|
41
|
+
5. **Report; never fix in the same step.** List each finding with its classification
|
|
42
|
+
and evidence. Fixing a definition is a data-catalog change; fixing code or data is
|
|
43
|
+
the owner's call. File an issue only when the user asks.
|
|
44
|
+
6. **Make columns checkable at the source.** A finding that needs a fact the catalog
|
|
45
|
+
lacks (allowed values, a unit) is fixed by adding it to the `Field`, not by
|
|
46
|
+
special-casing the check.
|
|
47
|
+
Example: add `values=("是", "否", "无法判定")` to `agent_risk_status`, then rerun.
|
|
48
|
+
|
|
49
|
+
## Run the checks
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python3 <engine> run --table usr.dwd_orders --where "dt >= DATE '2026-10-01'" \
|
|
53
|
+
--key dt --key order_id --runner "python3 tools/presto_query.py" # or --sqlite warehouse.db
|
|
54
|
+
python3 <engine> plan --table usr.dwd_orders --where "..." # just the SQL, to run elsewhere
|
|
55
|
+
python3 <engine> judge --results results.json # judge rows you ran yourself
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Run from the repo root, or pass `--root`.
|
|
59
|
+
- Pass the table's grain as `--key` (one per column): a join that repeats rows breaks
|
|
60
|
+
it, and nothing else catches that.
|
|
61
|
+
- One table per run when tables use different partition columns.
|
|
62
|
+
- Engine errors such as "exceeds the allowed maximum stages": lower `--batch`.
|
|
63
|
+
- A column type the engine cannot count distinct values of (maps, some arrays): `--skip`.
|
|
64
|
+
- Exit 0: no problems. Exit 1: problems found. Exit 2: setup or usage error.
|
|
65
|
+
|
|
66
|
+
## Classify each finding
|
|
67
|
+
|
|
68
|
+
For each problem, read the column's definition and the code that writes it, then give
|
|
69
|
+
one of:
|
|
70
|
+
|
|
71
|
+
| Class | Means | Example | Next step |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| definition | the code and data agree; the definition is wrong or incomplete | a retention ratio documented as 0–1 that is by design unbounded | fix the `Field` (data-catalog) |
|
|
74
|
+
| code | the code does not compute what the definition says | `transaction_result` hard-coded to `SUCCESS` | report to the owner with the code location |
|
|
75
|
+
| data | the code is right; the source data is wrong or missing | a source that never fills a certificate type | report to the source owner |
|
|
76
|
+
| expected | correct as is, and the definition should say so | a payout-only column that is 0 in a collections-only period | add the reason to the definition so the next run marks it documented |
|
|
77
|
+
|
|
78
|
+
A column documented as always empty whose `source` names a real upstream column is
|
|
79
|
+
still a question for the owner: is the placeholder meant to stay, or is the code
|
|
80
|
+
missing the read?
|
|
81
|
+
|
|
82
|
+
Findings marked `(documented)` already match what the definition says (for example
|
|
83
|
+
"always empty"), version and hash columns, and missing-flags that are 0 on every row:
|
|
84
|
+
mention them, do not chase them.
|
|
85
|
+
|
|
86
|
+
## Report
|
|
87
|
+
|
|
88
|
+
Give, per table: the range checked, the row count, then each problem as
|
|
89
|
+
`column — finding — class — evidence (file:line or query)`, most serious first.
|
|
90
|
+
End with what you could not classify and why. Never claim a table is clean when a
|
|
91
|
+
check was `not-run` or a column was skipped.
|
|
92
|
+
|
|
93
|
+
## Done when
|
|
94
|
+
|
|
95
|
+
- Every problem finding has a class and evidence, or is listed as unclassified.
|
|
96
|
+
- Nothing was written to the warehouse, and no row-level value was printed.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Checks, findings, and runners
|
|
2
|
+
|
|
3
|
+
## What each check reads
|
|
4
|
+
|
|
5
|
+
The engine builds up to three kinds of query per documented table:
|
|
6
|
+
|
|
7
|
+
- **Profile** (one per 40 columns, `--batch`): `COUNT(*)`, and per column `COUNT(col)`
|
|
8
|
+
and `COUNT(DISTINCT col)`; `MIN`/`MAX` for columns whose `unit` is numeric (count,
|
|
9
|
+
ratio, percent, a currency, seconds, days, score) or whose name looks like a flag
|
|
10
|
+
(`*_missing`, `*_flag`, `is_*`, `has_*`).
|
|
11
|
+
- **Key** (with `--key`): counts the grain values that repeat, with a GROUP BY …
|
|
12
|
+
HAVING COUNT(*) > 1 subquery.
|
|
13
|
+
- **Values** (one per column with `values=(...)`): `SELECT col AS value, COUNT(*) AS n …
|
|
14
|
+
GROUP BY col`. Only declare `values` on enumerated columns, never on identifiers.
|
|
15
|
+
|
|
16
|
+
## Findings
|
|
17
|
+
|
|
18
|
+
| Finding | Fires when | Marked documented when |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `empty-table` | no rows in the `--where` range; nothing else is judged | — |
|
|
21
|
+
| `never-filled` | the column is empty in every row | the definition says always empty / 始终为空 |
|
|
22
|
+
| `mostly-empty` | filled in under 1% of rows | — |
|
|
23
|
+
| `constant` | one distinct value in every filled row, with at least 30 rows (`--constant-min-rows`) and the column not named in `--where` | the definition says always/constant/恒为; the name is a version, hash, or source table; a flag column is constant at 0 or N |
|
|
24
|
+
| `negative-value` | minimum below 0 for unit count, seconds, or days | — |
|
|
25
|
+
| `out-of-range` | values outside 0–1 for unit `ratio`, or 0–100 for unit `percent` | — |
|
|
26
|
+
| `unseen-value` | a declared value never appears (and the column has rows) | — |
|
|
27
|
+
| `undeclared-value` | a value appears that `values` does not list (empty values are not counted) | — |
|
|
28
|
+
| `duplicate-key` | a `--key` combination appears in more than one row | — |
|
|
29
|
+
| `not-run` | a query has no result | — |
|
|
30
|
+
|
|
31
|
+
## Making a column checkable
|
|
32
|
+
|
|
33
|
+
Each check needs a fact in the column's data-catalog `Field`:
|
|
34
|
+
|
|
35
|
+
| To check | Write in the `Field` |
|
|
36
|
+
|---|---|
|
|
37
|
+
| allowed values | `values=("是", "否", "无法判定")`, or `-- values: 是; 否; 无法判定` in SQL |
|
|
38
|
+
| 0–1 range | `unit="ratio"`; use `"percent"` for 0–100; leave a ratio unbounded by design without that unit |
|
|
39
|
+
| never negative | `unit="count"` (or seconds, days) |
|
|
40
|
+
| expected empty or constant | say so in the text: "Always empty until the source fills it." |
|
|
41
|
+
|
|
42
|
+
## Runners
|
|
43
|
+
|
|
44
|
+
`run --runner CMD` sends one SELECT on stdin per query and reads the rows from stdout:
|
|
45
|
+
a JSON array of objects, JSON lines (one object per line), or CSV with a header row.
|
|
46
|
+
Exit non-zero with a message on stderr when a query fails.
|
|
47
|
+
|
|
48
|
+
| Engine | Runner command shape |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Trino / Presto CLI | `trino --server <url> --catalog hive --output-format JSON` (reads stdin) |
|
|
51
|
+
| Presto REST | a small script that POSTs to `/v1/statement`, follows `nextUri`, prints a JSON array |
|
|
52
|
+
| Spark SQL | a script that runs `spark.sql(stdin).toPandas().to_json(orient="records")` |
|
|
53
|
+
| SQLite (local, tests) | built in: `--sqlite <file>`, opened read-only |
|
|
54
|
+
|
|
55
|
+
Use `--dialect spark` with Spark SQL (backtick identifiers); the default quotes with
|
|
56
|
+
double quotes. Use read-only credentials where the engine offers them.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
function readJson(path, fallback = {}) {
|
|
13
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function frontmatterValue(text, key) {
|
|
17
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '';
|
|
18
|
+
const match = new RegExp('^' + key + ':\\s*([^\\n]+)$', 'm').exec(frontmatter);
|
|
19
|
+
return match?.[1].trim().replace(/^['"]|['"]$/g, '') ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function repositoryOf(source) {
|
|
23
|
+
const value = String(source ?? '').trim();
|
|
24
|
+
const match = /^(?:git@github\.com:|https:\/\/github\.com\/)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(value);
|
|
25
|
+
return match?.[1] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stableVersion(value) {
|
|
29
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(value ?? '').trim());
|
|
30
|
+
return match ? match.slice(1).map(Number) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function newer(left, right) {
|
|
34
|
+
for (let index = 0; index < 3; index += 1) {
|
|
35
|
+
if (left[index] !== right[index]) return left[index] > right[index];
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeCache(path, value) {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
+
writeFileSync(path, JSON.stringify(value));
|
|
44
|
+
} catch { /* A read-only cache must not block skill use. */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main(now = Date.now()) {
|
|
48
|
+
if (process.env.ONEABOVEIO_NO_UPDATE_CHECK) return;
|
|
49
|
+
const skill = readFileSync(join(here, 'SKILL.md'), 'utf8');
|
|
50
|
+
const name = frontmatterValue(skill, 'name');
|
|
51
|
+
const localVersion = stableVersion(frontmatterValue(skill, 'release-version'));
|
|
52
|
+
if (!name || !localVersion) return;
|
|
53
|
+
|
|
54
|
+
const lock = readJson(join(process.env.HOME || homedir(), '.agents', '.skill-lock.json'));
|
|
55
|
+
const entry = lock.skills?.[name];
|
|
56
|
+
const repository = repositoryOf(entry?.sourceUrl || entry?.source);
|
|
57
|
+
if (!repository) return;
|
|
58
|
+
|
|
59
|
+
const cacheRoot = process.env.XDG_CACHE_HOME || join(process.env.HOME || homedir(), '.cache');
|
|
60
|
+
const cachePath = join(cacheRoot, '1aboveio-skills', 'release-check.json');
|
|
61
|
+
const all = readJson(cachePath);
|
|
62
|
+
const cache = all[repository] && typeof all[repository] === 'object' ? all[repository] : {};
|
|
63
|
+
|
|
64
|
+
if (!cache.lastCheck || now - cache.lastCheck >= DAY_MS) {
|
|
65
|
+
const result = spawnSync('gh', ['api', 'repos/' + repository + '/releases/latest', '--jq', '.tag_name'], {
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
+
timeout: 1500,
|
|
69
|
+
});
|
|
70
|
+
cache.lastCheck = now;
|
|
71
|
+
if (result.status === 0 && stableVersion(result.stdout)) cache.latestTag = result.stdout.trim();
|
|
72
|
+
else delete cache.latestTag;
|
|
73
|
+
all[repository] = cache;
|
|
74
|
+
writeCache(cachePath, all);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const latestVersion = stableVersion(cache.latestTag);
|
|
78
|
+
if (!latestVersion || !newer(latestVersion, localVersion)) return;
|
|
79
|
+
if (cache.notifiedTag === cache.latestTag && cache.notifiedAt && now - cache.notifiedAt < WEEK_MS) return;
|
|
80
|
+
|
|
81
|
+
cache.notifiedTag = cache.latestTag;
|
|
82
|
+
cache.notifiedAt = now;
|
|
83
|
+
all[repository] = cache;
|
|
84
|
+
writeCache(cachePath, all);
|
|
85
|
+
const command = `npx skills@1.5.23 add '${repository}#${cache.latestTag}' --global --agent universal claude-code --skill ${name} --yes`;
|
|
86
|
+
process.stdout.write(
|
|
87
|
+
`UPDATE_AVAILABLE: A newer 1AboveIO skills release (${cache.latestTag}) is available; this skill is `
|
|
88
|
+
+ `v${localVersion.join('.')}. Ask once whether to refresh ${name}. On approval run \`${command}\`. `
|
|
89
|
+
+ 'The refreshed instructions apply next session. Either way, continue the current task and do not ask again this session.\n',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try { main(); } catch { /* Update checks are advisory and always fail silent. */ }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
test('data-quality test_data_quality.py', () => {
|
|
10
|
+
const result = spawnSync('python3', [join(scriptsDir, 'test_data_quality.py')], { encoding: 'utf8' });
|
|
11
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
12
|
+
});
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Data quality engine: check a published table's data against its data-catalog definitions.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
plan write the read-only SELECT checks for documented tables (JSON)
|
|
6
|
+
judge turn query results into findings
|
|
7
|
+
run plan, execute through a runner, and judge in one step
|
|
8
|
+
version print the engine identity
|
|
9
|
+
|
|
10
|
+
Definitions come from the repo's data-catalog engine (scripts/data-catalog/data_catalog.py),
|
|
11
|
+
so a table is checkable once its script carries CATALOG_TABLE / CATALOG_FIELDS or a
|
|
12
|
+
`-- @field` block. Every query is a single SELECT; nothing is ever written.
|
|
13
|
+
|
|
14
|
+
Runner contract (run --runner CMD): CMD reads one SQL statement on stdin and prints the
|
|
15
|
+
result rows on stdout as a JSON array of objects, or as CSV with a header row.
|
|
16
|
+
|
|
17
|
+
Standard library only.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import csv
|
|
23
|
+
import hashlib
|
|
24
|
+
import importlib.util
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import sqlite3
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
ENGINE_VERSION = "1.0.0"
|
|
36
|
+
CATALOG_ENGINE = "scripts/data-catalog/data_catalog.py"
|
|
37
|
+
NUMERIC_UNITS = {"count", "usd", "cny", "eur", "gbp", "hkd", "amount", "ratio", "percent", "seconds", "days", "score"}
|
|
38
|
+
NON_NEGATIVE_UNITS = {"count", "seconds", "days"}
|
|
39
|
+
RANGES = {"ratio": (0.0, 1.0), "percent": (0.0, 100.0)}
|
|
40
|
+
DOCUMENTED_CONSTANT_RE = re.compile(r"始终|恒为|目前.{0,8}(为空|不填)|always|constant|never (filled|set)|not filled", re.I)
|
|
41
|
+
PROVENANCE_RE = re.compile(r"(^|_)(version|sha\d*|hash|checksum|source_table|compute_time|etl_time)$", re.I)
|
|
42
|
+
FLAG_RE = re.compile(r"(_missing|_flag)$|^(is|has)_", re.I) # healthy when constant at 0 / N
|
|
43
|
+
CLEAR_FLAG = {"0", "0.0", "false", "n", "no"}
|
|
44
|
+
PROFILE_BATCH = 40 # columns per profile query: each COUNT(DISTINCT) is a stage, and engines cap stages
|
|
45
|
+
MOSTLY_EMPTY = 0.01
|
|
46
|
+
CONSTANT_MIN_ROWS = 30 # below this, one distinct value says little
|
|
47
|
+
NULL_KEY = "<null>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QualityError(Exception):
|
|
51
|
+
"""A usage or setup problem; exit code 2."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Definitions, through the repo's data-catalog engine
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def load_catalog_engine(root: Path, path: str | None):
|
|
59
|
+
engine = Path(path) if path else root / CATALOG_ENGINE
|
|
60
|
+
if not engine.is_file():
|
|
61
|
+
raise QualityError(f"no data-catalog engine at {engine}; set the repo up with the data-catalog skill first "
|
|
62
|
+
"(its init copies the engine), or pass --catalog-engine")
|
|
63
|
+
spec = importlib.util.spec_from_file_location("data_catalog_for_quality", engine)
|
|
64
|
+
module = importlib.util.module_from_spec(spec)
|
|
65
|
+
sys.modules[spec.name] = module # dataclasses resolve string annotations through sys.modules
|
|
66
|
+
spec.loader.exec_module(module)
|
|
67
|
+
return module
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def documented_tables(root: Path, engine_path: str | None, wanted: list) -> list:
|
|
71
|
+
dc = load_catalog_engine(root, engine_path)
|
|
72
|
+
try:
|
|
73
|
+
config = dc.load_config(root)
|
|
74
|
+
tables, errors, _ = dc.collect(root, config)
|
|
75
|
+
except dc.CatalogError as exc:
|
|
76
|
+
raise QualityError(f"data-catalog: {exc}") from exc
|
|
77
|
+
if errors:
|
|
78
|
+
raise QualityError("data-catalog metadata has errors; run its generate first:\n " + "\n ".join(errors))
|
|
79
|
+
if wanted:
|
|
80
|
+
missing = [w for w in wanted if w not in {t.name for t in tables}]
|
|
81
|
+
if missing:
|
|
82
|
+
raise QualityError(f"not documented in the catalog: {', '.join(missing)}")
|
|
83
|
+
tables = [t for t in tables if t.name in wanted]
|
|
84
|
+
if not tables:
|
|
85
|
+
raise QualityError("no documented tables to check")
|
|
86
|
+
return tables
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Plan: one profile query per table, one value query per enumerated column
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def quote(name: str, dialect: str) -> str:
|
|
94
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
95
|
+
raise QualityError(f"column name {name!r} cannot be quoted safely")
|
|
96
|
+
return f"`{name}`" if dialect == "spark" else f'"{name}"'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def table_ref(name: str) -> str:
|
|
100
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){0,2}", name):
|
|
101
|
+
raise QualityError(f"table name {name!r} is not a plain identifier")
|
|
102
|
+
return name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def allowed(f) -> tuple:
|
|
106
|
+
"""Declared values; empty for engine copies older than `Field.values`."""
|
|
107
|
+
return tuple(getattr(f, "values", ()) or ())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_numeric(f) -> bool:
|
|
111
|
+
return (f.unit or "").strip().lower() in NUMERIC_UNITS
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def plan_queries(tables: list, where: str | None, dialect: str, skip: set, batch: int = PROFILE_BATCH,
|
|
115
|
+
key: list | None = None) -> list:
|
|
116
|
+
cond = f" WHERE {where}" if where else ""
|
|
117
|
+
queries = []
|
|
118
|
+
for t in tables:
|
|
119
|
+
if key:
|
|
120
|
+
unknown = [k for k in key if k not in {f.name for f in t.fields}]
|
|
121
|
+
if unknown:
|
|
122
|
+
raise QualityError(f"--key column(s) not documented in {t.name}: {', '.join(unknown)}")
|
|
123
|
+
cols = ", ".join(quote(k, dialect) for k in key)
|
|
124
|
+
sql = (f"SELECT COUNT(*) AS dup_keys, COALESCE(SUM(c), 0) AS dup_rows FROM "
|
|
125
|
+
f"(SELECT COUNT(*) AS c FROM {table_ref(t.name)}{cond} GROUP BY {cols} HAVING COUNT(*) > 1) dup")
|
|
126
|
+
queries.append({"id": f"key:{t.name}", "table": t.name, "kind": "key", "columns": list(key), "sql": sql})
|
|
127
|
+
cols = [f for f in t.fields if f.name not in skip]
|
|
128
|
+
chunks = [cols[i:i + batch] for i in range(0, len(cols), batch)] or [[]]
|
|
129
|
+
for k, chunk in enumerate(chunks):
|
|
130
|
+
parts = ["COUNT(*) AS n"]
|
|
131
|
+
for i, f in enumerate(chunk):
|
|
132
|
+
q = quote(f.name, dialect)
|
|
133
|
+
parts += [f"COUNT({q}) AS nn_{i}", f"COUNT(DISTINCT {q}) AS d_{i}"]
|
|
134
|
+
if is_numeric(f) or FLAG_RE.search(f.name):
|
|
135
|
+
parts += [f"MIN({q}) AS mn_{i}", f"MAX({q}) AS mx_{i}"]
|
|
136
|
+
sql = f"SELECT {', '.join(parts)} FROM {table_ref(t.name)}{cond}"
|
|
137
|
+
suffix = f"#{k + 1}" if len(chunks) > 1 else ""
|
|
138
|
+
queries.append({"id": f"profile:{t.name}{suffix}", "table": t.name, "kind": "profile", "part": k,
|
|
139
|
+
"columns": [f.name for f in chunk], "sql": sql})
|
|
140
|
+
for f in cols:
|
|
141
|
+
if allowed(f):
|
|
142
|
+
q = quote(f.name, dialect)
|
|
143
|
+
sql = f"SELECT {q} AS value, COUNT(*) AS n FROM {table_ref(t.name)}{cond} GROUP BY {q}"
|
|
144
|
+
queries.append({"id": f"values:{t.name}.{f.name}", "table": t.name, "kind": "values",
|
|
145
|
+
"column": f.name, "sql": sql})
|
|
146
|
+
for q in queries:
|
|
147
|
+
assert_read_only(q["sql"])
|
|
148
|
+
return queries
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def assert_read_only(sql: str) -> None:
|
|
152
|
+
if not re.match(r"\s*SELECT\b", sql, re.I) or ";" in sql:
|
|
153
|
+
raise QualityError(f"refusing to run a statement that is not a single SELECT: {sql[:80]}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Judge: results -> findings
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class Finding:
|
|
162
|
+
table: str
|
|
163
|
+
column: str | None
|
|
164
|
+
kind: str
|
|
165
|
+
message: str
|
|
166
|
+
documented: bool = False # the definition already says so: report as info, not a problem
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def number(value):
|
|
170
|
+
if value is None or value == "":
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return float(value)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def where_columns(where: str | None) -> set:
|
|
179
|
+
"""Identifiers a --where condition names: those columns are constant by construction."""
|
|
180
|
+
if not where:
|
|
181
|
+
return set()
|
|
182
|
+
bare = re.sub(r"'(?:[^']|'')*'", " ", where)
|
|
183
|
+
return {w.strip('`"').lower() for w in re.findall(r'[`"]?[A-Za-z_][A-Za-z0-9_]*[`"]?', bare)}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def judge(tables: list, queries: list, results: dict, where: str | None = None,
|
|
187
|
+
constant_min_rows: int = CONSTANT_MIN_ROWS) -> list:
|
|
188
|
+
by_table = {t.name: t for t in tables}
|
|
189
|
+
pinned = where_columns(where)
|
|
190
|
+
findings = []
|
|
191
|
+
for q in queries:
|
|
192
|
+
rows = results.get(q["id"])
|
|
193
|
+
if rows is None:
|
|
194
|
+
findings.append(Finding(q["table"], q.get("column"), "not-run", f"no result for {q['id']}"))
|
|
195
|
+
continue
|
|
196
|
+
t = by_table[q["table"]]
|
|
197
|
+
if q["kind"] == "key":
|
|
198
|
+
row = {k.lower(): v for k, v in (rows[0] if rows else {}).items()}
|
|
199
|
+
keys = number(row.get("dup_keys")) or 0
|
|
200
|
+
if keys:
|
|
201
|
+
findings.append(Finding(t.name, None, "duplicate-key",
|
|
202
|
+
f"{int(keys)} ({', '.join(q['columns'])}) value(s) repeat across "
|
|
203
|
+
f"{int(number(row.get('dup_rows')) or 0)} rows; the grain allows one each"))
|
|
204
|
+
continue
|
|
205
|
+
if q["kind"] == "profile":
|
|
206
|
+
part = judge_profile(t, q["columns"], rows, pinned, constant_min_rows)
|
|
207
|
+
if q.get("part", 0) > 0: # every batch sees the same row count; report an empty range once
|
|
208
|
+
part = [f for f in part if f.kind != "empty-table"]
|
|
209
|
+
findings += part
|
|
210
|
+
else:
|
|
211
|
+
findings += judge_values(t, q["column"], rows)
|
|
212
|
+
return findings
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def judge_profile(t, columns: list, rows: list, pinned: set, constant_min_rows: int) -> list:
|
|
216
|
+
if len(rows) != 1:
|
|
217
|
+
return [Finding(t.name, None, "not-run", f"profile returned {len(rows)} rows, expected 1")]
|
|
218
|
+
row = {k.lower(): v for k, v in rows[0].items()}
|
|
219
|
+
n = number(row.get("n")) or 0
|
|
220
|
+
if n == 0:
|
|
221
|
+
return [Finding(t.name, None, "empty-table", "no rows in the checked range; nothing else can be judged")]
|
|
222
|
+
fields = {f.name: f for f in t.fields}
|
|
223
|
+
out = []
|
|
224
|
+
for i, name in enumerate(columns):
|
|
225
|
+
f = fields[name]
|
|
226
|
+
text = " ".join([*f.text.values(), f.source])
|
|
227
|
+
documented = bool(DOCUMENTED_CONSTANT_RE.search(text))
|
|
228
|
+
filled = number(row.get(f"nn_{i}")) or 0
|
|
229
|
+
distinct = number(row.get(f"d_{i}")) or 0
|
|
230
|
+
lo, hi = number(row.get(f"mn_{i}")), number(row.get(f"mx_{i}"))
|
|
231
|
+
if filled == 0:
|
|
232
|
+
out.append(Finding(t.name, name, "never-filled", f"empty in all {int(n)} rows", documented))
|
|
233
|
+
continue
|
|
234
|
+
if filled / n < MOSTLY_EMPTY:
|
|
235
|
+
out.append(Finding(t.name, name, "mostly-empty", f"filled in {int(filled)} of {int(n)} rows"))
|
|
236
|
+
if distinct == 1 and n >= constant_min_rows and name.lower() not in pinned:
|
|
237
|
+
value = row.get(f"mn_{i}")
|
|
238
|
+
shown = f" ({value})" if value is not None else ""
|
|
239
|
+
clear_flag = FLAG_RE.search(name) and str(row.get(f"mn_{i}")).strip().lower() in CLEAR_FLAG
|
|
240
|
+
# a build's version is the same on every row; a missing-flag at 0 means nothing was missing
|
|
241
|
+
expected = documented or bool(PROVENANCE_RE.search(name)) or bool(clear_flag)
|
|
242
|
+
out.append(Finding(t.name, name, "constant", f"one value{shown} in all {int(filled)} filled rows", expected))
|
|
243
|
+
unit = (f.unit or "").strip().lower()
|
|
244
|
+
if unit in NON_NEGATIVE_UNITS and lo is not None and lo < 0:
|
|
245
|
+
out.append(Finding(t.name, name, "negative-value", f"minimum {lo:g}, but the unit is {f.unit}"))
|
|
246
|
+
if unit in RANGES and lo is not None and hi is not None:
|
|
247
|
+
low, high = RANGES[unit]
|
|
248
|
+
if lo < low or hi > high:
|
|
249
|
+
out.append(Finding(t.name, name, "out-of-range",
|
|
250
|
+
f"values from {lo:g} to {hi:g}, outside {low:g}–{high:g} for unit {f.unit}"))
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def judge_values(t, column: str, rows: list) -> list:
|
|
255
|
+
f = next(f for f in t.fields if f.name == column)
|
|
256
|
+
seen = {}
|
|
257
|
+
for r in rows:
|
|
258
|
+
r = {k.lower(): v for k, v in r.items()}
|
|
259
|
+
value = r.get("value")
|
|
260
|
+
empty = value is None or (value == "" and "" not in allowed(f))
|
|
261
|
+
key = NULL_KEY if empty else str(value)
|
|
262
|
+
seen[key] = seen.get(key, 0) + (number(r.get("n")) or 0)
|
|
263
|
+
declared = set(allowed(f))
|
|
264
|
+
out = []
|
|
265
|
+
unseen = [v for v in allowed(f) if v not in seen]
|
|
266
|
+
if unseen and seen:
|
|
267
|
+
out.append(Finding(t.name, column, "unseen-value", f"declared but never present: {', '.join(unseen)}"))
|
|
268
|
+
extra = sorted(k for k in seen if k not in declared and k != NULL_KEY)
|
|
269
|
+
if extra:
|
|
270
|
+
counts = ", ".join(f"{k} ({int(seen[k])})" for k in extra[:10])
|
|
271
|
+
out.append(Finding(t.name, column, "undeclared-value", f"present but not declared: {counts}"))
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Runners
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def parse_rows(text: str) -> list:
|
|
280
|
+
text = text.strip()
|
|
281
|
+
if not text:
|
|
282
|
+
return []
|
|
283
|
+
if text[0] == "[":
|
|
284
|
+
return json.loads(text)
|
|
285
|
+
if text[0] == "{": # one object, or JSON lines (trino/presto --output-format JSON)
|
|
286
|
+
return [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
287
|
+
return [dict(r) for r in csv.DictReader(io.StringIO(text))]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def run_command(cmd: str, sql: str) -> list:
|
|
291
|
+
proc = subprocess.run(shlex.split(cmd), input=sql, capture_output=True, text=True)
|
|
292
|
+
if proc.returncode != 0:
|
|
293
|
+
raise QualityError(f"runner failed ({proc.returncode}) on: {sql[:80]}\n{proc.stderr.strip()[-400:]}")
|
|
294
|
+
return parse_rows(proc.stdout)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def run_sqlite(db: str, sql: str) -> list:
|
|
298
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) # read-only by construction
|
|
299
|
+
try:
|
|
300
|
+
cur = con.execute(sql)
|
|
301
|
+
names = [d[0] for d in cur.description]
|
|
302
|
+
return [dict(zip(names, row)) for row in cur.fetchall()]
|
|
303
|
+
finally:
|
|
304
|
+
con.close()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def execute(queries: list, runner: str | None, sqlite_db: str | None) -> dict:
|
|
308
|
+
results = {}
|
|
309
|
+
for q in queries:
|
|
310
|
+
assert_read_only(q["sql"])
|
|
311
|
+
results[q["id"]] = run_sqlite(sqlite_db, q["sql"]) if sqlite_db else run_command(runner, q["sql"])
|
|
312
|
+
return results
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Report and CLI
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def report(findings: list, tables: list, where: str | None, as_json: bool) -> int:
|
|
320
|
+
problems = [f for f in findings if not f.documented]
|
|
321
|
+
if as_json:
|
|
322
|
+
print(json.dumps({"engine": ENGINE_VERSION, "where": where, "tables": [t.name for t in tables],
|
|
323
|
+
"problems": len(problems),
|
|
324
|
+
"findings": [vars(f) for f in findings]}, ensure_ascii=False, indent=2))
|
|
325
|
+
return 1 if problems else 0
|
|
326
|
+
scope = f" where {where}" if where else " (all rows)"
|
|
327
|
+
print(f"data-quality: {len(tables)} table(s){scope}; {len(problems)} problem(s), "
|
|
328
|
+
f"{len(findings) - len(problems)} already documented")
|
|
329
|
+
for t in tables:
|
|
330
|
+
mine = [f for f in findings if f.table == t.name]
|
|
331
|
+
if not mine:
|
|
332
|
+
continue
|
|
333
|
+
print(f"\n{t.name}")
|
|
334
|
+
for f in mine:
|
|
335
|
+
tag = " (documented)" if f.documented else ""
|
|
336
|
+
print(f" - {f.kind}{tag}: {f.column + ': ' if f.column else ''}{f.message}")
|
|
337
|
+
return 1 if problems else 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def engine_identity() -> str:
|
|
341
|
+
return f"{ENGINE_VERSION}+{hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]}"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def find_root(start: Path) -> Path:
|
|
345
|
+
for candidate in [start, *start.parents]:
|
|
346
|
+
if (candidate / "data-catalog.json").exists() or (candidate / ".git").exists():
|
|
347
|
+
return candidate
|
|
348
|
+
return start
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv: list | None = None) -> int:
|
|
352
|
+
parser = argparse.ArgumentParser(prog="data_quality.py", description=__doc__.split("\n\n")[0])
|
|
353
|
+
parser.add_argument("--root", type=Path, help="repo root (default: nearest dir with data-catalog.json or .git)")
|
|
354
|
+
parser.add_argument("--catalog-engine", help=f"path to data_catalog.py (default <root>/{CATALOG_ENGINE})")
|
|
355
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
356
|
+
|
|
357
|
+
def scoped(p):
|
|
358
|
+
p.add_argument("--table", action="append", default=[], help="documented table to check; repeatable (default all)")
|
|
359
|
+
p.add_argument("--where", help="SQL condition that bounds the scan, e.g. \"dt >= '2026-10-01'\"")
|
|
360
|
+
p.add_argument("--dialect", choices=["ansi", "spark"], default="ansi", help="identifier quoting: ansi \"x\", spark `x`")
|
|
361
|
+
p.add_argument("--skip", action="append", default=[], help="column to leave out (e.g. arrays); repeatable")
|
|
362
|
+
p.add_argument("--key", action="append", default=[],
|
|
363
|
+
help="grain column; repeat for a composite key: --key dt --key cust_id")
|
|
364
|
+
p.add_argument("--batch", type=int, default=PROFILE_BATCH,
|
|
365
|
+
help=f"columns per profile query (default {PROFILE_BATCH}); lower it if the engine caps stages")
|
|
366
|
+
p.add_argument("--constant-min-rows", type=int, default=CONSTANT_MIN_ROWS,
|
|
367
|
+
help=f"rows needed before a single value counts as constant (default {CONSTANT_MIN_ROWS})")
|
|
368
|
+
|
|
369
|
+
p_plan = sub.add_parser("plan", help="print the SELECT checks as JSON")
|
|
370
|
+
scoped(p_plan)
|
|
371
|
+
p_judge = sub.add_parser("judge", help="judge results; --results maps query id to rows")
|
|
372
|
+
scoped(p_judge)
|
|
373
|
+
p_judge.add_argument("--results", required=True, help="JSON file: {query id: [row objects]}")
|
|
374
|
+
p_judge.add_argument("--json", action="store_true")
|
|
375
|
+
p_run = sub.add_parser("run", help="plan, execute, judge")
|
|
376
|
+
scoped(p_run)
|
|
377
|
+
group = p_run.add_mutually_exclusive_group(required=True)
|
|
378
|
+
group.add_argument("--runner", help="command that reads SQL on stdin and prints JSON or CSV rows")
|
|
379
|
+
group.add_argument("--sqlite", help="SQLite database file (opened read-only)")
|
|
380
|
+
p_run.add_argument("--json", action="store_true")
|
|
381
|
+
sub.add_parser("version", help="print the engine identity")
|
|
382
|
+
args = parser.parse_args(argv)
|
|
383
|
+
if args.command == "version":
|
|
384
|
+
print(engine_identity())
|
|
385
|
+
return 0
|
|
386
|
+
root = (args.root or find_root(Path.cwd())).resolve()
|
|
387
|
+
try:
|
|
388
|
+
tables = documented_tables(root, args.catalog_engine, args.table)
|
|
389
|
+
queries = plan_queries(tables, args.where, args.dialect, set(args.skip), args.batch, args.key)
|
|
390
|
+
if args.command == "plan":
|
|
391
|
+
print(json.dumps(queries, ensure_ascii=False, indent=2))
|
|
392
|
+
return 0
|
|
393
|
+
if args.command == "judge":
|
|
394
|
+
results = json.loads(Path(args.results).read_text(encoding="utf-8"))
|
|
395
|
+
else:
|
|
396
|
+
results = execute(queries, args.runner, args.sqlite)
|
|
397
|
+
found = judge(tables, queries, results, args.where, args.constant_min_rows)
|
|
398
|
+
return report(found, tables, args.where, args.json)
|
|
399
|
+
except QualityError as exc:
|
|
400
|
+
print(f"data-quality: {exc}", file=sys.stderr)
|
|
401
|
+
return 2
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
sys.exit(main())
|