dbt-testpilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbt_testpilot/__init__.py +3 -0
- dbt_testpilot/__main__.py +4 -0
- dbt_testpilot/apply.py +164 -0
- dbt_testpilot/artifacts.py +81 -0
- dbt_testpilot/cli.py +130 -0
- dbt_testpilot/llm.py +135 -0
- dbt_testpilot/macrogen.py +106 -0
- dbt_testpilot/profiler.py +124 -0
- dbt_testpilot/proposals.py +193 -0
- dbt_testpilot/report.py +66 -0
- dbt_testpilot/yaml_writer.py +148 -0
- dbt_testpilot-0.1.0.dist-info/METADATA +385 -0
- dbt_testpilot-0.1.0.dist-info/RECORD +16 -0
- dbt_testpilot-0.1.0.dist-info/WHEEL +4 -0
- dbt_testpilot-0.1.0.dist-info/entry_points.txt +2 -0
- dbt_testpilot-0.1.0.dist-info/licenses/LICENSE +21 -0
dbt_testpilot/apply.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Approve/reject flow + write approved tests to schema.yml, then optionally run dbt test.
|
|
2
|
+
|
|
3
|
+
The reliability ethic: nothing is written without a human OK. Approval is
|
|
4
|
+
interactive by default; `--yes` approves all, and `--dry-run` shows what would
|
|
5
|
+
happen without touching any file.
|
|
6
|
+
|
|
7
|
+
Custom (non-built-in) tests are skipped by default because dbt has no macro for
|
|
8
|
+
them. With `--include-custom`, we auto-generate vetted macros for supported
|
|
9
|
+
patterns (see macrogen.py) and write those; unsupported ones are still skipped.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .macrogen import classify, generate_macros
|
|
19
|
+
from .yaml_writer import add_tests
|
|
20
|
+
|
|
21
|
+
_CONF = {"low": 0, "medium": 1, "high": 2}
|
|
22
|
+
|
|
23
|
+
# dbt's built-in generic tests — always safe to write and run as-is.
|
|
24
|
+
WRITABLE = {"not_null", "unique", "accepted_values", "relationships"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_proposals(path: str) -> list[dict]:
|
|
28
|
+
data = json.loads(Path(path).read_text())
|
|
29
|
+
out: list[dict] = []
|
|
30
|
+
for group in data:
|
|
31
|
+
for p in group.get("proposals", []):
|
|
32
|
+
p = dict(p)
|
|
33
|
+
p.setdefault("model", group.get("model"))
|
|
34
|
+
out.append(p)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fmt(p: dict) -> str:
|
|
39
|
+
col = p.get("column") or "(model-level)"
|
|
40
|
+
args = p.get("arguments") or {}
|
|
41
|
+
extra = ""
|
|
42
|
+
if p["test"] == "accepted_values" and args.get("values"):
|
|
43
|
+
extra = f" values={args['values']}"
|
|
44
|
+
elif p["test"] == "relationships" and args.get("to"):
|
|
45
|
+
extra = f" -> {args['to']}.{args.get('field', '')}"
|
|
46
|
+
return f"{p['model']} :: {col} :: {p['test']}{extra} [{p.get('source','?')[:1]}/{p.get('confidence','')}]"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def review(proposals: list[dict], assume_yes: bool, min_conf: str) -> list[dict]:
|
|
50
|
+
thr = _CONF.get(min_conf, 0)
|
|
51
|
+
proposals = [p for p in proposals if _CONF.get(p.get("confidence", "medium"), 1) >= thr]
|
|
52
|
+
if not proposals:
|
|
53
|
+
return []
|
|
54
|
+
if assume_yes:
|
|
55
|
+
return proposals
|
|
56
|
+
if not sys.stdin.isatty():
|
|
57
|
+
print("[fail] not attached to a terminal and --yes not set — refusing to auto-approve.")
|
|
58
|
+
print(" Re-run in a terminal to review interactively, or pass --yes to accept all.")
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
approved: list[dict] = []
|
|
62
|
+
take_rest = False
|
|
63
|
+
for i, p in enumerate(proposals, 1):
|
|
64
|
+
if take_rest:
|
|
65
|
+
approved.append(p)
|
|
66
|
+
continue
|
|
67
|
+
print(f"\n[{i}/{len(proposals)}] {_fmt(p)}")
|
|
68
|
+
if p.get("rationale"):
|
|
69
|
+
print(f" {p['rationale']}")
|
|
70
|
+
ans = input(" approve? [y]es / [n]o / [a]ll remaining / [q]uit: ").strip().lower()
|
|
71
|
+
if ans == "y":
|
|
72
|
+
approved.append(p)
|
|
73
|
+
elif ans == "a":
|
|
74
|
+
take_rest = True
|
|
75
|
+
approved.append(p)
|
|
76
|
+
elif ans == "q":
|
|
77
|
+
break
|
|
78
|
+
# anything else = skip
|
|
79
|
+
return approved
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _split(approved: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
83
|
+
builtin = [p for p in approved if p["test"] in WRITABLE]
|
|
84
|
+
custom = [p for p in approved if p["test"] not in WRITABLE]
|
|
85
|
+
return builtin, custom
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_list(header: str, items: list[dict]) -> None:
|
|
89
|
+
print(header)
|
|
90
|
+
for p in items:
|
|
91
|
+
print(f" - {p['model']} :: {p.get('column') or '(model-level)'} :: {p['test']}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apply_proposals(project_dir: str, proposals_path: str, assume_yes: bool = False,
|
|
95
|
+
min_conf: str = "low", dry_run: bool = False, run: bool = False,
|
|
96
|
+
include_custom: bool = False) -> int:
|
|
97
|
+
project = Path(project_dir).expanduser().resolve()
|
|
98
|
+
manifest_path = project / "target" / "manifest.json"
|
|
99
|
+
if not manifest_path.exists():
|
|
100
|
+
print(f"[fail] {manifest_path} not found — run `dbt build` in the project first.")
|
|
101
|
+
return 1
|
|
102
|
+
if not Path(proposals_path).exists():
|
|
103
|
+
print(f"[fail] {proposals_path} not found — run `propose --out {proposals_path}` first.")
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
manifest = json.loads(manifest_path.read_text())
|
|
107
|
+
proposals = load_proposals(proposals_path)
|
|
108
|
+
approved = review(proposals, assume_yes=assume_yes, min_conf=min_conf)
|
|
109
|
+
if not approved:
|
|
110
|
+
print("\n[ok] nothing approved — no files changed.")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
builtin, custom = _split(approved)
|
|
114
|
+
|
|
115
|
+
# --- dry-run: preview only, write nothing (and generate no macros) ---
|
|
116
|
+
if dry_run:
|
|
117
|
+
print(f"\n[dry-run] would write {len(builtin)} built-in test(s):")
|
|
118
|
+
for p in builtin:
|
|
119
|
+
print(" +", _fmt(p))
|
|
120
|
+
if custom:
|
|
121
|
+
print("\n[dry-run] custom tests:")
|
|
122
|
+
for p in custom:
|
|
123
|
+
if include_custom:
|
|
124
|
+
tag = "generate macro + write" if classify(p["test"]) else "skip (no template)"
|
|
125
|
+
else:
|
|
126
|
+
tag = "skip (needs --include-custom)"
|
|
127
|
+
print(f" ~ {_fmt(p)} [{tag}]")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
# --- resolve custom tests ---
|
|
131
|
+
to_write = list(builtin)
|
|
132
|
+
if custom and include_custom:
|
|
133
|
+
writable_custom, generated, skipped = generate_macros(project, custom)
|
|
134
|
+
to_write += writable_custom
|
|
135
|
+
if generated:
|
|
136
|
+
print(f"\n[ok] generated {len(generated)} test macro(s) under macros/dbt_testpilot/:")
|
|
137
|
+
for name in generated:
|
|
138
|
+
print(f" test_{name}.sql")
|
|
139
|
+
if skipped:
|
|
140
|
+
_print_list(f"\n[note] {len(skipped)} custom test(s) have no macro template — left for you to implement:", skipped)
|
|
141
|
+
elif custom:
|
|
142
|
+
_print_list(
|
|
143
|
+
f"\n[note] skipping {len(custom)} custom test(s) that need a dbt macro "
|
|
144
|
+
f"(use --include-custom to auto-generate supported ones):",
|
|
145
|
+
custom,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if not to_write:
|
|
149
|
+
print("\n[ok] nothing runnable to write.")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
changed = add_tests(project, manifest, to_write)
|
|
153
|
+
total = sum(changed.values())
|
|
154
|
+
print(f"\n[ok] wrote {total} test(s) across {len(changed)} file(s):")
|
|
155
|
+
for f, n in changed.items():
|
|
156
|
+
print(f" {n:>2} {f}")
|
|
157
|
+
|
|
158
|
+
if run:
|
|
159
|
+
print("\n[info] running `dbt test` ...")
|
|
160
|
+
proc = subprocess.run(["dbt", "test", "--profiles-dir", str(project)], cwd=str(project))
|
|
161
|
+
return proc.returncode
|
|
162
|
+
|
|
163
|
+
print("\n[next] review the diff, then run: dbt test (in the project dir)")
|
|
164
|
+
return 0
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Read a dbt project's machine-readable artifacts (manifest.json + catalog.json).
|
|
2
|
+
|
|
3
|
+
We take the *model list* and *existing tests* from the manifest, and the
|
|
4
|
+
declared column types from the catalog. Actual columns to profile are read
|
|
5
|
+
from the warehouse itself later (see profiler.py) — the artifacts tell us
|
|
6
|
+
which relations are models and what's already tested.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Model:
|
|
17
|
+
unique_id: str
|
|
18
|
+
name: str # the built relation name (alias or model name)
|
|
19
|
+
database: str | None
|
|
20
|
+
schema: str | None
|
|
21
|
+
catalog_types: dict[str, str] = field(default_factory=dict) # column -> type (from catalog)
|
|
22
|
+
existing_tests: dict[str, list[str]] = field(default_factory=dict) # column -> [test types]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def identifier(self) -> str:
|
|
26
|
+
return f"{self.schema}.{self.name}" if self.schema else self.name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load(path: Path) -> dict:
|
|
30
|
+
with open(path) as f:
|
|
31
|
+
return json.load(f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _test_type(node: dict) -> str:
|
|
35
|
+
meta = node.get("test_metadata") or {}
|
|
36
|
+
return meta.get("name") or node.get("name") or "test"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_models(target_dir: str | Path) -> list[Model]:
|
|
40
|
+
"""Parse target/manifest.json (+ catalog.json if present) into Model objects."""
|
|
41
|
+
target = Path(target_dir)
|
|
42
|
+
manifest = _load(target / "manifest.json")
|
|
43
|
+
catalog_path = target / "catalog.json"
|
|
44
|
+
catalog = _load(catalog_path) if catalog_path.exists() else {"nodes": {}}
|
|
45
|
+
|
|
46
|
+
nodes = manifest.get("nodes", {})
|
|
47
|
+
cat_nodes = catalog.get("nodes", {})
|
|
48
|
+
|
|
49
|
+
# Map existing generic tests: model unique_id -> {column -> [test types]}
|
|
50
|
+
existing: dict[str, dict[str, list[str]]] = {}
|
|
51
|
+
for node in nodes.values():
|
|
52
|
+
if node.get("resource_type") != "test":
|
|
53
|
+
continue
|
|
54
|
+
attached = node.get("attached_node")
|
|
55
|
+
if not attached:
|
|
56
|
+
deps = (node.get("depends_on") or {}).get("nodes") or []
|
|
57
|
+
attached = next((d for d in deps if d.startswith("model.")), None)
|
|
58
|
+
column = node.get("column_name") or "*" # table-level tests keyed under "*"
|
|
59
|
+
if attached:
|
|
60
|
+
existing.setdefault(attached, {}).setdefault(column, []).append(_test_type(node))
|
|
61
|
+
|
|
62
|
+
models: list[Model] = []
|
|
63
|
+
for uid, node in nodes.items():
|
|
64
|
+
if node.get("resource_type") != "model":
|
|
65
|
+
continue
|
|
66
|
+
catalog_types: dict[str, str] = {}
|
|
67
|
+
cat = cat_nodes.get(uid)
|
|
68
|
+
if cat:
|
|
69
|
+
for cname, cinfo in (cat.get("columns") or {}).items():
|
|
70
|
+
catalog_types[cname] = cinfo.get("type")
|
|
71
|
+
models.append(
|
|
72
|
+
Model(
|
|
73
|
+
unique_id=uid,
|
|
74
|
+
name=node.get("alias") or node.get("name"),
|
|
75
|
+
database=node.get("database"),
|
|
76
|
+
schema=node.get("schema"),
|
|
77
|
+
catalog_types=catalog_types,
|
|
78
|
+
existing_tests=existing.get(uid, {}),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return models
|
dbt_testpilot/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
python -m dbt_testpilot profile --project-dir <dir> --out profiles.json
|
|
4
|
+
python -m dbt_testpilot propose --profiles profiles.json --out proposals.json [--llm]
|
|
5
|
+
python -m dbt_testpilot apply --proposals proposals.json --project-dir <dir> [--yes] [--run]
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .apply import apply_proposals
|
|
15
|
+
from .artifacts import load_models
|
|
16
|
+
from .llm import llm_proposals, load_env
|
|
17
|
+
from .profiler import profile_models
|
|
18
|
+
from .proposals import group_by_model, heuristic_proposals, merge
|
|
19
|
+
from .report import model_to_dict, render_proposals, render_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_duckdb(project_dir: Path) -> Path | None:
|
|
23
|
+
cands = sorted(project_dir.glob("*.duckdb"))
|
|
24
|
+
return cands[0] if cands else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cmd_profile(args) -> int:
|
|
28
|
+
project = Path(args.project_dir).expanduser().resolve()
|
|
29
|
+
target = project / "target"
|
|
30
|
+
if not (target / "manifest.json").exists():
|
|
31
|
+
print(f"[fail] no manifest.json under {target} — run `dbt build` / `dbt docs generate` first.")
|
|
32
|
+
return 1
|
|
33
|
+
db = Path(args.db).expanduser().resolve() if args.db else _find_duckdb(project)
|
|
34
|
+
if not db or not db.exists():
|
|
35
|
+
print(f"[fail] DuckDB file not found (looked in {project}). Pass --db /path/to/file.duckdb.")
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
models = load_models(target)
|
|
39
|
+
profiles = profile_models(str(db), models, max_values=args.max_values)
|
|
40
|
+
for mp in profiles:
|
|
41
|
+
print(render_text(mp))
|
|
42
|
+
print()
|
|
43
|
+
if args.out:
|
|
44
|
+
Path(args.out).write_text(json.dumps([model_to_dict(mp) for mp in profiles], indent=2, default=str))
|
|
45
|
+
print(f"[ok] wrote {len(profiles)} model profiles -> {args.out}")
|
|
46
|
+
else:
|
|
47
|
+
print(f"[ok] profiled {len(profiles)} models (pass --out profiles.json to save).")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cmd_propose(args) -> int:
|
|
52
|
+
profiles_path = Path(args.profiles).expanduser().resolve()
|
|
53
|
+
if not profiles_path.exists():
|
|
54
|
+
print(f"[fail] {profiles_path} not found — run `profile --out {args.profiles}` first.")
|
|
55
|
+
return 1
|
|
56
|
+
profiles = json.loads(profiles_path.read_text())
|
|
57
|
+
|
|
58
|
+
heur = heuristic_proposals(profiles)
|
|
59
|
+
llm_list = []
|
|
60
|
+
if args.llm:
|
|
61
|
+
env = load_env(args.env)
|
|
62
|
+
provider = (args.provider or env.get("LLM_PROVIDER") or "gemini").lower()
|
|
63
|
+
key_name = "GEMINI_API_KEY" if provider == "gemini" else "GROQ_API_KEY"
|
|
64
|
+
api_key = env.get(key_name) or os.environ.get(key_name, "")
|
|
65
|
+
if not api_key:
|
|
66
|
+
print(f"[fail] --llm needs {key_name} in {args.env} (or the environment).")
|
|
67
|
+
return 1
|
|
68
|
+
print(f"[info] querying {provider} for proposals ...")
|
|
69
|
+
llm_list = llm_proposals(profiles, provider, api_key)
|
|
70
|
+
|
|
71
|
+
proposals = merge(profiles, heur, llm_list)
|
|
72
|
+
grouped = group_by_model(proposals)
|
|
73
|
+
for g in grouped:
|
|
74
|
+
print(render_proposals(g["model"], g["proposals"]))
|
|
75
|
+
print()
|
|
76
|
+
|
|
77
|
+
n_h = sum(1 for p in proposals if p.source == "heuristic")
|
|
78
|
+
n_l = sum(1 for p in proposals if p.source == "llm")
|
|
79
|
+
if args.out:
|
|
80
|
+
Path(args.out).write_text(json.dumps(grouped, indent=2, default=str))
|
|
81
|
+
print(f"[ok] wrote {len(proposals)} proposals ({n_h} heuristic, {n_l} llm) -> {args.out}")
|
|
82
|
+
else:
|
|
83
|
+
print(f"[ok] {len(proposals)} proposals ({n_h} heuristic, {n_l} llm). Pass --out proposals.json to save.")
|
|
84
|
+
if not args.llm:
|
|
85
|
+
print("[hint] add --llm to augment with LLM proposals (needs an API key in .env).")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cmd_apply(args) -> int:
|
|
90
|
+
return apply_proposals(
|
|
91
|
+
args.project_dir, args.proposals,
|
|
92
|
+
assume_yes=args.yes, min_conf=args.min_confidence,
|
|
93
|
+
dry_run=args.dry_run, run=args.run, include_custom=args.include_custom,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str] | None = None) -> int:
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
prog="dbt-testpilot",
|
|
100
|
+
description="Profile a dbt project, propose the tests it's missing, and write the ones you approve.",
|
|
101
|
+
)
|
|
102
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
103
|
+
|
|
104
|
+
pp = sub.add_parser("profile", help="Profile every model in the project.")
|
|
105
|
+
pp.add_argument("--project-dir", default=".", help="dbt project dir (contains target/ and the .duckdb file).")
|
|
106
|
+
pp.add_argument("--db", default=None, help="Path to the DuckDB file (auto-detected if omitted).")
|
|
107
|
+
pp.add_argument("--out", default=None, help="Write per-model profiles as JSON to this path.")
|
|
108
|
+
pp.add_argument("--max-values", type=int, default=25, help="Max distinct values captured for low-cardinality columns.")
|
|
109
|
+
pp.set_defaults(func=_cmd_profile)
|
|
110
|
+
|
|
111
|
+
pr = sub.add_parser("propose", help="Propose missing tests from a profiles.json.")
|
|
112
|
+
pr.add_argument("--profiles", default="profiles.json", help="Path to the profiles JSON from `profile`.")
|
|
113
|
+
pr.add_argument("--out", default=None, help="Write proposals as JSON to this path.")
|
|
114
|
+
pr.add_argument("--llm", action="store_true", help="Augment heuristics with LLM proposals.")
|
|
115
|
+
pr.add_argument("--provider", default=None, help="gemini | groq (default: LLM_PROVIDER from .env).")
|
|
116
|
+
pr.add_argument("--env", default=".env", help="Path to the .env holding API keys.")
|
|
117
|
+
pr.set_defaults(func=_cmd_propose)
|
|
118
|
+
|
|
119
|
+
pa = sub.add_parser("apply", help="Review proposals and write approved tests into schema.yml.")
|
|
120
|
+
pa.add_argument("--proposals", default="proposals.json", help="Path to the proposals JSON from `propose`.")
|
|
121
|
+
pa.add_argument("--project-dir", default=".", help="dbt project dir whose schema.yml files get updated.")
|
|
122
|
+
pa.add_argument("--yes", action="store_true", help="Approve all proposals (non-interactive).")
|
|
123
|
+
pa.add_argument("--min-confidence", choices=["low", "medium", "high"], default="low", help="Only consider proposals at/above this confidence.")
|
|
124
|
+
pa.add_argument("--dry-run", action="store_true", help="Show what would be written; change nothing.")
|
|
125
|
+
pa.add_argument("--include-custom", action="store_true", help="Also handle custom tests: auto-generate vetted macros for supported patterns, then write them.")
|
|
126
|
+
pa.add_argument("--run", action="store_true", help="Run `dbt test` after writing.")
|
|
127
|
+
pa.set_defaults(func=_cmd_apply)
|
|
128
|
+
|
|
129
|
+
args = parser.parse_args(argv)
|
|
130
|
+
return args.func(args)
|
dbt_testpilot/llm.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""LLM layer: ask a model to propose the *missing* dbt tests as strict JSON.
|
|
2
|
+
|
|
3
|
+
Runs per model (small prompts, friendly to free-tier token limits). Output is
|
|
4
|
+
parsed and validated against the Proposal schema; anything malformed or
|
|
5
|
+
hallucinated is dropped, so a bad response degrades to "no LLM proposals"
|
|
6
|
+
rather than poisoning the results. The deterministic heuristics still stand on
|
|
7
|
+
their own — this only augments them.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .proposals import Proposal, validate_llm_proposal
|
|
16
|
+
|
|
17
|
+
GEMINI_MODEL = "gemini-2.5-flash"
|
|
18
|
+
GROQ_MODEL = "llama-3.1-8b-instant"
|
|
19
|
+
|
|
20
|
+
_INSTRUCTIONS = """You are a dbt data-quality assistant. Propose the dbt tests this model is MISSING.
|
|
21
|
+
Rules:
|
|
22
|
+
- Only propose tests NOT already listed in each column's existing_tests.
|
|
23
|
+
- Valid generic tests: not_null, unique, accepted_values (arguments.values = [...]),
|
|
24
|
+
relationships (arguments.to = "ref('model_name')", arguments.field = "column").
|
|
25
|
+
- You may also propose a custom test: use a short snake_case name and explain it in the rationale.
|
|
26
|
+
- Be conservative: only propose accepted_values for stable categorical columns, not for amounts/free text.
|
|
27
|
+
- Every proposal needs a one-line rationale grounded in the profile stats.
|
|
28
|
+
Return ONLY JSON of the form:
|
|
29
|
+
{"proposals":[{"column":"<name or null>","test":"<name>","arguments":{...},"rationale":"...","confidence":"high|medium|low"}]}"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_env(path: str = ".env") -> dict[str, str]:
|
|
33
|
+
env: dict[str, str] = {}
|
|
34
|
+
p = Path(path)
|
|
35
|
+
if not p.exists():
|
|
36
|
+
return env
|
|
37
|
+
for raw in p.read_text().splitlines():
|
|
38
|
+
line = raw.strip()
|
|
39
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
40
|
+
continue
|
|
41
|
+
k, _, v = line.partition("=")
|
|
42
|
+
env[k.strip()] = v.strip().strip('"').strip("'")
|
|
43
|
+
return env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _model_prompt(model: dict, unique_index: dict[str, list[str]]) -> str:
|
|
47
|
+
cols = []
|
|
48
|
+
for c in model.get("columns", []):
|
|
49
|
+
cols.append({
|
|
50
|
+
"name": c["name"],
|
|
51
|
+
"type": c.get("data_type"),
|
|
52
|
+
"null_pct": c.get("null_pct"),
|
|
53
|
+
"distinct": c.get("distinct_count"),
|
|
54
|
+
"is_unique": c.get("is_unique"),
|
|
55
|
+
"sample_values": [v for v, _ in (c.get("top_values") or [])][:10],
|
|
56
|
+
"existing_tests": c.get("existing_tests", []),
|
|
57
|
+
})
|
|
58
|
+
fk_targets = {col: models for col, models in unique_index.items()}
|
|
59
|
+
payload = {
|
|
60
|
+
"model": model["identifier"],
|
|
61
|
+
"row_count": model.get("row_count"),
|
|
62
|
+
"columns": cols,
|
|
63
|
+
"unique_columns_across_project": fk_targets, # for relationships
|
|
64
|
+
}
|
|
65
|
+
return f"{_INSTRUCTIONS}\n\nPROFILE:\n{json.dumps(payload, default=str)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _strip_fences(text: str) -> str:
|
|
69
|
+
t = text.strip()
|
|
70
|
+
if t.startswith("```"):
|
|
71
|
+
t = t.split("\n", 1)[-1]
|
|
72
|
+
if t.rstrip().endswith("```"):
|
|
73
|
+
t = t.rstrip()[:-3]
|
|
74
|
+
return t.strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_proposals(text: str) -> list[dict]:
|
|
78
|
+
data = json.loads(_strip_fences(text))
|
|
79
|
+
if isinstance(data, dict):
|
|
80
|
+
data = data.get("proposals", [])
|
|
81
|
+
return data if isinstance(data, list) else []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _call_gemini(prompt: str, api_key: str) -> str:
|
|
85
|
+
from google import genai # pip install google-genai
|
|
86
|
+
|
|
87
|
+
client = genai.Client(api_key=api_key)
|
|
88
|
+
resp = client.models.generate_content(
|
|
89
|
+
model=GEMINI_MODEL,
|
|
90
|
+
contents=prompt,
|
|
91
|
+
config={"response_mime_type": "application/json"},
|
|
92
|
+
)
|
|
93
|
+
return resp.text or ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _call_groq(prompt: str, api_key: str) -> str:
|
|
97
|
+
from groq import Groq # pip install groq
|
|
98
|
+
|
|
99
|
+
client = Groq(api_key=api_key)
|
|
100
|
+
resp = client.chat.completions.create(
|
|
101
|
+
model=GROQ_MODEL,
|
|
102
|
+
messages=[{"role": "user", "content": prompt}],
|
|
103
|
+
response_format={"type": "json_object"},
|
|
104
|
+
)
|
|
105
|
+
return resp.choices[0].message.content or ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _unique_index(profiles: list[dict]) -> dict[str, list[str]]:
|
|
109
|
+
idx: dict[str, list[str]] = {}
|
|
110
|
+
for m in profiles:
|
|
111
|
+
mname = m["identifier"].split(".")[-1]
|
|
112
|
+
for c in m.get("columns", []):
|
|
113
|
+
if c.get("is_unique"):
|
|
114
|
+
idx.setdefault(c["name"], []).append(mname)
|
|
115
|
+
return idx
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def llm_proposals(profiles: list[dict], provider: str, api_key: str) -> list[Proposal]:
|
|
119
|
+
"""Call the LLM per model and return validated Proposal objects. Never raises."""
|
|
120
|
+
call = _call_gemini if provider == "gemini" else _call_groq
|
|
121
|
+
unique_index = _unique_index(profiles)
|
|
122
|
+
out: list[Proposal] = []
|
|
123
|
+
for model in profiles:
|
|
124
|
+
if model.get("note") or not model.get("columns"):
|
|
125
|
+
continue
|
|
126
|
+
valid_columns = {c["name"] for c in model["columns"]}
|
|
127
|
+
try:
|
|
128
|
+
text = call(_model_prompt(model, unique_index), api_key)
|
|
129
|
+
for raw in _extract_proposals(text):
|
|
130
|
+
p = validate_llm_proposal(raw, model["identifier"], valid_columns)
|
|
131
|
+
if p:
|
|
132
|
+
out.append(p)
|
|
133
|
+
except Exception as e: # noqa: BLE001 - degrade gracefully per model
|
|
134
|
+
print(f"[warn] LLM proposal failed for {model['identifier']}: {type(e).__name__}: {e}")
|
|
135
|
+
return out
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Generate dbt generic-test macros for approved custom tests.
|
|
2
|
+
|
|
3
|
+
The LLM proposes semantically useful checks (e.g. `is_not_negative`) by *name*,
|
|
4
|
+
but dbt runs a generic test named `foo` by looking up a macro `test_foo`. Only
|
|
5
|
+
four built-ins ship with dbt, so custom names have no macro and fail to compile.
|
|
6
|
+
|
|
7
|
+
For a small set of well-understood patterns we emit **vetted, hand-written**
|
|
8
|
+
macros (no guessed SQL) into `macros/dbt_testpilot/`, so the approved tests run.
|
|
9
|
+
Anything we don't recognise is left for the user to implement — we never write a
|
|
10
|
+
macro whose logic we can't guarantee (that would risk false confidence).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
MACRO_SUBDIR = "dbt_testpilot" # under <project>/macros/
|
|
18
|
+
|
|
19
|
+
# value >= 0 (nulls pass, matching dbt's convention of ignoring nulls)
|
|
20
|
+
_NON_NEGATIVE = """{% test __NAME__(model, column_name) %}
|
|
21
|
+
-- generated by dbt-testpilot: fails rows where the value is negative (nulls pass)
|
|
22
|
+
select *
|
|
23
|
+
from {{ model }}
|
|
24
|
+
where {{ column_name }} < 0
|
|
25
|
+
{% endtest %}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# date/timestamp not in the future
|
|
29
|
+
_NOT_FUTURE = """{% test __NAME__(model, column_name) %}
|
|
30
|
+
-- generated by dbt-testpilot: fails rows where the date is in the future (nulls pass)
|
|
31
|
+
select *
|
|
32
|
+
from {{ model }}
|
|
33
|
+
where {{ column_name }} > current_date
|
|
34
|
+
{% endtest %}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# (pattern key, name substrings that map to it, template)
|
|
38
|
+
_PATTERNS = [
|
|
39
|
+
("non_negative", ("non_negative", "nonnegative", "not_negative", "no_negative",
|
|
40
|
+
"positive", "gte_zero", "ge_zero"), _NON_NEGATIVE),
|
|
41
|
+
("not_future", ("not_future", "past_or_present", "in_past", "future", "past"), _NOT_FUTURE),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_TEST_DEF_RE = re.compile(r"{%-?\s*test\s+([A-Za-z_]\w*)\s*\(")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify(test_name: str) -> str | None:
|
|
48
|
+
"""Return the template key a custom test maps to, or None if unsupported."""
|
|
49
|
+
n = test_name.lower()
|
|
50
|
+
for key, needles, _ in _PATTERNS:
|
|
51
|
+
if any(x in n for x in needles):
|
|
52
|
+
return key
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _template(key: str) -> str:
|
|
57
|
+
return next(tmpl for k, _, tmpl in _PATTERNS if k == key)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def existing_macro_names(project_dir: Path) -> set[str]:
|
|
61
|
+
"""Names of generic tests already defined in the project's macros/."""
|
|
62
|
+
names: set[str] = set()
|
|
63
|
+
macros_dir = Path(project_dir) / "macros"
|
|
64
|
+
if not macros_dir.exists():
|
|
65
|
+
return names
|
|
66
|
+
for sql in macros_dir.rglob("*.sql"):
|
|
67
|
+
try:
|
|
68
|
+
names |= set(_TEST_DEF_RE.findall(sql.read_text()))
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_macros(project_dir, custom: list[dict]):
|
|
75
|
+
"""For each custom proposal: reuse an existing macro, generate one for a known
|
|
76
|
+
pattern, or skip it.
|
|
77
|
+
|
|
78
|
+
Returns (writable, generated, skipped):
|
|
79
|
+
writable — proposals that now have a macro (existing or just generated)
|
|
80
|
+
generated — {test_name: file_path} written this run
|
|
81
|
+
skipped — proposals with no template (left for manual implementation)
|
|
82
|
+
"""
|
|
83
|
+
project_dir = Path(project_dir)
|
|
84
|
+
existing = existing_macro_names(project_dir)
|
|
85
|
+
out_dir = project_dir / "macros" / MACRO_SUBDIR
|
|
86
|
+
|
|
87
|
+
writable: list[dict] = []
|
|
88
|
+
generated: dict[str, str] = {}
|
|
89
|
+
skipped: list[dict] = []
|
|
90
|
+
|
|
91
|
+
for p in custom:
|
|
92
|
+
name = p["test"]
|
|
93
|
+
if name in existing or name in generated:
|
|
94
|
+
writable.append(p) # macro already available
|
|
95
|
+
continue
|
|
96
|
+
key = classify(name)
|
|
97
|
+
if key is None:
|
|
98
|
+
skipped.append(p)
|
|
99
|
+
continue
|
|
100
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
path = out_dir / f"test_{name}.sql"
|
|
102
|
+
path.write_text(_template(key).replace("__NAME__", name))
|
|
103
|
+
generated[name] = str(path)
|
|
104
|
+
writable.append(p)
|
|
105
|
+
|
|
106
|
+
return writable, generated, skipped
|