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/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
|
+
}
|