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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bricksentry = bricksentry.cli:entry