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/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""bricksentry: change-safety for Databricks pipelines and the AI agents that edit them.
|
|
2
|
+
|
|
3
|
+
One engine, three faces:
|
|
4
|
+
|
|
5
|
+
* CLI / GitHub Action for data engineers and CI
|
|
6
|
+
* Python API for notebooks, jobs and tests
|
|
7
|
+
* MCP server for AI coding agents
|
|
8
|
+
|
|
9
|
+
Every face answers the same question: *is this change safe, what will it break,
|
|
10
|
+
and how do I undo it?*
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from bricksentry.assess import Sentry
|
|
14
|
+
from bricksentry.config import Config, TableSpec
|
|
15
|
+
from bricksentry.models import Assessment, ImpactNode, TableDiff, Verdict
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Sentry",
|
|
19
|
+
"Config",
|
|
20
|
+
"TableSpec",
|
|
21
|
+
"Assessment",
|
|
22
|
+
"ImpactNode",
|
|
23
|
+
"TableDiff",
|
|
24
|
+
"Verdict",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Agent tools as plain Python functions returning JSON-safe dicts.
|
|
2
|
+
|
|
3
|
+
The MCP server registers these, but they work in any agent framework
|
|
4
|
+
(LangGraph, OpenAI Agents SDK, Databricks Agent Framework...): import and wrap.
|
|
5
|
+
|
|
6
|
+
Errors come back as ``{"error": "..."}`` instead of exceptions, so an agent
|
|
7
|
+
can read them and recover.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from bricksentry.assess import Sentry
|
|
16
|
+
from bricksentry.explain import explain_diff as _explain_diff
|
|
17
|
+
|
|
18
|
+
_SENTRY: Sentry | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_sentry() -> Sentry:
|
|
22
|
+
global _SENTRY
|
|
23
|
+
if _SENTRY is None:
|
|
24
|
+
_SENTRY = Sentry()
|
|
25
|
+
return _SENTRY
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_sentry(sentry: Sentry | None) -> None:
|
|
29
|
+
global _SENTRY
|
|
30
|
+
_SENTRY = sentry
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe(fn):
|
|
34
|
+
@functools.wraps(fn)
|
|
35
|
+
def wrapper(*args, **kwargs):
|
|
36
|
+
try:
|
|
37
|
+
return fn(*args, **kwargs)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
40
|
+
|
|
41
|
+
return wrapper
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@_safe
|
|
45
|
+
def whats_downstream(table: str, depth: int = 3) -> dict[str, Any]:
|
|
46
|
+
"""List everything downstream of a table (tables, jobs, notebooks, pipelines,
|
|
47
|
+
dashboards, queries) with owners and tags, from lineage. Read-only."""
|
|
48
|
+
s = get_sentry()
|
|
49
|
+
nodes = s.downstream(table, depth)
|
|
50
|
+
owner, tags = s.lineage.owner_and_tags(table)
|
|
51
|
+
return {
|
|
52
|
+
"table": table,
|
|
53
|
+
"owner": owner,
|
|
54
|
+
"tags": tags,
|
|
55
|
+
"count": len(nodes),
|
|
56
|
+
"downstream": [n.to_dict() for n in nodes],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@_safe
|
|
61
|
+
def assess_change(
|
|
62
|
+
tables: list[str],
|
|
63
|
+
candidates: dict[str, str] | None = None,
|
|
64
|
+
pr: str | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Assess a change: diff each table's production version against its candidate
|
|
67
|
+
(built by your change), walk downstream lineage and apply policy.
|
|
68
|
+
Returns a verdict (pass / warn / needs_human / block), a risk tier, reasons,
|
|
69
|
+
owners to notify and an assessment id for explain_diff / safe_to_ship. Read-only."""
|
|
70
|
+
a = get_sentry().assess(list(tables), candidates=candidates, pr=pr)
|
|
71
|
+
return a.to_dict()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@_safe
|
|
75
|
+
def preview_write(sql: str, target: str, keys: list[str] | None = None) -> dict[str, Any]:
|
|
76
|
+
"""Run ONE write statement (INSERT / MERGE / UPDATE / DELETE / CREATE OR REPLACE TABLE)
|
|
77
|
+
against a disposable sandbox clone of `target`, never production, and report
|
|
78
|
+
what it would change plus the policy verdict. Use {{target}} in the SQL or the
|
|
79
|
+
literal table name. The clone is dropped afterwards."""
|
|
80
|
+
return get_sentry().preview_write(sql, target, keys=keys)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@_safe
|
|
84
|
+
def explain_diff(assessment_id: str | None = None, table: str | None = None) -> dict[str, Any]:
|
|
85
|
+
"""Plain-English explanation of what changed, by assessment id or table name. Read-only."""
|
|
86
|
+
s = get_sentry()
|
|
87
|
+
if assessment_id:
|
|
88
|
+
a = s.get_assessment(assessment_id)
|
|
89
|
+
if a is None:
|
|
90
|
+
return {"error": f"unknown assessment id {assessment_id}"}
|
|
91
|
+
return {"assessment_id": a.id, "explanations": {t.table: t.summary for t in a.tables}}
|
|
92
|
+
if table:
|
|
93
|
+
d = s.get_diff(table)
|
|
94
|
+
if d is None:
|
|
95
|
+
return {"error": f"no diff for {table} yet; call assess_change or preview_write first"}
|
|
96
|
+
return {"table": table, "explanation": _explain_diff(d)}
|
|
97
|
+
return {"error": "pass assessment_id or table"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@_safe
|
|
101
|
+
def safe_to_ship(assessment_id: str) -> dict[str, Any]:
|
|
102
|
+
"""Final gate for an assessment: returns safe_to_ship (bool), the verdict,
|
|
103
|
+
the rules that fired and the next step the agent should take. Read-only."""
|
|
104
|
+
a = get_sentry().get_assessment(assessment_id)
|
|
105
|
+
if a is None:
|
|
106
|
+
return {"error": f"unknown assessment id {assessment_id}"}
|
|
107
|
+
v = a.verdict.value
|
|
108
|
+
next_step = {
|
|
109
|
+
"pass": "Proceed: open or merge the pull request.",
|
|
110
|
+
"warn": "Proceed, but include the warnings in the pull request description.",
|
|
111
|
+
"needs_human": "Do not merge. Open the pull request with the report and request review from: "
|
|
112
|
+
+ (", ".join(a.notify) or "a data owner") + ".",
|
|
113
|
+
"block": "Stop. Fix the blocking issues listed in rule_hits, rebuild the candidate and assess again.",
|
|
114
|
+
}[v]
|
|
115
|
+
return {
|
|
116
|
+
"assessment_id": a.id,
|
|
117
|
+
"safe_to_ship": v in ("pass", "warn"),
|
|
118
|
+
"verdict": v,
|
|
119
|
+
"risk_tier": a.verdict.risk_tier,
|
|
120
|
+
"rule_hits": [h.to_dict() for h in a.hits],
|
|
121
|
+
"notify": a.notify,
|
|
122
|
+
"next_step": next_step,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@_safe
|
|
127
|
+
def rollback_plan(table: str) -> dict[str, Any]:
|
|
128
|
+
"""The exact SQL to restore `table` to its current version if a change goes wrong.
|
|
129
|
+
Capture it BEFORE applying a change. Read-only."""
|
|
130
|
+
s = get_sentry()
|
|
131
|
+
return {
|
|
132
|
+
"table": table,
|
|
133
|
+
"current_version": s.backend.current_version(table),
|
|
134
|
+
"rollback_sql": s.rollback_plan(table),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
TOOLS = [whats_downstream, assess_change, preview_write, explain_diff, safe_to_ship, rollback_plan]
|
|
139
|
+
READ_ONLY = {"whats_downstream", "assess_change", "explain_diff", "safe_to_ship", "rollback_plan"}
|
bricksentry/assess.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The ``Sentry``: one object that the CLI, the MCP server and Python users all share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bricksentry.backends.base import Backend, make_backend
|
|
9
|
+
from bricksentry.config import Config
|
|
10
|
+
from bricksentry.diff import diff_tables
|
|
11
|
+
from bricksentry.explain import explain_diff, explain_impact
|
|
12
|
+
from bricksentry.lineage import LineageProvider, make_lineage
|
|
13
|
+
from bricksentry.models import Assessment, ImpactNode, TableAssessment, TableDiff, Verdict
|
|
14
|
+
from bricksentry.policy import Policy
|
|
15
|
+
from bricksentry.preview import rewrite_for_preview, sandbox_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Sentry:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
config: Config | None = None,
|
|
22
|
+
backend: Backend | None = None,
|
|
23
|
+
lineage: LineageProvider | None = None,
|
|
24
|
+
policy: Policy | None = None,
|
|
25
|
+
):
|
|
26
|
+
self.config = config or Config.load()
|
|
27
|
+
self.backend = backend or make_backend(self.config.backend)
|
|
28
|
+
self.lineage = lineage or make_lineage(self.config.lineage, self.backend)
|
|
29
|
+
self.policy = policy or Policy(self.config.policy)
|
|
30
|
+
self._assessments: dict[str, Assessment] = {}
|
|
31
|
+
self._diffs: dict[str, TableDiff] = {}
|
|
32
|
+
|
|
33
|
+
# --- building blocks --------------------------------------------------
|
|
34
|
+
def diff(
|
|
35
|
+
self,
|
|
36
|
+
base: str,
|
|
37
|
+
candidate: str,
|
|
38
|
+
keys: list[str] | None = None,
|
|
39
|
+
table: str | None = None,
|
|
40
|
+
sample_pct: float | None = None,
|
|
41
|
+
) -> TableDiff:
|
|
42
|
+
spec = self.config.spec(table or base)
|
|
43
|
+
d = diff_tables(
|
|
44
|
+
self.backend,
|
|
45
|
+
base,
|
|
46
|
+
candidate,
|
|
47
|
+
keys or spec.keys,
|
|
48
|
+
table=table or base,
|
|
49
|
+
sample_pct=sample_pct if sample_pct is not None else self.config.sample_pct,
|
|
50
|
+
segment_by=spec.segment_by,
|
|
51
|
+
max_samples=self.config.max_samples,
|
|
52
|
+
)
|
|
53
|
+
self._diffs[d.table] = d
|
|
54
|
+
return d
|
|
55
|
+
|
|
56
|
+
def downstream(self, table: str, depth: int | None = None) -> list[ImpactNode]:
|
|
57
|
+
return self.lineage.downstream(table, depth or int(self.config.lineage.get("depth", 3)))
|
|
58
|
+
|
|
59
|
+
def rollback_plan(self, table: str) -> str:
|
|
60
|
+
v = self.backend.current_version(table)
|
|
61
|
+
if v is None:
|
|
62
|
+
return (
|
|
63
|
+
f"No Delta history available for {table} on this backend; "
|
|
64
|
+
"take a snapshot (CREATE TABLE ... CLONE) before applying the change."
|
|
65
|
+
)
|
|
66
|
+
return f"RESTORE TABLE {self.backend.qualified(table)} TO VERSION AS OF {v}"
|
|
67
|
+
|
|
68
|
+
# --- the main entry point ---------------------------------------------
|
|
69
|
+
def assess(
|
|
70
|
+
self,
|
|
71
|
+
tables: list[str],
|
|
72
|
+
candidates: dict[str, str] | None = None,
|
|
73
|
+
pr: str | None = None,
|
|
74
|
+
diffs: dict[str, TableDiff] | None = None,
|
|
75
|
+
) -> Assessment:
|
|
76
|
+
"""Assess a change touching ``tables``.
|
|
77
|
+
|
|
78
|
+
For each table: diff base vs candidate (unless a diff is supplied),
|
|
79
|
+
walk downstream lineage, evaluate policy and record a rollback plan.
|
|
80
|
+
"""
|
|
81
|
+
candidates = candidates or {}
|
|
82
|
+
diffs = diffs or {}
|
|
83
|
+
results: list[TableAssessment] = []
|
|
84
|
+
notify: set[str] = set()
|
|
85
|
+
for table in tables:
|
|
86
|
+
diff: TableDiff | None = diffs.get(table)
|
|
87
|
+
err: str | None = None
|
|
88
|
+
if diff is None:
|
|
89
|
+
cand = candidates.get(table) or self.config.candidate_for(table, pr)
|
|
90
|
+
try:
|
|
91
|
+
if not self.backend.table_exists(cand):
|
|
92
|
+
raise ValueError(f"candidate table {cand} not found (did the CI build run?)")
|
|
93
|
+
diff = self.diff(table, cand, table=table)
|
|
94
|
+
except Exception as e: # report, don't crash: an agent needs a verdict
|
|
95
|
+
err = str(e)
|
|
96
|
+
try:
|
|
97
|
+
nodes = self.downstream(table)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
nodes = []
|
|
100
|
+
err = err or f"lineage lookup failed: {e}"
|
|
101
|
+
owner, tags = self.lineage.owner_and_tags(table)
|
|
102
|
+
verdict, hits = self.policy.evaluate(table, diff, nodes, tags, diff_error=err)
|
|
103
|
+
summary = (explain_diff(diff) if diff else f"{table}: diff unavailable ({err}).") + " " + explain_impact(table, nodes)
|
|
104
|
+
ta = TableAssessment(
|
|
105
|
+
table=table,
|
|
106
|
+
diff=diff,
|
|
107
|
+
downstream=nodes,
|
|
108
|
+
owner=owner,
|
|
109
|
+
tags=tags,
|
|
110
|
+
rollback=self.rollback_plan(table),
|
|
111
|
+
verdict=verdict,
|
|
112
|
+
hits=hits,
|
|
113
|
+
summary=summary,
|
|
114
|
+
)
|
|
115
|
+
results.append(ta)
|
|
116
|
+
if verdict.rank >= self.policy.notify_on.rank:
|
|
117
|
+
if owner:
|
|
118
|
+
notify.add(owner)
|
|
119
|
+
for n in nodes:
|
|
120
|
+
if n.owner and n.kind == "table":
|
|
121
|
+
notify.add(n.owner)
|
|
122
|
+
a = Assessment(
|
|
123
|
+
id=uuid.uuid4().hex[:12],
|
|
124
|
+
tables=results,
|
|
125
|
+
verdict=Verdict.worst([t.verdict for t in results]),
|
|
126
|
+
notify=sorted(notify),
|
|
127
|
+
)
|
|
128
|
+
self._assessments[a.id] = a
|
|
129
|
+
return a
|
|
130
|
+
|
|
131
|
+
def preview_write(
|
|
132
|
+
self,
|
|
133
|
+
sql: str,
|
|
134
|
+
target: str,
|
|
135
|
+
keys: list[str] | None = None,
|
|
136
|
+
keep: bool = False,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Run a write against a sandbox clone of ``target`` and return the assessment of its effect."""
|
|
139
|
+
spec = self.config.spec(target)
|
|
140
|
+
keys = keys or spec.keys
|
|
141
|
+
if not keys:
|
|
142
|
+
raise ValueError(f"No keys configured for {target}; pass keys=[...] to preview_write")
|
|
143
|
+
clone = sandbox_name(self.config.sandbox_schema, target)
|
|
144
|
+
rewritten = rewrite_for_preview(sql, target, clone, self.backend.quote)
|
|
145
|
+
schema = ".".join(clone.split(".")[:-1])
|
|
146
|
+
if schema:
|
|
147
|
+
self.backend.ensure_schema(schema)
|
|
148
|
+
self.backend.clone(target, clone)
|
|
149
|
+
try:
|
|
150
|
+
self.backend.execute(rewritten)
|
|
151
|
+
diff = self.diff(target, clone, keys=keys, table=target)
|
|
152
|
+
assessment = self.assess([target], diffs={target: diff})
|
|
153
|
+
finally:
|
|
154
|
+
if not keep:
|
|
155
|
+
self.backend.drop(clone)
|
|
156
|
+
out = assessment.to_dict()
|
|
157
|
+
out["preview"] = {"sandbox_table": clone if keep else None, "executed_sql": rewritten}
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
# --- lookups for agents -------------------------------------------------
|
|
161
|
+
def get_assessment(self, assessment_id: str) -> Assessment | None:
|
|
162
|
+
return self._assessments.get(assessment_id)
|
|
163
|
+
|
|
164
|
+
def get_diff(self, table: str) -> TableDiff | None:
|
|
165
|
+
return self._diffs.get(table)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Backend interface. All heavy lifting is pushed down as SQL to the engine
|
|
2
|
+
(Databricks SQL warehouse, or DuckDB locally), so bricksentry itself needs no Spark
|
|
3
|
+
session and stays cheap enough for an agent to call in a loop."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
NUMERIC_PREFIXES = (
|
|
11
|
+
"tinyint", "smallint", "int", "bigint", "long", "short", "byte", "integer",
|
|
12
|
+
"float", "double", "real", "decimal", "numeric", "hugeint", "ubigint", "uinteger",
|
|
13
|
+
"usmallint", "utinyint",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Backend(ABC):
|
|
18
|
+
name: str = "base"
|
|
19
|
+
supports_time_travel: bool = False
|
|
20
|
+
|
|
21
|
+
# --- execution ---------------------------------------------------------
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def query(self, sql: str) -> list[dict[str, Any]]:
|
|
24
|
+
"""Run SQL and return rows as dicts."""
|
|
25
|
+
|
|
26
|
+
def execute(self, sql: str) -> None:
|
|
27
|
+
self.query(sql)
|
|
28
|
+
|
|
29
|
+
def scalar(self, sql: str) -> Any:
|
|
30
|
+
rows = self.query(sql)
|
|
31
|
+
if not rows:
|
|
32
|
+
return None
|
|
33
|
+
return next(iter(rows[0].values()))
|
|
34
|
+
|
|
35
|
+
# --- dialect -----------------------------------------------------------
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def quote(self, ident: str) -> str: ...
|
|
38
|
+
|
|
39
|
+
def qualified(self, table: str) -> str:
|
|
40
|
+
return ".".join(self.quote(p) for p in table.split("."))
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def string_type(self) -> str:
|
|
44
|
+
return "STRING"
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def sample_predicate(self, keys: list[str], pct: float, alias: str | None = None) -> str:
|
|
48
|
+
"""Deterministic hash sample on the key columns, identical on both sides."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def days_ago(self, days: int) -> str:
|
|
52
|
+
"""SQL expression for the date ``days`` days ago."""
|
|
53
|
+
|
|
54
|
+
# --- metadata ----------------------------------------------------------
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def columns(self, table: str) -> dict[str, str]:
|
|
57
|
+
"""Ordered mapping column -> lower-cased type name."""
|
|
58
|
+
|
|
59
|
+
def table_exists(self, table: str) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
self.columns(table)
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def clone(self, source: str, target: str) -> None:
|
|
68
|
+
"""Make a cheap copy of ``source`` at ``target`` (Delta SHALLOW CLONE on Databricks)."""
|
|
69
|
+
|
|
70
|
+
def drop(self, table: str) -> None:
|
|
71
|
+
self.execute(f"DROP TABLE IF EXISTS {self.qualified(table)}")
|
|
72
|
+
|
|
73
|
+
def ensure_schema(self, schema: str) -> None:
|
|
74
|
+
self.execute(f"CREATE SCHEMA IF NOT EXISTS {self.qualified(schema)}")
|
|
75
|
+
|
|
76
|
+
def current_version(self, table: str) -> int | None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def is_numeric(type_name: str) -> bool:
|
|
81
|
+
t = type_name.lower()
|
|
82
|
+
return t.startswith(NUMERIC_PREFIXES)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_backend(spec: dict[str, Any] | None) -> Backend:
|
|
86
|
+
spec = dict(spec or {"type": "duckdb"})
|
|
87
|
+
kind = spec.pop("type", "duckdb")
|
|
88
|
+
if kind == "duckdb":
|
|
89
|
+
from bricksentry.backends.duckdb_backend import DuckDBBackend
|
|
90
|
+
|
|
91
|
+
return DuckDBBackend(**spec)
|
|
92
|
+
if kind == "databricks":
|
|
93
|
+
from bricksentry.backends.databricks_backend import DatabricksBackend
|
|
94
|
+
|
|
95
|
+
return DatabricksBackend(**spec)
|
|
96
|
+
raise ValueError(f"Unknown backend type: {kind!r} (expected 'databricks' or 'duckdb')")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Databricks backend: runs SQL on a SQL warehouse via the Statement Execution API.
|
|
2
|
+
|
|
3
|
+
Auth uses the standard Databricks SDK chain (env vars, ~/.databrickscfg profile,
|
|
4
|
+
OAuth, Azure/GCP identity...). Nothing in bricksentry handles credentials itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bricksentry.backends.base import Backend
|
|
14
|
+
|
|
15
|
+
_INT_TYPES = {"BYTE", "SHORT", "INT", "LONG"}
|
|
16
|
+
_FLOAT_TYPES = {"FLOAT", "DOUBLE", "DECIMAL"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BricksentryQueryError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DatabricksBackend(Backend):
|
|
24
|
+
name = "databricks"
|
|
25
|
+
supports_time_travel = True
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
warehouse_id: str | None = None,
|
|
30
|
+
profile: str | None = None,
|
|
31
|
+
client: Any = None,
|
|
32
|
+
timeout_s: int = 600,
|
|
33
|
+
**_: Any,
|
|
34
|
+
):
|
|
35
|
+
self.warehouse_id = warehouse_id or os.environ.get("DATABRICKS_WAREHOUSE_ID")
|
|
36
|
+
if not self.warehouse_id:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Databricks backend needs a SQL warehouse: set backend.warehouse_id in "
|
|
39
|
+
"bricksentry.yml or the DATABRICKS_WAREHOUSE_ID env var."
|
|
40
|
+
)
|
|
41
|
+
if client is None:
|
|
42
|
+
try:
|
|
43
|
+
from databricks.sdk import WorkspaceClient
|
|
44
|
+
except ImportError as e: # pragma: no cover
|
|
45
|
+
raise ImportError("Databricks backend needs `pip install bricksentry[databricks]`") from e
|
|
46
|
+
client = WorkspaceClient(profile=profile) if profile else WorkspaceClient()
|
|
47
|
+
self.client = client
|
|
48
|
+
self.timeout_s = timeout_s
|
|
49
|
+
|
|
50
|
+
# --- execution ---------------------------------------------------------
|
|
51
|
+
def query(self, sql: str) -> list[dict[str, Any]]:
|
|
52
|
+
api = self.client.statement_execution
|
|
53
|
+
resp = api.execute_statement(
|
|
54
|
+
statement=sql,
|
|
55
|
+
warehouse_id=self.warehouse_id,
|
|
56
|
+
wait_timeout="30s",
|
|
57
|
+
**self._format_kwargs(),
|
|
58
|
+
)
|
|
59
|
+
deadline = time.monotonic() + self.timeout_s
|
|
60
|
+
while _state(resp) in ("PENDING", "RUNNING"):
|
|
61
|
+
if time.monotonic() > deadline:
|
|
62
|
+
try:
|
|
63
|
+
api.cancel_execution(resp.statement_id)
|
|
64
|
+
finally:
|
|
65
|
+
raise BricksentryQueryError(f"Query timed out after {self.timeout_s}s")
|
|
66
|
+
time.sleep(1.0)
|
|
67
|
+
resp = api.get_statement(resp.statement_id)
|
|
68
|
+
|
|
69
|
+
state = _state(resp)
|
|
70
|
+
if state != "SUCCEEDED":
|
|
71
|
+
err = getattr(getattr(resp, "status", None), "error", None)
|
|
72
|
+
msg = getattr(err, "message", None) or str(err) or state
|
|
73
|
+
raise BricksentryQueryError(f"Databricks query {state}: {msg}\nSQL: {sql[:500]}")
|
|
74
|
+
|
|
75
|
+
manifest = resp.manifest
|
|
76
|
+
if manifest is None or manifest.schema is None or not manifest.schema.columns:
|
|
77
|
+
return []
|
|
78
|
+
cols = manifest.schema.columns
|
|
79
|
+
names = [c.name for c in cols]
|
|
80
|
+
types = [_type_name(c) for c in cols]
|
|
81
|
+
|
|
82
|
+
rows: list[list[Any]] = []
|
|
83
|
+
chunk = resp.result
|
|
84
|
+
while chunk is not None:
|
|
85
|
+
rows.extend(chunk.data_array or [])
|
|
86
|
+
nxt = getattr(chunk, "next_chunk_index", None)
|
|
87
|
+
if nxt is None:
|
|
88
|
+
break
|
|
89
|
+
chunk = api.get_statement_result_chunk_n(resp.statement_id, nxt)
|
|
90
|
+
return [{n: _convert(v, t) for n, v, t in zip(names, row, types)} for row in rows]
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _format_kwargs() -> dict[str, Any]:
|
|
94
|
+
try:
|
|
95
|
+
from databricks.sdk.service.sql import Disposition, Format
|
|
96
|
+
|
|
97
|
+
return {"disposition": Disposition.INLINE, "format": Format.JSON_ARRAY}
|
|
98
|
+
except ImportError: # pragma: no cover - fake clients in tests
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
# --- dialect -----------------------------------------------------------
|
|
102
|
+
def quote(self, ident: str) -> str:
|
|
103
|
+
return "`" + ident.replace("`", "``") + "`"
|
|
104
|
+
|
|
105
|
+
def sample_predicate(self, keys: list[str], pct: float, alias: str | None = None) -> str:
|
|
106
|
+
pre = f"{alias}." if alias else ""
|
|
107
|
+
cols = ", ".join(f"{pre}{self.quote(k)}" for k in keys)
|
|
108
|
+
return f"(abs(xxhash64({cols})) % 10000) < {int(pct * 100)}"
|
|
109
|
+
|
|
110
|
+
def days_ago(self, days: int) -> str:
|
|
111
|
+
return f"date_sub(current_date(), {int(days)})"
|
|
112
|
+
|
|
113
|
+
# --- metadata ----------------------------------------------------------
|
|
114
|
+
def columns(self, table: str) -> dict[str, str]:
|
|
115
|
+
out: dict[str, str] = {}
|
|
116
|
+
for r in self.query(f"DESCRIBE TABLE {self.qualified(table)}"):
|
|
117
|
+
name = (r.get("col_name") or "").strip()
|
|
118
|
+
if not name or name.startswith("#"):
|
|
119
|
+
break # partition / detail sections follow
|
|
120
|
+
out[name] = str(r.get("data_type", "")).lower()
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
def clone(self, source: str, target: str) -> None:
|
|
124
|
+
self.execute(
|
|
125
|
+
f"CREATE OR REPLACE TABLE {self.qualified(target)} SHALLOW CLONE {self.qualified(source)}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def current_version(self, table: str) -> int | None:
|
|
129
|
+
try:
|
|
130
|
+
rows = self.query(f"DESCRIBE HISTORY {self.qualified(table)} LIMIT 1")
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
if not rows:
|
|
134
|
+
return None
|
|
135
|
+
v = rows[0].get("version")
|
|
136
|
+
return int(v) if v is not None else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _state(resp: Any) -> str:
|
|
140
|
+
st = getattr(getattr(resp, "status", None), "state", None)
|
|
141
|
+
return getattr(st, "value", st) or "UNKNOWN"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _type_name(col: Any) -> str:
|
|
145
|
+
t = getattr(col, "type_name", None)
|
|
146
|
+
return str(getattr(t, "value", t) or "STRING").upper()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _convert(v: Any, t: str) -> Any:
|
|
150
|
+
if v is None:
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
if t in _INT_TYPES:
|
|
154
|
+
return int(v)
|
|
155
|
+
if t in _FLOAT_TYPES:
|
|
156
|
+
return float(v)
|
|
157
|
+
if t == "BOOLEAN":
|
|
158
|
+
return str(v).lower() == "true"
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return v
|
|
161
|
+
return v
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""DuckDB backend: for local development, tests, demos and offline CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bricksentry.backends.base import Backend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DuckDBBackend(Backend):
|
|
11
|
+
name = "duckdb"
|
|
12
|
+
supports_time_travel = False
|
|
13
|
+
|
|
14
|
+
def __init__(self, database: str = ":memory:", connection: Any = None, **_: Any):
|
|
15
|
+
if connection is None:
|
|
16
|
+
try:
|
|
17
|
+
import duckdb
|
|
18
|
+
except ImportError as e: # pragma: no cover
|
|
19
|
+
raise ImportError("DuckDB backend needs `pip install bricksentry[duckdb]`") from e
|
|
20
|
+
connection = duckdb.connect(database)
|
|
21
|
+
self.con = connection
|
|
22
|
+
|
|
23
|
+
def query(self, sql: str) -> list[dict[str, Any]]:
|
|
24
|
+
cur = self.con.execute(sql)
|
|
25
|
+
if cur.description is None:
|
|
26
|
+
return []
|
|
27
|
+
cols = [d[0] for d in cur.description]
|
|
28
|
+
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
29
|
+
|
|
30
|
+
def quote(self, ident: str) -> str:
|
|
31
|
+
return '"' + ident.replace('"', '""') + '"'
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def string_type(self) -> str:
|
|
35
|
+
return "VARCHAR"
|
|
36
|
+
|
|
37
|
+
def sample_predicate(self, keys: list[str], pct: float, alias: str | None = None) -> str:
|
|
38
|
+
pre = f"{alias}." if alias else ""
|
|
39
|
+
cols = ", ".join(f"{pre}{self.quote(k)}" for k in keys)
|
|
40
|
+
return f"(hash({cols}) % 10000) < {int(pct * 100)}"
|
|
41
|
+
|
|
42
|
+
def days_ago(self, days: int) -> str:
|
|
43
|
+
return f"(current_date - INTERVAL {int(days)} DAY)"
|
|
44
|
+
|
|
45
|
+
def columns(self, table: str) -> dict[str, str]:
|
|
46
|
+
rows = self.query(f"DESCRIBE {self.qualified(table)}")
|
|
47
|
+
return {r["column_name"]: str(r["column_type"]).lower() for r in rows}
|
|
48
|
+
|
|
49
|
+
def clone(self, source: str, target: str) -> None:
|
|
50
|
+
self.execute(
|
|
51
|
+
f"CREATE OR REPLACE TABLE {self.qualified(target)} AS SELECT * FROM {self.qualified(source)}"
|
|
52
|
+
)
|
bricksentry/changes.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Work out which tables a pull request touches.
|
|
2
|
+
|
|
3
|
+
Each table in ``bricksentry.yml`` lists the repo paths that produce it
|
|
4
|
+
(notebooks, SQL files, Databricks Asset Bundle resource YAML...). A changed
|
|
5
|
+
file matching a table's globs marks that table as changed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from bricksentry.config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def changed_files(base_ref: str = "origin/main", cwd: str | None = None) -> list[str]:
|
|
17
|
+
out = subprocess.run(
|
|
18
|
+
["git", "diff", "--name-only", f"{base_ref}...HEAD"],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
cwd=cwd,
|
|
22
|
+
check=True,
|
|
23
|
+
).stdout
|
|
24
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tables_for_files(cfg: Config, files: list[str]) -> list[str]:
|
|
28
|
+
hits: list[str] = []
|
|
29
|
+
for name, spec in cfg.tables.items():
|
|
30
|
+
for pat in spec.paths:
|
|
31
|
+
if any(fnmatch.fnmatch(f, pat) for f in files):
|
|
32
|
+
hits.append(name)
|
|
33
|
+
break
|
|
34
|
+
return hits
|