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/github.py ADDED
@@ -0,0 +1,52 @@
1
+ """Post or update a single bricksentry comment on a GitHub pull request (stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.request
8
+
9
+ from bricksentry.report import MARKER
10
+
11
+
12
+ def _req(method: str, url: str, token: str, body: dict | None = None):
13
+ data = json.dumps(body).encode() if body is not None else None
14
+ req = urllib.request.Request(url, data=data, method=method)
15
+ req.add_header("Authorization", f"Bearer {token}")
16
+ req.add_header("Accept", "application/vnd.github+json")
17
+ req.add_header("X-GitHub-Api-Version", "2022-11-28")
18
+ if data:
19
+ req.add_header("Content-Type", "application/json")
20
+ with urllib.request.urlopen(req, timeout=30) as r:
21
+ return json.loads(r.read() or b"null")
22
+
23
+
24
+ def upsert_pr_comment(
25
+ markdown: str,
26
+ repo: str | None = None,
27
+ pr: int | str | None = None,
28
+ token: str | None = None,
29
+ api: str | None = None,
30
+ ) -> str:
31
+ repo = repo or os.environ["GITHUB_REPOSITORY"]
32
+ token = token or os.environ["GITHUB_TOKEN"]
33
+ api = (api or os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
34
+ if pr is None:
35
+ event_path = os.environ.get("GITHUB_EVENT_PATH")
36
+ if event_path and os.path.exists(event_path):
37
+ with open(event_path) as f:
38
+ pr = json.load(f).get("pull_request", {}).get("number")
39
+ if not pr:
40
+ raise ValueError("Pull request number not found; pass --pr")
41
+ base = f"{api}/repos/{repo}/issues/{pr}/comments"
42
+ page = 1
43
+ while True:
44
+ comments = _req("GET", f"{base}?per_page=100&page={page}", token) or []
45
+ for c in comments:
46
+ if MARKER in (c.get("body") or ""):
47
+ _req("PATCH", f"{api}/repos/{repo}/issues/comments/{c['id']}", token, {"body": markdown})
48
+ return c.get("html_url", "")
49
+ if len(comments) < 100:
50
+ break
51
+ page += 1
52
+ return (_req("POST", base, token, {"body": markdown}) or {}).get("html_url", "")
bricksentry/lineage.py ADDED
@@ -0,0 +1,224 @@
1
+ """Downstream impact ("blast radius").
2
+
3
+ ``UnityCatalogLineage`` reads Databricks lineage system tables, which are filled
4
+ at *runtime* from query plans. That means it sees PySpark notebooks, Jobs,
5
+ Lakeflow pipelines, dashboards and SQL queries, not just SQL you can parse.
6
+
7
+ ``StaticLineage`` takes edges from config, for local work, tests and non-UC estates.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any
14
+
15
+ from bricksentry.backends.base import Backend
16
+ from bricksentry.models import ImpactNode
17
+
18
+ _ENTITY_KINDS = {
19
+ "NOTEBOOK": "notebook",
20
+ "JOB": "job",
21
+ "PIPELINE": "pipeline",
22
+ "DLT_PIPELINE": "pipeline",
23
+ "DASHBOARD": "dashboard",
24
+ "DASHBOARD_V3": "dashboard",
25
+ "DBSQL_DASHBOARD": "dashboard",
26
+ "DBSQL_QUERY": "query",
27
+ "SQL_QUERY": "query",
28
+ "GENIE_SPACE": "genie_space",
29
+ }
30
+
31
+
32
+ class LineageProvider(ABC):
33
+ @abstractmethod
34
+ def downstream(self, table: str, depth: int = 3) -> list[ImpactNode]: ...
35
+
36
+ def owner_and_tags(self, table: str) -> tuple[str | None, dict[str, str]]:
37
+ return None, {}
38
+
39
+
40
+ class StaticLineage(LineageProvider):
41
+ """Lineage from config::
42
+
43
+ lineage:
44
+ type: static
45
+ edges:
46
+ main.sales.orders: [main.sales.daily_revenue, "dashboard:Revenue Overview"]
47
+ owners:
48
+ main.sales.daily_revenue: finance-team
49
+ tags:
50
+ main.sales.daily_revenue: {certified: "true"}
51
+ """
52
+
53
+ def __init__(self, edges: dict[str, list[str]] | None = None, owners: dict | None = None, tags: dict | None = None):
54
+ self.edges = edges or {}
55
+ self.owners = owners or {}
56
+ self.tags = tags or {}
57
+
58
+ def downstream(self, table: str, depth: int = 3) -> list[ImpactNode]:
59
+ out: list[ImpactNode] = []
60
+ seen = {table}
61
+ frontier = [table]
62
+ for d in range(1, depth + 1):
63
+ nxt = []
64
+ for src in frontier:
65
+ for tgt in self.edges.get(src, []):
66
+ kind, name = _parse_static(tgt)
67
+ if name in seen:
68
+ continue
69
+ seen.add(name)
70
+ out.append(
71
+ ImpactNode(
72
+ kind=kind,
73
+ name=name,
74
+ depth=d,
75
+ via=src,
76
+ owner=self.owners.get(name),
77
+ tags={str(k): str(v) for k, v in (self.tags.get(name) or {}).items()},
78
+ )
79
+ )
80
+ if kind == "table":
81
+ nxt.append(name)
82
+ frontier = nxt
83
+ if not frontier:
84
+ break
85
+ return out
86
+
87
+ def owner_and_tags(self, table: str) -> tuple[str | None, dict[str, str]]:
88
+ return self.owners.get(table), {str(k): str(v) for k, v in (self.tags.get(table) or {}).items()}
89
+
90
+
91
+ def _parse_static(s: str) -> tuple[str, str]:
92
+ if ":" in s and not s.startswith(("http:", "https:")):
93
+ kind, name = s.split(":", 1)
94
+ return kind.strip().lower(), name.strip()
95
+ return "table", s
96
+
97
+
98
+ class UnityCatalogLineage(LineageProvider):
99
+ """Breadth-first walk over ``system.access.table_lineage``.
100
+
101
+ One query per depth level (not per node), bounded by ``lookback_days``.
102
+ Table owners come from ``information_schema.tables`` and tags from
103
+ ``information_schema.table_tags``. All table names are configurable so the
104
+ same code runs against copies of the system tables in tests.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ backend: Backend,
110
+ lookback_days: int = 90,
111
+ lineage_table: str = "system.access.table_lineage",
112
+ tables_table: str = "system.information_schema.tables",
113
+ tags_table: str = "system.information_schema.table_tags",
114
+ max_nodes: int = 500,
115
+ ):
116
+ self.backend = backend
117
+ self.lookback_days = lookback_days
118
+ self.lineage_table = lineage_table
119
+ self.tables_table = tables_table
120
+ self.tags_table = tags_table
121
+ self.max_nodes = max_nodes
122
+ self._meta_cache: dict[str, tuple[str | None, dict[str, str]]] = {}
123
+
124
+ def downstream(self, table: str, depth: int = 3) -> list[ImpactNode]:
125
+ out: list[ImpactNode] = []
126
+ seen_tables = {table.lower()}
127
+ seen_entities: set[tuple[str, str]] = set()
128
+ frontier = [table]
129
+ for d in range(1, depth + 1):
130
+ if not frontier or len(out) >= self.max_nodes:
131
+ break
132
+ in_list = ", ".join(_lit(t) for t in frontier)
133
+ sql = (
134
+ "SELECT DISTINCT source_table_full_name AS src, target_table_full_name AS tgt, "
135
+ "entity_type, CAST(entity_id AS STRING) AS entity_id "
136
+ f"FROM {self.lineage_table} "
137
+ f"WHERE lower(source_table_full_name) IN ({in_list.lower()}) "
138
+ f"AND event_date >= {self.backend.days_ago(self.lookback_days)}"
139
+ )
140
+ rows = self.backend.query(sql)
141
+ nxt: list[str] = []
142
+ for r in rows:
143
+ src = r.get("src")
144
+ tgt = r.get("tgt")
145
+ if tgt and tgt.lower() not in seen_tables:
146
+ seen_tables.add(tgt.lower())
147
+ nxt.append(tgt)
148
+ out.append(ImpactNode(kind="table", name=tgt, depth=d, via=src))
149
+ et = (r.get("entity_type") or "").upper()
150
+ eid = r.get("entity_id")
151
+ if et and eid and (et, eid) not in seen_entities:
152
+ seen_entities.add((et, eid))
153
+ out.append(
154
+ ImpactNode(kind=_ENTITY_KINDS.get(et, et.lower()), name=f"{et.lower()}:{eid}", depth=d, via=src)
155
+ )
156
+ frontier = nxt
157
+ out = out[: self.max_nodes]
158
+ self._attach_meta([n for n in out if n.kind == "table"])
159
+ return out
160
+
161
+ def owner_and_tags(self, table: str) -> tuple[str | None, dict[str, str]]:
162
+ if table.lower() not in self._meta_cache:
163
+ self._load_meta([table])
164
+ return self._meta_cache.get(table.lower(), (None, {}))
165
+
166
+ def _attach_meta(self, nodes: list[ImpactNode]) -> None:
167
+ names = [n.name for n in nodes if n.name.lower() not in self._meta_cache]
168
+ if names:
169
+ self._load_meta(names)
170
+ for n in nodes:
171
+ n.owner, n.tags = self._meta_cache.get(n.name.lower(), (None, {}))
172
+
173
+ def _load_meta(self, names: list[str]) -> None:
174
+ for name in names:
175
+ self._meta_cache.setdefault(name.lower(), (None, {}))
176
+ full = "lower(concat_ws('.', table_catalog, table_schema, table_name))"
177
+ tag_full = "lower(concat_ws('.', catalog_name, schema_name, table_name))"
178
+ for i in range(0, len(names), 200):
179
+ batch = names[i : i + 200]
180
+ in_list = ", ".join(_lit(n.lower()) for n in batch)
181
+ try:
182
+ for r in self.backend.query(
183
+ f"SELECT {full} AS fqn, table_owner FROM {self.tables_table} WHERE {full} IN ({in_list})"
184
+ ):
185
+ _, tags = self._meta_cache[r["fqn"]]
186
+ self._meta_cache[r["fqn"]] = (r.get("table_owner"), tags)
187
+ except Exception:
188
+ pass # missing privileges on information_schema should not break the check
189
+ try:
190
+ for r in self.backend.query(
191
+ f"SELECT {tag_full} AS fqn, tag_name, tag_value FROM {self.tags_table} "
192
+ f"WHERE {tag_full} IN ({in_list})"
193
+ ):
194
+ owner, tags = self._meta_cache[r["fqn"]]
195
+ tags = dict(tags)
196
+ tags[str(r["tag_name"])] = "" if r.get("tag_value") is None else str(r["tag_value"])
197
+ self._meta_cache[r["fqn"]] = (owner, tags)
198
+ except Exception:
199
+ pass
200
+
201
+
202
+ def _lit(s: str) -> str:
203
+ s = str(s)
204
+ if any(ch in s for ch in "'\\\n\r;"):
205
+ # Table names can come from agents; refuse anything that could break out of a literal.
206
+ raise ValueError(f"Refusing suspicious table name in lineage lookup: {s!r}")
207
+ return "'" + s + "'"
208
+
209
+
210
+ def make_lineage(cfg: dict[str, Any] | None, backend: Backend) -> LineageProvider:
211
+ cfg = dict(cfg or {})
212
+ kind = cfg.get("type") or ("databricks" if backend.name == "databricks" else "static")
213
+ if kind == "static":
214
+ return StaticLineage(cfg.get("edges"), cfg.get("owners"), cfg.get("tags"))
215
+ if kind in ("databricks", "unity_catalog", "uc"):
216
+ return UnityCatalogLineage(
217
+ backend,
218
+ lookback_days=int(cfg.get("lookback_days", 90)),
219
+ lineage_table=cfg.get("lineage_table", "system.access.table_lineage"),
220
+ tables_table=cfg.get("tables_table", "system.information_schema.tables"),
221
+ tags_table=cfg.get("tags_table", "system.information_schema.table_tags"),
222
+ max_nodes=int(cfg.get("max_nodes", 500)),
223
+ )
224
+ raise ValueError(f"Unknown lineage type {kind!r}")
@@ -0,0 +1,47 @@
1
+ """MCP server exposing bricksentry's agent tools.
2
+
3
+ Run: ``bricksentry mcp`` (stdio) or ``bricksentry mcp --transport streamable-http``
4
+
5
+ Claude Code: ``claude mcp add bricksentry -- bricksentry mcp --config /path/bricksentry.yml``
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from bricksentry import agent_tools
11
+
12
+ INSTRUCTIONS = """bricksentry checks whether a change to Databricks data is safe.
13
+ Recommended loop when you change a pipeline or table:
14
+ 1. whats_downstream(table) to learn the blast radius before editing.
15
+ 2. rollback_plan(table) and keep the SQL.
16
+ 3. preview_write(sql, target) for any direct write, or assess_change(tables) after
17
+ your change has built candidate tables.
18
+ 4. safe_to_ship(assessment_id) and follow `next_step` exactly.
19
+ Never bypass a needs_human or block verdict."""
20
+
21
+
22
+ def build_server():
23
+ try: # mcp >= 2
24
+ from mcp.server.mcpserver import MCPServer as Server
25
+ except ImportError:
26
+ try: # mcp 1.x
27
+ from mcp.server.fastmcp import FastMCP as Server
28
+ except ImportError as e: # pragma: no cover
29
+ raise ImportError("The MCP server needs `pip install bricksentry[mcp]`") from e
30
+
31
+ try:
32
+ from mcp.types import ToolAnnotations
33
+ except ImportError: # pragma: no cover
34
+ ToolAnnotations = None
35
+
36
+ server = Server(name="bricksentry", instructions=INSTRUCTIONS)
37
+ for fn in agent_tools.TOOLS:
38
+ ann = None
39
+ if ToolAnnotations is not None:
40
+ ro = fn.__name__ in agent_tools.READ_ONLY
41
+ ann = ToolAnnotations(readOnlyHint=ro, destructiveHint=False, idempotentHint=ro)
42
+ server.tool(name=fn.__name__, description=fn.__doc__, annotations=ann)(fn)
43
+ return server
44
+
45
+
46
+ def serve(transport: str = "stdio") -> None:
47
+ build_server().run(transport=transport)
bricksentry/models.py ADDED
@@ -0,0 +1,212 @@
1
+ """Result types. Every result is a plain dataclass with a stable ``to_dict()``,
2
+ so agents can branch on fields and humans get the same data rendered as markdown."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import asdict, dataclass, field
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ class Verdict(str, Enum):
12
+ """Ordered from safest to most severe."""
13
+
14
+ PASS = "pass"
15
+ WARN = "warn"
16
+ NEEDS_HUMAN = "needs_human"
17
+ BLOCK = "block"
18
+
19
+ @property
20
+ def rank(self) -> int:
21
+ return [Verdict.PASS, Verdict.WARN, Verdict.NEEDS_HUMAN, Verdict.BLOCK].index(self)
22
+
23
+ @property
24
+ def risk_tier(self) -> str:
25
+ return {
26
+ Verdict.PASS: "low",
27
+ Verdict.WARN: "medium",
28
+ Verdict.NEEDS_HUMAN: "high",
29
+ Verdict.BLOCK: "blocked",
30
+ }[self]
31
+
32
+ @staticmethod
33
+ def worst(verdicts: list["Verdict"]) -> "Verdict":
34
+ return max(verdicts, key=lambda v: v.rank, default=Verdict.PASS)
35
+
36
+
37
+ @dataclass
38
+ class ColumnChange:
39
+ column: str
40
+ changed_rows: int
41
+ base_nulls: int | None = None
42
+ candidate_nulls: int | None = None
43
+ base_sum: float | None = None
44
+ candidate_sum: float | None = None
45
+ samples: list[dict[str, Any]] = field(default_factory=list)
46
+
47
+ @property
48
+ def sum_change_pct(self) -> float | None:
49
+ if self.base_sum in (None, 0) or self.candidate_sum is None:
50
+ return None
51
+ return (self.candidate_sum - self.base_sum) / abs(self.base_sum) * 100
52
+
53
+
54
+ @dataclass
55
+ class SchemaChange:
56
+ added: dict[str, str] = field(default_factory=dict)
57
+ removed: dict[str, str] = field(default_factory=dict)
58
+ type_changed: dict[str, tuple[str, str]] = field(default_factory=dict)
59
+
60
+ @property
61
+ def is_empty(self) -> bool:
62
+ return not (self.added or self.removed or self.type_changed)
63
+
64
+
65
+ @dataclass
66
+ class Segment:
67
+ column: str
68
+ value: Any
69
+ affected_rows: int
70
+
71
+
72
+ @dataclass
73
+ class TableDiff:
74
+ table: str
75
+ base: str
76
+ candidate: str
77
+ keys: list[str]
78
+ base_rows: int
79
+ candidate_rows: int
80
+ added_rows: int
81
+ removed_rows: int
82
+ changed_rows: int
83
+ unchanged_rows: int
84
+ schema: SchemaChange
85
+ columns: list[ColumnChange] = field(default_factory=list)
86
+ duplicate_keys_base: int = 0
87
+ duplicate_keys_candidate: int = 0
88
+ segments: list[Segment] = field(default_factory=list)
89
+ sampled_pct: float | None = None
90
+ error: str | None = None
91
+
92
+ def _pct(self, n: int) -> float:
93
+ denom = max(self.base_rows, 1)
94
+ return round(n / denom * 100, 4)
95
+
96
+ @property
97
+ def added_pct(self) -> float:
98
+ return self._pct(self.added_rows)
99
+
100
+ @property
101
+ def removed_pct(self) -> float:
102
+ return self._pct(self.removed_rows)
103
+
104
+ @property
105
+ def changed_pct(self) -> float:
106
+ return self._pct(self.changed_rows)
107
+
108
+ @property
109
+ def row_count_change_pct(self) -> float:
110
+ return self._pct(self.candidate_rows - self.base_rows)
111
+
112
+ @property
113
+ def is_identical(self) -> bool:
114
+ return (
115
+ self.error is None
116
+ and self.schema.is_empty
117
+ and self.added_rows == 0
118
+ and self.removed_rows == 0
119
+ and self.changed_rows == 0
120
+ and self.base_rows == self.candidate_rows
121
+ and self.duplicate_keys_candidate <= self.duplicate_keys_base
122
+ )
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ d = asdict(self)
126
+ d["schema"]["type_changed"] = {
127
+ k: {"base": v[0], "candidate": v[1]} for k, v in self.schema.type_changed.items()
128
+ }
129
+ d["added_pct"] = self.added_pct
130
+ d["removed_pct"] = self.removed_pct
131
+ d["changed_pct"] = self.changed_pct
132
+ d["row_count_change_pct"] = self.row_count_change_pct
133
+ d["is_identical"] = self.is_identical
134
+ for c, cc in zip(d["columns"], self.columns):
135
+ c["sum_change_pct"] = cc.sum_change_pct
136
+ return d
137
+
138
+
139
+ @dataclass
140
+ class ImpactNode:
141
+ """Something downstream of a changed table: a table, job, notebook, pipeline, dashboard, query..."""
142
+
143
+ kind: str
144
+ name: str
145
+ depth: int
146
+ via: str
147
+ owner: str | None = None
148
+ tags: dict[str, str] = field(default_factory=dict)
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ return asdict(self)
152
+
153
+
154
+ @dataclass
155
+ class RuleHit:
156
+ rule: str
157
+ verdict: Verdict
158
+ table: str
159
+ reason: str
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ return {"rule": self.rule, "verdict": self.verdict.value, "table": self.table, "reason": self.reason}
163
+
164
+
165
+ @dataclass
166
+ class TableAssessment:
167
+ table: str
168
+ diff: TableDiff | None
169
+ downstream: list[ImpactNode]
170
+ owner: str | None
171
+ tags: dict[str, str]
172
+ rollback: str | None
173
+ verdict: Verdict = Verdict.PASS
174
+ hits: list[RuleHit] = field(default_factory=list)
175
+ summary: str = ""
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ return {
179
+ "table": self.table,
180
+ "verdict": self.verdict.value,
181
+ "risk_tier": self.verdict.risk_tier,
182
+ "owner": self.owner,
183
+ "tags": self.tags,
184
+ "summary": self.summary,
185
+ "rule_hits": [h.to_dict() for h in self.hits],
186
+ "diff": self.diff.to_dict() if self.diff else None,
187
+ "downstream": [n.to_dict() for n in self.downstream],
188
+ "rollback": self.rollback,
189
+ }
190
+
191
+
192
+ @dataclass
193
+ class Assessment:
194
+ id: str
195
+ tables: list[TableAssessment]
196
+ verdict: Verdict
197
+ notify: list[str] = field(default_factory=list)
198
+
199
+ @property
200
+ def hits(self) -> list[RuleHit]:
201
+ return [h for t in self.tables for h in t.hits]
202
+
203
+ def to_dict(self) -> dict[str, Any]:
204
+ return {
205
+ "id": self.id,
206
+ "verdict": self.verdict.value,
207
+ "risk_tier": self.verdict.risk_tier,
208
+ "safe_to_ship": self.verdict in (Verdict.PASS, Verdict.WARN),
209
+ "notify": self.notify,
210
+ "rule_hits": [h.to_dict() for h in self.hits],
211
+ "tables": [t.to_dict() for t in self.tables],
212
+ }