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.
@@ -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,3 @@
1
+ from bricksentry.backends.base import Backend, make_backend
2
+
3
+ __all__ = ["Backend", "make_backend"]
@@ -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