ripple-sql 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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
ripple/answer_page.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The answer page: one self-contained HTML file per question.
|
|
2
|
+
|
|
3
|
+
The same component the public demo uses, pointed at the user's own repo.
|
|
4
|
+
It reads the answer contract and the terminal's text twin, and when the
|
|
5
|
+
graph export fits the size budget it embeds the whole graph so the page
|
|
6
|
+
can answer a follow-up question by itself, with no server behind it.
|
|
7
|
+
|
|
8
|
+
Nothing here talks to the network. The page loads no fonts, scripts, or
|
|
9
|
+
images from anywhere; it is a file. `ripple serve` renders the same template
|
|
10
|
+
live (render_live), where the question box asks the local server instead.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import webbrowser
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
TEMPLATE = Path(__file__).parent / "static" / "answer.html"
|
|
24
|
+
STYLE = Path(__file__).parent / "static" / "answer.css"
|
|
25
|
+
TWIN = Path(__file__).parent / "static" / "answer_twin.js"
|
|
26
|
+
EXPLORE = Path(__file__).parent / "static" / "explore.js"
|
|
27
|
+
EMBED_BUDGET_ENV = "RIPPLE_EMBED_GRAPH_BYTES"
|
|
28
|
+
DEFAULT_EMBED_BUDGET = 5_000_000
|
|
29
|
+
TRUST_CODE = {"verified": "v", "high_confidence": "h", "moderate": "m", "review_required": "r"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def embed_budget() -> int:
|
|
33
|
+
"""Bytes of graph export the page will carry so follow-ups work offline.
|
|
34
|
+
Above it the page carries the answer alone and says so. 0 disables."""
|
|
35
|
+
try:
|
|
36
|
+
return int(os.environ.get(EMBED_BUDGET_ENV, str(DEFAULT_EMBED_BUDGET)))
|
|
37
|
+
except ValueError:
|
|
38
|
+
return DEFAULT_EMBED_BUDGET
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compact_edges(edges) -> dict:
|
|
42
|
+
"""The graph as the page carries it: {"edges": rows, "reasons": strings}.
|
|
43
|
+
|
|
44
|
+
A row is [src, dst, trust code], then the kind when the link is not a
|
|
45
|
+
value link, then the index of its reason when it has one (the kind slot
|
|
46
|
+
holds "" in that case). Each distinct reason travels once."""
|
|
47
|
+
reasons: dict[str, int] = {}
|
|
48
|
+
rows = []
|
|
49
|
+
for e in edges:
|
|
50
|
+
row = [
|
|
51
|
+
f"{e.src_model}.{e.src_column}",
|
|
52
|
+
f"{e.dst_model}.{e.dst_column}",
|
|
53
|
+
TRUST_CODE.get(e.trust, "r"),
|
|
54
|
+
]
|
|
55
|
+
kind = "" if e.kind == "value" else e.kind
|
|
56
|
+
reason = getattr(e, "reason", "") or ""
|
|
57
|
+
if reason:
|
|
58
|
+
row += [kind, reasons.setdefault(reason, len(reasons))]
|
|
59
|
+
elif kind:
|
|
60
|
+
row.append(kind)
|
|
61
|
+
rows.append(row)
|
|
62
|
+
return {"edges": rows, "reasons": list(reasons)}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compact_names(graph) -> dict[str, list[str]]:
|
|
66
|
+
"""The spellings the command accepts, so the page resolves a typed name
|
|
67
|
+
the same way."""
|
|
68
|
+
from ripple.names import askable_names
|
|
69
|
+
|
|
70
|
+
return askable_names(graph)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def graph_export(graph) -> dict:
|
|
74
|
+
"""The whole graph as the page carries it: links, the reasons behind
|
|
75
|
+
them, the spellings that name a model, and the columns no link touches."""
|
|
76
|
+
from ripple.names import unlinked_columns
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
**compact_edges(graph.edges),
|
|
80
|
+
"names": compact_names(graph),
|
|
81
|
+
"columns": unlinked_columns(graph),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ripple_version() -> str:
|
|
86
|
+
try:
|
|
87
|
+
return version("ripple-sql")
|
|
88
|
+
except PackageNotFoundError:
|
|
89
|
+
return "dev"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def title_for(answer: dict) -> str:
|
|
93
|
+
if answer["kind"] == "trace":
|
|
94
|
+
return f"Where does {answer['target']} come from?"
|
|
95
|
+
return f"What breaks if {answer['target']} changes?"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render(
|
|
99
|
+
answer: dict,
|
|
100
|
+
text: str,
|
|
101
|
+
project: dict | None = None,
|
|
102
|
+
edges: dict | None = None,
|
|
103
|
+
views: list[dict] | None = None,
|
|
104
|
+
change: dict | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""The page as a string. `edges` is graph_export() of the whole graph.
|
|
107
|
+
`views` carries one {answer, text} per changed column for a change page,
|
|
108
|
+
with `change` holding the rebuild list and counts."""
|
|
109
|
+
graph = None
|
|
110
|
+
note = ""
|
|
111
|
+
if edges is not None:
|
|
112
|
+
payload = json.dumps(edges, separators=(",", ":"))
|
|
113
|
+
budget = embed_budget()
|
|
114
|
+
if budget and len(payload) <= budget:
|
|
115
|
+
graph = edges
|
|
116
|
+
else:
|
|
117
|
+
note = (
|
|
118
|
+
f"The graph export is {len(payload) / 1e6:.1f} MB, over this page's "
|
|
119
|
+
f"{budget / 1e6:.0f} MB budget, so follow-up questions need the command line."
|
|
120
|
+
)
|
|
121
|
+
data = {
|
|
122
|
+
"answer": answer,
|
|
123
|
+
"text": text,
|
|
124
|
+
"views": views or [],
|
|
125
|
+
"change": change,
|
|
126
|
+
"project": project or {},
|
|
127
|
+
"graph": graph,
|
|
128
|
+
"note": note,
|
|
129
|
+
}
|
|
130
|
+
title = "What breaks in this change?" if change else title_for(answer)
|
|
131
|
+
return _page(data, title)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_live(project: dict, live: dict) -> str:
|
|
135
|
+
"""The page `ripple serve` opens on: no answer yet, a question box that
|
|
136
|
+
asks the local server, the starters, and the coverage ladder. `live`
|
|
137
|
+
carries api, edges, review, ladder rows, starters, and column ids."""
|
|
138
|
+
data = {
|
|
139
|
+
"answer": None,
|
|
140
|
+
"text": "",
|
|
141
|
+
"views": [],
|
|
142
|
+
"change": None,
|
|
143
|
+
"project": project,
|
|
144
|
+
"graph": None,
|
|
145
|
+
"note": "",
|
|
146
|
+
"live": live,
|
|
147
|
+
}
|
|
148
|
+
return _page(data, f"Ripple: {project.get('root', 'this project')}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _page(data: dict, title: str) -> str:
|
|
152
|
+
data = {
|
|
153
|
+
**data,
|
|
154
|
+
"generated": _dt.datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
155
|
+
"version": _ripple_version(),
|
|
156
|
+
}
|
|
157
|
+
# a column or reason could contain "</", which would end the data block early
|
|
158
|
+
blob = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
|
|
159
|
+
html = (
|
|
160
|
+
TEMPLATE.read_text(encoding="utf-8")
|
|
161
|
+
.replace("/*CSS*/", STYLE.read_text(encoding="utf-8"))
|
|
162
|
+
.replace("/*TWIN*/", TWIN.read_text(encoding="utf-8"))
|
|
163
|
+
.replace("/*EXPLORE*/", EXPLORE.read_text(encoding="utf-8"))
|
|
164
|
+
)
|
|
165
|
+
return html.replace("__TITLE__", _escape(title)).replace("__DATA__", blob)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def default_path(root: Path, answer: dict) -> Path:
|
|
169
|
+
slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", answer["target"])
|
|
170
|
+
return root / ".ripple" / "answers" / f"{answer['kind']}-{slug}.html"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write(
|
|
174
|
+
answer: dict,
|
|
175
|
+
text: str,
|
|
176
|
+
path: Path | None,
|
|
177
|
+
root: Path,
|
|
178
|
+
project: dict | None = None,
|
|
179
|
+
edges: dict | None = None,
|
|
180
|
+
) -> Path:
|
|
181
|
+
target = path or default_path(root, answer)
|
|
182
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
target.write_text(render(answer, text, project, edges), encoding="utf-8")
|
|
184
|
+
return target
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def project_info(project, root: Path) -> dict:
|
|
188
|
+
"""What the page header says about the project it was made from."""
|
|
189
|
+
return {
|
|
190
|
+
"root": Path(root).name,
|
|
191
|
+
"mode": project.mode,
|
|
192
|
+
"dialect": project.dialect,
|
|
193
|
+
"models": len(project.models),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_for(graph, root: Path, answer: dict, text_full: str, path: Path | None = None) -> Path:
|
|
198
|
+
"""The page for one answer, with the whole graph embedded for follow-ups.
|
|
199
|
+
Under .ripple/answers unless a path is given."""
|
|
200
|
+
info = project_info(graph.project, root)
|
|
201
|
+
return write(answer, text_full, path, root, info, graph_export(graph))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def open_in_browser(path: Path) -> bool:
|
|
205
|
+
"""True when a browser took the file. False in a sandbox or over SSH,
|
|
206
|
+
where the caller prints the path instead."""
|
|
207
|
+
try:
|
|
208
|
+
return bool(webbrowser.open(path.resolve().as_uri()))
|
|
209
|
+
except Exception:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _escape(text: str) -> str:
|
|
214
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
ripple/cache.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""On-disk graph cache.
|
|
2
|
+
|
|
3
|
+
A 2,000-model estate takes ~40s to analyze; asking three questions should
|
|
4
|
+
not cost three builds. The cache key is a content fingerprint (path, size,
|
|
5
|
+
and mtime of every file sourcefiles.py names) plus the ripple and sqlglot
|
|
6
|
+
versions and the dialect, so any change anywhere invalidates it. Cache lives
|
|
7
|
+
under the user cache dir, never inside the project.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import pickle
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ripple.sourcefiles import stamp
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cache_dir() -> Path:
|
|
25
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
26
|
+
return base / "ripple"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fingerprint(root: Path, dialect: str | None) -> str:
|
|
30
|
+
import sqlglot
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from ripple import __version__
|
|
34
|
+
except Exception:
|
|
35
|
+
__version__ = "0"
|
|
36
|
+
digest = hashlib.sha256()
|
|
37
|
+
digest.update(f"{__version__}|{sqlglot.__version__}|{dialect}|{root}".encode())
|
|
38
|
+
# hash ripple's own source, so editing the engine (or upgrading in place
|
|
39
|
+
# without a version bump) invalidates the cache instead of serving stale
|
|
40
|
+
# lineage. Cheap: a few dozen stats.
|
|
41
|
+
try:
|
|
42
|
+
pkg = Path(__file__).parent
|
|
43
|
+
for src in sorted(pkg.rglob("*.py")):
|
|
44
|
+
digest.update(f"{src.name}|{src.stat().st_mtime_ns}".encode())
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
digest.update(stamp(root).encode())
|
|
48
|
+
return digest.hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load(root: Path, dialect: str | None):
|
|
52
|
+
path = _cache_dir() / f"graph-{_fingerprint(root, dialect)}.pickle"
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
return pickle.load(f)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.debug("cache read failed (%s); rebuilding", e)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def store(graph, root: Path, dialect: str | None) -> None:
|
|
64
|
+
path = _cache_dir() / f"graph-{_fingerprint(root, dialect)}.pickle"
|
|
65
|
+
# write-then-rename: a crash mid-write must not leave a corrupt file at
|
|
66
|
+
# the live path, where the matching fingerprint would make every later
|
|
67
|
+
# session fail the load and silently pay the full rebuild
|
|
68
|
+
tmp = path.with_suffix(f".tmp-{os.getpid()}")
|
|
69
|
+
try:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
graph._jinja_env = None # not picklable, only needed during build
|
|
72
|
+
with open(tmp, "wb") as f:
|
|
73
|
+
pickle.dump(graph, f)
|
|
74
|
+
os.replace(tmp, path)
|
|
75
|
+
except BaseException as e:
|
|
76
|
+
with contextlib.suppress(OSError):
|
|
77
|
+
tmp.unlink(missing_ok=True)
|
|
78
|
+
if not isinstance(e, Exception):
|
|
79
|
+
raise # KeyboardInterrupt/SystemExit propagate after cleanup
|
|
80
|
+
logger.debug("cache write failed (%s); continuing without", e)
|