bricksentry 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.
- bricksentry/__init__.py +26 -0
- bricksentry/agent_tools.py +139 -0
- bricksentry/assess.py +165 -0
- bricksentry/backends/__init__.py +3 -0
- bricksentry/backends/base.py +96 -0
- bricksentry/backends/databricks_backend.py +161 -0
- bricksentry/backends/duckdb_backend.py +52 -0
- bricksentry/changes.py +34 -0
- bricksentry/cli.py +260 -0
- bricksentry/config.py +123 -0
- bricksentry/diff.py +194 -0
- bricksentry/explain.py +91 -0
- bricksentry/github.py +52 -0
- bricksentry/lineage.py +224 -0
- bricksentry/mcp_server.py +47 -0
- bricksentry/models.py +212 -0
- bricksentry/policy.py +221 -0
- bricksentry/preview.py +79 -0
- bricksentry/py.typed +0 -0
- bricksentry/report.py +88 -0
- bricksentry-0.1.0.dist-info/METADATA +186 -0
- bricksentry-0.1.0.dist-info/RECORD +25 -0
- bricksentry-0.1.0.dist-info/WHEEL +4 -0
- bricksentry-0.1.0.dist-info/entry_points.txt +2 -0
- bricksentry-0.1.0.dist-info/licenses/LICENSE +202 -0
bricksentry/policy.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Policy-as-code. Deterministic: the verdict always comes from these rules,
|
|
2
|
+
never from an LLM, so agents and CI get the same answer for the same change.
|
|
3
|
+
|
|
4
|
+
``policy.yml``::
|
|
5
|
+
|
|
6
|
+
default: pass
|
|
7
|
+
notify_owners_on: needs_human
|
|
8
|
+
rules:
|
|
9
|
+
- name: duplicate-keys
|
|
10
|
+
when: {duplicate_keys: true}
|
|
11
|
+
then: block
|
|
12
|
+
- name: dropped-column
|
|
13
|
+
when: {column_removed: true}
|
|
14
|
+
then: needs_human
|
|
15
|
+
- name: certified-downstream
|
|
16
|
+
when: {downstream_tag: certified}
|
|
17
|
+
then: needs_human
|
|
18
|
+
- name: big-row-loss
|
|
19
|
+
when: {removed_pct_gt: 1}
|
|
20
|
+
then: needs_human
|
|
21
|
+
- name: revenue-moved
|
|
22
|
+
when: {column: amount, sum_change_pct_gt: 2}
|
|
23
|
+
then: needs_human
|
|
24
|
+
- name: wide-blast-radius
|
|
25
|
+
when: {downstream_count_gt: 25}
|
|
26
|
+
then: warn
|
|
27
|
+
|
|
28
|
+
All conditions inside one ``when`` must hold (AND). Use several rules for OR.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import fnmatch
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from bricksentry.models import ImpactNode, RuleHit, TableDiff, Verdict
|
|
37
|
+
|
|
38
|
+
DEFAULT_POLICY: dict[str, Any] = {
|
|
39
|
+
"default": "pass",
|
|
40
|
+
"notify_owners_on": "needs_human",
|
|
41
|
+
"rules": [
|
|
42
|
+
{"name": "diff-failed", "when": {"diff_error": True}, "then": "needs_human"},
|
|
43
|
+
{"name": "duplicate-keys", "when": {"duplicate_keys": True}, "then": "block"},
|
|
44
|
+
{"name": "dropped-column", "when": {"column_removed": True}, "then": "needs_human"},
|
|
45
|
+
{"name": "type-change", "when": {"column_type_changed": True}, "then": "warn"},
|
|
46
|
+
{"name": "certified-downstream", "when": {"downstream_tag": "certified"}, "then": "needs_human"},
|
|
47
|
+
{"name": "pii-table", "when": {"tag": "pii"}, "then": "needs_human"},
|
|
48
|
+
{"name": "row-loss", "when": {"removed_pct_gt": 1}, "then": "needs_human"},
|
|
49
|
+
{"name": "many-rows-changed", "when": {"changed_pct_gt": 10}, "then": "warn"},
|
|
50
|
+
{"name": "numeric-total-moved", "when": {"sum_change_pct_gt": 5}, "then": "warn"},
|
|
51
|
+
{"name": "dashboard-downstream", "when": {"downstream_kind": "dashboard", "any_change": True}, "then": "warn"},
|
|
52
|
+
{"name": "wide-blast-radius", "when": {"downstream_count_gt": 25}, "then": "warn"},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
KNOWN_CONDITIONS = {
|
|
57
|
+
"table", "tag", "downstream_tag", "downstream_kind", "downstream_count_gt",
|
|
58
|
+
"removed_pct_gt", "added_pct_gt", "changed_pct_gt", "row_count_change_pct_gt",
|
|
59
|
+
"row_count_drop_pct_gt", "column_removed", "column_type_changed", "column_added",
|
|
60
|
+
"duplicate_keys", "column", "sum_change_pct_gt", "null_increase_gt",
|
|
61
|
+
"any_change", "diff_error",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PolicyError(ValueError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Policy:
|
|
70
|
+
def __init__(self, spec: dict[str, Any] | None = None):
|
|
71
|
+
spec = spec if spec is not None else DEFAULT_POLICY
|
|
72
|
+
self.default = Verdict(spec.get("default", "pass"))
|
|
73
|
+
self.notify_on = Verdict(spec.get("notify_owners_on", "needs_human"))
|
|
74
|
+
self.rules = spec.get("rules") or []
|
|
75
|
+
for r in self.rules:
|
|
76
|
+
unknown = set((r.get("when") or {}).keys()) - KNOWN_CONDITIONS
|
|
77
|
+
if unknown:
|
|
78
|
+
raise PolicyError(f"Rule {r.get('name')!r} uses unknown condition(s): {sorted(unknown)}")
|
|
79
|
+
Verdict(r.get("then", "warn"))
|
|
80
|
+
|
|
81
|
+
def evaluate(
|
|
82
|
+
self,
|
|
83
|
+
table: str,
|
|
84
|
+
diff: TableDiff | None,
|
|
85
|
+
downstream: list[ImpactNode],
|
|
86
|
+
tags: dict[str, str],
|
|
87
|
+
diff_error: str | None = None,
|
|
88
|
+
) -> tuple[Verdict, list[RuleHit]]:
|
|
89
|
+
hits: list[RuleHit] = []
|
|
90
|
+
for r in self.rules:
|
|
91
|
+
ok, reason = _match(r.get("when") or {}, table, diff, downstream, tags, diff_error)
|
|
92
|
+
if ok:
|
|
93
|
+
hits.append(RuleHit(rule=r.get("name", "unnamed"), verdict=Verdict(r.get("then", "warn")), table=table, reason=reason))
|
|
94
|
+
verdict = Verdict.worst([self.default] + [h.verdict for h in hits])
|
|
95
|
+
return verdict, hits
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _has_tag(tags: dict[str, str], want: Any) -> bool:
|
|
99
|
+
if isinstance(want, dict):
|
|
100
|
+
return all(str(tags.get(k, "")).lower() == str(v).lower() for k, v in want.items())
|
|
101
|
+
want = str(want).lower()
|
|
102
|
+
for k, v in tags.items():
|
|
103
|
+
if k.lower() == want and str(v).lower() not in ("false", "no", "0"):
|
|
104
|
+
return True
|
|
105
|
+
if str(v).lower() == want:
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _match(
|
|
111
|
+
when: dict[str, Any],
|
|
112
|
+
table: str,
|
|
113
|
+
diff: TableDiff | None,
|
|
114
|
+
downstream: list[ImpactNode],
|
|
115
|
+
tags: dict[str, str],
|
|
116
|
+
diff_error: str | None,
|
|
117
|
+
) -> tuple[bool, str]:
|
|
118
|
+
reasons: list[str] = []
|
|
119
|
+
for cond, val in when.items():
|
|
120
|
+
if cond == "column":
|
|
121
|
+
continue # modifier for sum_change_pct_gt / null_increase_gt
|
|
122
|
+
if cond == "diff_error":
|
|
123
|
+
if bool(diff_error) != bool(val):
|
|
124
|
+
return False, ""
|
|
125
|
+
reasons.append(f"diff could not run: {diff_error}")
|
|
126
|
+
continue
|
|
127
|
+
if cond == "table":
|
|
128
|
+
if not fnmatch.fnmatch(table.lower(), str(val).lower()):
|
|
129
|
+
return False, ""
|
|
130
|
+
continue
|
|
131
|
+
if cond == "tag":
|
|
132
|
+
if not _has_tag(tags, val):
|
|
133
|
+
return False, ""
|
|
134
|
+
reasons.append(f"table is tagged {val}")
|
|
135
|
+
continue
|
|
136
|
+
if cond == "downstream_tag":
|
|
137
|
+
m = [n.name for n in downstream if _has_tag(n.tags, val)]
|
|
138
|
+
if not m:
|
|
139
|
+
return False, ""
|
|
140
|
+
reasons.append(f"{len(m)} downstream asset(s) tagged {val}: {', '.join(m[:3])}")
|
|
141
|
+
continue
|
|
142
|
+
if cond == "downstream_kind":
|
|
143
|
+
m = [n.name for n in downstream if n.kind == str(val).lower()]
|
|
144
|
+
if not m:
|
|
145
|
+
return False, ""
|
|
146
|
+
reasons.append(f"{len(m)} downstream {val}(s): {', '.join(m[:3])}")
|
|
147
|
+
continue
|
|
148
|
+
if cond == "downstream_count_gt":
|
|
149
|
+
if len(downstream) <= float(val):
|
|
150
|
+
return False, ""
|
|
151
|
+
reasons.append(f"{len(downstream)} downstream assets")
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Everything below needs a diff.
|
|
155
|
+
if diff is None:
|
|
156
|
+
return False, ""
|
|
157
|
+
if cond == "any_change":
|
|
158
|
+
if diff.is_identical == bool(val):
|
|
159
|
+
return False, ""
|
|
160
|
+
continue
|
|
161
|
+
if cond == "duplicate_keys":
|
|
162
|
+
dup = diff.duplicate_keys_candidate > diff.duplicate_keys_base
|
|
163
|
+
if dup != bool(val):
|
|
164
|
+
return False, ""
|
|
165
|
+
reasons.append(f"{diff.duplicate_keys_candidate} duplicate key(s) in candidate (base had {diff.duplicate_keys_base})")
|
|
166
|
+
continue
|
|
167
|
+
if cond == "column_removed":
|
|
168
|
+
if bool(diff.schema.removed) != bool(val):
|
|
169
|
+
return False, ""
|
|
170
|
+
reasons.append(f"columns removed: {', '.join(diff.schema.removed)}")
|
|
171
|
+
continue
|
|
172
|
+
if cond == "column_added":
|
|
173
|
+
if bool(diff.schema.added) != bool(val):
|
|
174
|
+
return False, ""
|
|
175
|
+
reasons.append(f"columns added: {', '.join(diff.schema.added)}")
|
|
176
|
+
continue
|
|
177
|
+
if cond == "column_type_changed":
|
|
178
|
+
if bool(diff.schema.type_changed) != bool(val):
|
|
179
|
+
return False, ""
|
|
180
|
+
reasons.append(
|
|
181
|
+
"type changes: " + ", ".join(f"{k} {a}->{b}" for k, (a, b) in diff.schema.type_changed.items())
|
|
182
|
+
)
|
|
183
|
+
continue
|
|
184
|
+
if cond in ("removed_pct_gt", "added_pct_gt", "changed_pct_gt"):
|
|
185
|
+
attr = cond.replace("_gt", "")
|
|
186
|
+
pct = getattr(diff, attr)
|
|
187
|
+
if pct <= float(val):
|
|
188
|
+
return False, ""
|
|
189
|
+
reasons.append(f"{attr.replace('_pct', '')} {pct:.2f}% of rows (> {val}%)")
|
|
190
|
+
continue
|
|
191
|
+
if cond == "row_count_change_pct_gt":
|
|
192
|
+
if abs(diff.row_count_change_pct) <= float(val):
|
|
193
|
+
return False, ""
|
|
194
|
+
reasons.append(f"row count changed {diff.row_count_change_pct:+.2f}%")
|
|
195
|
+
continue
|
|
196
|
+
if cond == "row_count_drop_pct_gt":
|
|
197
|
+
if -diff.row_count_change_pct <= float(val):
|
|
198
|
+
return False, ""
|
|
199
|
+
reasons.append(f"row count dropped {-diff.row_count_change_pct:.2f}%")
|
|
200
|
+
continue
|
|
201
|
+
if cond == "sum_change_pct_gt":
|
|
202
|
+
col = when.get("column")
|
|
203
|
+
cols = [c for c in diff.columns if (col is None or c.column == col) and c.sum_change_pct is not None]
|
|
204
|
+
m = [c for c in cols if abs(c.sum_change_pct) > float(val)]
|
|
205
|
+
if not m:
|
|
206
|
+
return False, ""
|
|
207
|
+
reasons.append(", ".join(f"sum({c.column}) {c.sum_change_pct:+.2f}%" for c in m[:3]))
|
|
208
|
+
continue
|
|
209
|
+
if cond == "null_increase_gt":
|
|
210
|
+
col = when.get("column")
|
|
211
|
+
m = [
|
|
212
|
+
c for c in diff.columns
|
|
213
|
+
if (col is None or c.column == col)
|
|
214
|
+
and c.base_nulls is not None and c.candidate_nulls is not None
|
|
215
|
+
and (c.candidate_nulls - c.base_nulls) > float(val)
|
|
216
|
+
]
|
|
217
|
+
if not m:
|
|
218
|
+
return False, ""
|
|
219
|
+
reasons.append(", ".join(f"{c.column} nulls +{c.candidate_nulls - c.base_nulls}" for c in m[:3]))
|
|
220
|
+
continue
|
|
221
|
+
return True, "; ".join(reasons) or "matched"
|
bricksentry/preview.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""``preview_write``: see what a write would do *before* it touches production.
|
|
2
|
+
|
|
3
|
+
1. Clone the target into the sandbox schema (Delta SHALLOW CLONE on Databricks:
|
|
4
|
+
metadata only, no data copied).
|
|
5
|
+
2. Point the write at the clone (``{{target}}`` placeholder, or the target name
|
|
6
|
+
is rewritten to the clone name).
|
|
7
|
+
3. Run the write against the clone and diff clone vs. the untouched target.
|
|
8
|
+
4. Drop the clone (unless ``keep=True``).
|
|
9
|
+
|
|
10
|
+
Guards: a single statement only, it must be INSERT / MERGE / UPDATE / DELETE /
|
|
11
|
+
CREATE OR REPLACE TABLE whose write target is the clone, and the production
|
|
12
|
+
table name must not survive the rewrite. So a preview cannot write to production.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
PLACEHOLDER = "{{target}}"
|
|
21
|
+
|
|
22
|
+
_WRITE_RE = re.compile(
|
|
23
|
+
r"^\s*(?:INSERT\s+(?:INTO|OVERWRITE)(?:\s+TABLE)?|MERGE\s+INTO|UPDATE|DELETE\s+FROM|"
|
|
24
|
+
r"CREATE\s+OR\s+REPLACE\s+TABLE|REPLACE\s+TABLE)\s+(?P<target>[`\"\w.\-]+)",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
_FORBIDDEN_RE = re.compile(r"\b(DROP|TRUNCATE|GRANT|REVOKE|ALTER|VACUUM|RESTORE)\b", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PreviewGuardError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sandbox_name(sandbox_schema: str, target: str) -> str:
|
|
35
|
+
short = target.split(".")[-1]
|
|
36
|
+
return f"{sandbox_schema}.{short}__preview_{uuid.uuid4().hex[:8]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _strip_quotes(name: str) -> str:
|
|
40
|
+
return name.replace("`", "").replace('"', "").lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _strip_sql_comments_and_strings(sql: str) -> str:
|
|
44
|
+
sql = re.sub(r"--[^\n]*", " ", sql)
|
|
45
|
+
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
|
|
46
|
+
return re.sub(r"'(?:[^'\\]|\\.|'')*'", "''", sql)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def rewrite_for_preview(sql: str, target: str, clone: str, quote) -> str:
|
|
50
|
+
"""Return SQL that writes to ``clone`` instead of ``target``. Raises PreviewGuardError if unsafe."""
|
|
51
|
+
body = sql.strip().rstrip(";").strip()
|
|
52
|
+
code = _strip_sql_comments_and_strings(body)
|
|
53
|
+
if ";" in code:
|
|
54
|
+
raise PreviewGuardError("preview_write accepts exactly one SQL statement")
|
|
55
|
+
if _FORBIDDEN_RE.search(code):
|
|
56
|
+
raise PreviewGuardError("preview_write only runs INSERT / MERGE / UPDATE / DELETE / CREATE OR REPLACE TABLE")
|
|
57
|
+
|
|
58
|
+
clone_q = ".".join(quote(p) for p in clone.split("."))
|
|
59
|
+
if PLACEHOLDER in body:
|
|
60
|
+
out = body.replace(PLACEHOLDER, clone_q)
|
|
61
|
+
else:
|
|
62
|
+
parts = [re.escape(p) for p in target.split(".")]
|
|
63
|
+
pattern = r"(?<![\w.])" + r"\.".join(rf"[`\"]?{p}[`\"]?" for p in parts) + r"(?![\w])"
|
|
64
|
+
out, n = re.subn(pattern, clone_q, body, flags=re.IGNORECASE)
|
|
65
|
+
if n == 0:
|
|
66
|
+
raise PreviewGuardError(
|
|
67
|
+
f"Could not find {target} in the SQL. Write it literally or use the {PLACEHOLDER} placeholder."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
m = _WRITE_RE.match(out)
|
|
71
|
+
if not m:
|
|
72
|
+
raise PreviewGuardError("Statement must start with INSERT, MERGE, UPDATE, DELETE or CREATE OR REPLACE TABLE")
|
|
73
|
+
if _strip_quotes(m.group("target")) != _strip_quotes(clone):
|
|
74
|
+
raise PreviewGuardError(f"The write target must be {target}; got {m.group('target')}")
|
|
75
|
+
|
|
76
|
+
leftover = re.compile(r"(?<![\w.])" + re.escape(_strip_quotes(target)) + r"(?![\w])")
|
|
77
|
+
if leftover.search(_strip_quotes(_strip_sql_comments_and_strings(out))):
|
|
78
|
+
raise PreviewGuardError("Production table name still present after rewrite; refusing to run")
|
|
79
|
+
return out
|
bricksentry/py.typed
ADDED
|
File without changes
|
bricksentry/report.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Markdown report for pull-request comments and job summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bricksentry.models import Assessment, TableAssessment, Verdict
|
|
6
|
+
|
|
7
|
+
MARKER = "<!-- bricksentry-report -->"
|
|
8
|
+
|
|
9
|
+
_ICON = {
|
|
10
|
+
Verdict.PASS: "🟢",
|
|
11
|
+
Verdict.WARN: "🟡",
|
|
12
|
+
Verdict.NEEDS_HUMAN: "🟠",
|
|
13
|
+
Verdict.BLOCK: "🔴",
|
|
14
|
+
}
|
|
15
|
+
_TITLE = {
|
|
16
|
+
Verdict.PASS: "Safe to ship",
|
|
17
|
+
Verdict.WARN: "Safe to ship, with warnings",
|
|
18
|
+
Verdict.NEEDS_HUMAN: "Needs human review",
|
|
19
|
+
Verdict.BLOCK: "Blocked",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_markdown(a: Assessment, max_downstream: int = 15) -> str:
|
|
24
|
+
v = a.verdict
|
|
25
|
+
lines = [
|
|
26
|
+
MARKER,
|
|
27
|
+
f"## {_ICON[v]} bricksentry: {_TITLE[v]}",
|
|
28
|
+
"",
|
|
29
|
+
f"**Risk tier:** `{v.risk_tier}` · **Tables checked:** {len(a.tables)} · **Assessment:** `{a.id}`",
|
|
30
|
+
]
|
|
31
|
+
if a.notify:
|
|
32
|
+
lines.append(f"**Review requested from:** {', '.join(a.notify)}")
|
|
33
|
+
lines.append("")
|
|
34
|
+
if a.hits:
|
|
35
|
+
lines += ["### Why", "", "| Verdict | Rule | Table | Reason |", "|---|---|---|---|"]
|
|
36
|
+
for h in sorted(a.hits, key=lambda h: -h.verdict.rank):
|
|
37
|
+
lines.append(f"| {_ICON[h.verdict]} `{h.verdict.value}` | {h.rule} | `{h.table}` | {_esc(h.reason)} |")
|
|
38
|
+
lines.append("")
|
|
39
|
+
for t in a.tables:
|
|
40
|
+
lines += _table_section(t, max_downstream)
|
|
41
|
+
lines += ["", "<sub>Generated by bricksentry. Verdicts come from policy rules, not from an LLM.</sub>"]
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _table_section(t: TableAssessment, max_downstream: int) -> list[str]:
|
|
46
|
+
out = [f"### {_ICON[t.verdict]} `{t.table}`", "", t.summary, ""]
|
|
47
|
+
d = t.diff
|
|
48
|
+
if d and not d.error:
|
|
49
|
+
out += [
|
|
50
|
+
"| Base rows | Candidate rows | Added | Removed | Changed | Unchanged |",
|
|
51
|
+
"|---:|---:|---:|---:|---:|---:|",
|
|
52
|
+
f"| {d.base_rows:,} | {d.candidate_rows:,} | {d.added_rows:,} | {d.removed_rows:,} | "
|
|
53
|
+
f"{d.changed_rows:,} | {d.unchanged_rows:,} |",
|
|
54
|
+
"",
|
|
55
|
+
]
|
|
56
|
+
changed_cols = [c for c in d.columns if c.changed_rows or (c.sum_change_pct and abs(c.sum_change_pct) > 0.01)]
|
|
57
|
+
if changed_cols:
|
|
58
|
+
out += ["<details><summary>Column changes</summary>", "",
|
|
59
|
+
"| Column | Rows changed | Nulls (base → cand.) | Sum change |", "|---|---:|---|---:|"]
|
|
60
|
+
for c in sorted(changed_cols, key=lambda c: -c.changed_rows):
|
|
61
|
+
sc = f"{c.sum_change_pct:+.2f}%" if c.sum_change_pct is not None else "–"
|
|
62
|
+
out.append(f"| `{c.column}` | {c.changed_rows:,} | {c.base_nulls} → {c.candidate_nulls} | {sc} |")
|
|
63
|
+
samples = [c for c in changed_cols if c.samples]
|
|
64
|
+
if samples:
|
|
65
|
+
out += ["", "**Sample changes**", ""]
|
|
66
|
+
for c in samples[:3]:
|
|
67
|
+
for s in c.samples[:3]:
|
|
68
|
+
key = ", ".join(f"{k}={s.get(k)}" for k in d.keys)
|
|
69
|
+
out.append(f"- `{c.column}` @ {key}: `{s.get('base_value')}` → `{s.get('candidate_value')}`")
|
|
70
|
+
out += ["", "</details>", ""]
|
|
71
|
+
if t.downstream:
|
|
72
|
+
out += [f"<details><summary>Blast radius: {len(t.downstream)} downstream asset(s)</summary>", "",
|
|
73
|
+
"| Depth | Kind | Name | Owner | Tags |", "|---:|---|---|---|---|"]
|
|
74
|
+
for n in sorted(t.downstream, key=lambda n: (n.depth, n.kind))[:max_downstream]:
|
|
75
|
+
tags = ", ".join(f"{k}={v}" if v else k for k, v in n.tags.items()) or "–"
|
|
76
|
+
out.append(f"| {n.depth} | {n.kind} | `{n.name}` | {n.owner or '–'} | {tags} |")
|
|
77
|
+
if len(t.downstream) > max_downstream:
|
|
78
|
+
out.append(f"| … | | {len(t.downstream) - max_downstream} more | | |")
|
|
79
|
+
out += ["", "</details>", ""]
|
|
80
|
+
if t.rollback and t.rollback.startswith("RESTORE"):
|
|
81
|
+
out += ["**Rollback:**", "```sql", t.rollback, "```", ""]
|
|
82
|
+
elif t.rollback:
|
|
83
|
+
out += [f"**Rollback:** {t.rollback}", ""]
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _esc(s: str) -> str:
|
|
88
|
+
return s.replace("|", "\\|").replace("\n", " ")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bricksentry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Change-safety for Databricks pipelines and the AI agents that edit them: data diffs, Unity Catalog blast radius, policy verdicts and rollback plans.
|
|
5
|
+
Author: Brahma
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai-agents,ci,data-diff,data-engineering,data-quality,databricks,delta-lake,impact-analysis,lineage,mcp,unity-catalog
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: databricks-sdk>=0.30; extra == 'all'
|
|
26
|
+
Requires-Dist: duckdb>=1.0; extra == 'all'
|
|
27
|
+
Requires-Dist: mcp>=1.10; extra == 'all'
|
|
28
|
+
Provides-Extra: databricks
|
|
29
|
+
Requires-Dist: databricks-sdk>=0.30; extra == 'databricks'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: databricks-sdk>=0.30; extra == 'dev'
|
|
32
|
+
Requires-Dist: duckdb>=1.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: mcp>=1.10; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
35
|
+
Provides-Extra: duckdb
|
|
36
|
+
Requires-Dist: duckdb>=1.0; extra == 'duckdb'
|
|
37
|
+
Provides-Extra: mcp
|
|
38
|
+
Requires-Dist: mcp>=1.10; extra == 'mcp'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# bricksentry
|
|
42
|
+
|
|
43
|
+
**Change-safety for Databricks pipelines and the AI agents that edit them.**
|
|
44
|
+
|
|
45
|
+
Before a change reaches production, bricksentry answers three questions, in a
|
|
46
|
+
pull-request comment for humans and in structured JSON for agents:
|
|
47
|
+
|
|
48
|
+
1. **What does it do to the data?** A key-based data diff: added, removed and changed rows, schema changes, per-column changes, numeric totals, duplicate keys, and *where* the change is concentrated ("100% of affected rows are region = APAC").
|
|
49
|
+
2. **Who gets hurt?** The downstream blast radius from Unity Catalog lineage: tables, jobs, notebooks, Lakeflow pipelines, dashboards and queries, with owners and tags.
|
|
50
|
+
3. **Is it safe, and how do I undo it?** A deterministic policy verdict (`pass` · `warn` · `needs_human` · `block`) and the exact `RESTORE TABLE … VERSION AS OF n` rollback.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install "bricksentry[databricks,mcp]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Why it exists
|
|
57
|
+
|
|
58
|
+
AI now writes much of our pipeline code, and checking what that code does to the data hasn't kept up. Code review can't see a silent 3% revenue shift or a join that fans out. The tools that do catch this mostly cover dbt, or need a paid platform. Most Databricks work is PySpark notebooks, Jobs and Lakeflow pipelines, and bricksentry is built for that work:
|
|
59
|
+
|
|
60
|
+
- **Unity Catalog runtime lineage**, not SQL parsing, so PySpark notebooks and jobs are included.
|
|
61
|
+
- **SQL pushdown**: all diffing runs on your SQL warehouse, so no Spark session is needed and calls stay cheap enough for an agent loop.
|
|
62
|
+
- **One engine, three faces**: CLI and GitHub Action · Python API · MCP server.
|
|
63
|
+
|
|
64
|
+
## For AI agents: the MCP server
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bricksentry mcp --config bricksentry.yml # stdio
|
|
68
|
+
claude mcp add bricksentry -- bricksentry mcp --config /abs/path/bricksentry.yml
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Tool | What the agent gets |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `whats_downstream(table, depth)` | Downstream assets with owners and tags. Call it *before* editing. |
|
|
74
|
+
| `rollback_plan(table)` | The restore SQL. Capture it *before* changing anything. |
|
|
75
|
+
| `preview_write(sql, target, keys?)` | Runs one write against a **disposable shallow clone**, never production, and returns the diff plus verdict. |
|
|
76
|
+
| `assess_change(tables, candidates?, pr?)` | Diffs prod vs. the candidate built by your change, plus lineage and policy. Returns a verdict and an `id`. |
|
|
77
|
+
| `explain_diff(assessment_id or table)` | Plain-English summary. |
|
|
78
|
+
| `safe_to_ship(assessment_id)` | `safe_to_ship`, the reasons, and an explicit `next_step` to follow. |
|
|
79
|
+
|
|
80
|
+
Designed for agents:
|
|
81
|
+
|
|
82
|
+
- **The verdict never comes from an LLM.** Rules in `policy.yml` decide, so the same change always gets the same answer.
|
|
83
|
+
- **Read-only by default.** Only `preview_write` writes, and only to a sandbox clone. It accepts a single INSERT / MERGE / UPDATE / DELETE / CREATE OR REPLACE whose target must be the clone, and it refuses DROP, TRUNCATE, ALTER, GRANT and multiple statements.
|
|
84
|
+
- **Errors come back as `{"error": ...}`**, so agents can recover instead of crashing.
|
|
85
|
+
- **Bounded cost:** deterministic hash sampling (`sample_pct`), capped samples, and lineage limited by depth and lookback window.
|
|
86
|
+
|
|
87
|
+
The same tools are plain Python functions in `bricksentry.agent_tools`, so you can use them from LangGraph, the OpenAI Agents SDK or the Databricks Agent Framework without MCP.
|
|
88
|
+
|
|
89
|
+
### See the agent loop (no Databricks needed)
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install "bricksentry[duckdb]"
|
|
93
|
+
python examples/demo/agent_loop_demo.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In the demo, an agent previews a risky DELETE (needs_human, production untouched), ships a pipeline change with a join fan-out (**blocked**: 40 duplicate keys), fixes it, and gets routed to the certified table's owners. It ends with the pull-request comment a reviewer would see.
|
|
97
|
+
|
|
98
|
+
## For data engineers: CLI and CI
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bricksentry init # starter bricksentry.yml + policy.yml
|
|
102
|
+
bricksentry impact main.sales.orders # blast radius
|
|
103
|
+
bricksentry diff main.sales.orders main.sales_pr_42.orders
|
|
104
|
+
bricksentry preview main.sales.orders --sql "MERGE INTO {{target}} t USING updates u ON t.order_id = u.order_id WHEN MATCHED THEN UPDATE SET *"
|
|
105
|
+
bricksentry assess --from-git origin/main --pr 42 --format markdown --fail-on needs_human
|
|
106
|
+
bricksentry rollback main.sales.orders
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Exit codes: `0` below `--fail-on`, `1` at or above it, `2` for configuration errors.
|
|
110
|
+
|
|
111
|
+
**In GitHub Actions.** Build your PR's version of the pipeline into a per-PR schema (for example with a Databricks Asset Bundle `ci` target), then add:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
- run: pip install "bricksentry[databricks]"
|
|
115
|
+
- run: >
|
|
116
|
+
bricksentry assess
|
|
117
|
+
--from-git origin/${{ github.base_ref }}
|
|
118
|
+
--pr ${{ github.event.pull_request.number }}
|
|
119
|
+
--comment --output bricksentry-report.md
|
|
120
|
+
--fail-on needs_human
|
|
121
|
+
env:
|
|
122
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
123
|
+
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
|
|
124
|
+
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
|
|
125
|
+
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
|
|
126
|
+
DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It detects changed tables from the files in the PR and posts or updates one PR comment. Check out with `fetch-depth: 0` and give the job `pull-requests: write` permission. The source repository also ships a composite `action.yml` that wraps these steps.
|
|
130
|
+
|
|
131
|
+
## Python API
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from bricksentry import Sentry
|
|
135
|
+
|
|
136
|
+
s = Sentry() # reads ./bricksentry.yml
|
|
137
|
+
a = s.assess(["main.sales.orders"], pr="42")
|
|
138
|
+
print(a.verdict, a.notify)
|
|
139
|
+
print(a.tables[0].summary)
|
|
140
|
+
|
|
141
|
+
s.preview_write("DELETE FROM main.sales.orders WHERE status = 'test'", "main.sales.orders")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
backend: {type: databricks, warehouse_id: abc123} # auth: standard Databricks SDK chain
|
|
148
|
+
sandbox_schema: main.bricksentry_sandbox
|
|
149
|
+
candidate_pattern: "{catalog}.{schema}_pr_{pr}.{table}"
|
|
150
|
+
sample_pct: 10
|
|
151
|
+
lineage: {type: databricks, depth: 3, lookback_days: 90}
|
|
152
|
+
tables:
|
|
153
|
+
main.sales.orders:
|
|
154
|
+
keys: [order_id]
|
|
155
|
+
segment_by: [region]
|
|
156
|
+
paths: ["src/pipelines/orders/**", "resources/orders_job.yml"]
|
|
157
|
+
policy: policy.yml
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Policy conditions
|
|
161
|
+
|
|
162
|
+
`table` (glob) · `tag` · `downstream_tag` · `downstream_kind` · `downstream_count_gt` · `removed_pct_gt` · `added_pct_gt` · `changed_pct_gt` · `row_count_change_pct_gt` · `row_count_drop_pct_gt` · `column_removed` · `column_added` · `column_type_changed` · `duplicate_keys` · `sum_change_pct_gt` / `null_increase_gt` (optionally with `column`) · `any_change` · `diff_error`
|
|
163
|
+
|
|
164
|
+
All conditions in a `when` must hold. The most severe matching rule sets the verdict. Unknown conditions are rejected at load time, so a typo can't silently disable a rule.
|
|
165
|
+
|
|
166
|
+
## Permissions needed on Databricks
|
|
167
|
+
|
|
168
|
+
- `CAN USE` on a SQL warehouse.
|
|
169
|
+
- `SELECT` on the tables you assess and their candidates.
|
|
170
|
+
- `SELECT` on `system.access.table_lineage`, plus `system.information_schema` for owners and tags. Without these, bricksentry still diffs; lineage is just empty.
|
|
171
|
+
- `CREATE TABLE` on `sandbox_schema`, for `preview_write`.
|
|
172
|
+
|
|
173
|
+
## Known limits (v0.1)
|
|
174
|
+
|
|
175
|
+
- **Lineage is recorded at runtime.** Brand-new tables have no downstream history yet. Unity Catalog doesn't capture RDD operations, and UDFs hide column-level mapping.
|
|
176
|
+
- **Keys are required.** Tables without a stable key can't be diffed row by row yet (an aggregate-profile mode is planned).
|
|
177
|
+
- **Changed-table detection uses path globs** from config. It doesn't parse Asset Bundles yet.
|
|
178
|
+
- **The Databricks backend is tested against a mocked SDK.** Run it against a dev workspace before relying on it in CI.
|
|
179
|
+
|
|
180
|
+
## Roadmap
|
|
181
|
+
|
|
182
|
+
Asset Bundle parsing for automatic table detection · column-level blast radius from `system.access.column_lineage` · an aggregate diff for keyless tables · Lakeflow expectations in the verdict · `bricksentry watch` for scheduled drift checks · a Databricks App UI.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
Apache-2.0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
bricksentry/__init__.py,sha256=X8DV-XCMXInzsomkoj1P0uU3hgM1SDWM4FxmhxgCsg8,661
|
|
2
|
+
bricksentry/agent_tools.py,sha256=LTY279hdNd7JeDh_ZNjfpmPLxM-ey1ARSgiacgn8kDY,4968
|
|
3
|
+
bricksentry/assess.py,sha256=kCLbcqF8XLvL9fo2VfbcPdvngWB8x8A6jvR3Zhsl3zo,6585
|
|
4
|
+
bricksentry/changes.py,sha256=gT0lLYZS68BzqueEDbQSoMtqxclG6kBeRdB2AZdq_b4,1013
|
|
5
|
+
bricksentry/cli.py,sha256=c6zXWm-s8RB8biJOtULwRTQj4b5_kqtk6xOJmkZ8Vm4,9837
|
|
6
|
+
bricksentry/config.py,sha256=9np7IW2JNBho8JZIf-tLz_TcApqQdEy3xjwnn8AHr1E,4329
|
|
7
|
+
bricksentry/diff.py,sha256=zsGohP6zKZiQdJXFUSCFn_og_q61Sp4dsaDuEAKQmK0,7141
|
|
8
|
+
bricksentry/explain.py,sha256=dMUa6jv5u4ndxjziyl0XoXTP7M0Px2MQiygka4Hxsxs,3651
|
|
9
|
+
bricksentry/github.py,sha256=iu4fVXsYYKu08tx11YB8OwcxRANJMnLdD2Klt_5A4FI,1993
|
|
10
|
+
bricksentry/lineage.py,sha256=3dKYlPlyYEFh3THmC8mlrZbI8LVoCQQpaA1qXIvm_7s,9054
|
|
11
|
+
bricksentry/mcp_server.py,sha256=g2EvKi6-vz-XW4mGjeWe4rJWumKTEiBEwveT4twz7Fc,1785
|
|
12
|
+
bricksentry/models.py,sha256=XJQiQnvcVr69yQKA3jaNcRK3m6QFNs_2w8skTfb_K2w,5977
|
|
13
|
+
bricksentry/policy.py,sha256=FwFwraB8keEsoQhEGD_T64JHE7_4EPsPcaAK9Bhnvqs,9031
|
|
14
|
+
bricksentry/preview.py,sha256=y27u4oQNrHOx_l38uWkuwL3CUW8v_ba-jH1knigHEhc,3278
|
|
15
|
+
bricksentry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
bricksentry/report.py,sha256=5Sm35lHnelPhhYXjHaOTrDTfoJVv0bACHum130CEVA0,3967
|
|
17
|
+
bricksentry/backends/__init__.py,sha256=JRX-XiI2R1qlaCobzW8yhgRqhJUUw4l55dVizYAwVZc,99
|
|
18
|
+
bricksentry/backends/base.py,sha256=AZW7KlAAOSN2masgimBxZvH3mO7jLnFaewAoswvLlkw,3234
|
|
19
|
+
bricksentry/backends/databricks_backend.py,sha256=Na2ZOWI8DhPD_pPRox-hU8MgheH1rxO4t7QZDV1Pvg8,5829
|
|
20
|
+
bricksentry/backends/duckdb_backend.py,sha256=Q1z-jyW7H0WvH2vDaaasgP_38LvFGB0jBKiVEgKo-DI,1850
|
|
21
|
+
bricksentry-0.1.0.dist-info/METADATA,sha256=BO6kioJxcVJl88iPr3wfJyH1ZTPwON3eoOQUHSKO_zw,9500
|
|
22
|
+
bricksentry-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
23
|
+
bricksentry-0.1.0.dist-info/entry_points.txt,sha256=sWHWuzVCxUOUxH5dLTqQgLgVGdiWWWeClQa3xKLcjHA,54
|
|
24
|
+
bricksentry-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
25
|
+
bricksentry-0.1.0.dist-info/RECORD,,
|