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.
Files changed (72) hide show
  1. ripple/__init__.py +31 -0
  2. ripple/answer.py +473 -0
  3. ripple/answer_page.py +214 -0
  4. ripple/cache.py +80 -0
  5. ripple/ci.py +422 -0
  6. ripple/ci_signature.py +374 -0
  7. ripple/cli.py +733 -0
  8. ripple/doctor.py +225 -0
  9. ripple/engine/__init__.py +111 -0
  10. ripple/engine/budget.py +86 -0
  11. ripple/engine/column_lineage.py +112 -0
  12. ripple/engine/column_ref.py +818 -0
  13. ripple/engine/cte_tracing.py +1309 -0
  14. ripple/engine/dependencies.py +466 -0
  15. ripple/engine/dialect.py +132 -0
  16. ripple/engine/dispatch.py +12 -0
  17. ripple/engine/extraction.py +27 -0
  18. ripple/engine/jinja.py +282 -0
  19. ripple/engine/json_sources.py +241 -0
  20. ripple/engine/macro_source.py +127 -0
  21. ripple/engine/pipeline.py +265 -0
  22. ripple/engine/preprocess.py +174 -0
  23. ripple/engine/safe_gen.py +21 -0
  24. ripple/engine/schema_qualification.py +151 -0
  25. ripple/engine/scope.py +488 -0
  26. ripple/engine/select_sources.py +1038 -0
  27. ripple/engine/sql_script.py +729 -0
  28. ripple/engine/statement.py +449 -0
  29. ripple/engine/tech_debt.py +169 -0
  30. ripple/engine/tsql_catalog.py +83 -0
  31. ripple/engine/tsql_scalar_vars.py +248 -0
  32. ripple/engine/tsql_tvf.py +653 -0
  33. ripple/engine/tsql_xml.py +97 -0
  34. ripple/engine/types.py +167 -0
  35. ripple/engine/unused_deps.py +555 -0
  36. ripple/engine/validation.py +158 -0
  37. ripple/graph.py +1499 -0
  38. ripple/home.py +232 -0
  39. ripple/loaders/__init__.py +7 -0
  40. ripple/loaders/dbt.py +359 -0
  41. ripple/loaders/dbt_config.py +339 -0
  42. ripple/loaders/identity.py +328 -0
  43. ripple/loaders/sidecar.py +65 -0
  44. ripple/loaders/sqldir.py +262 -0
  45. ripple/loaders/types.py +197 -0
  46. ripple/lookml.py +163 -0
  47. ripple/mcp_server.py +600 -0
  48. ripple/names.py +40 -0
  49. ripple/project.py +167 -0
  50. ripple/py.typed +0 -0
  51. ripple/render.py +426 -0
  52. ripple/render_shims.py +209 -0
  53. ripple/schemas.py +155 -0
  54. ripple/semantic.py +232 -0
  55. ripple/server.py +184 -0
  56. ripple/sourcefiles.py +64 -0
  57. ripple/star_resolution.py +100 -0
  58. ripple/static/answer.css +146 -0
  59. ripple/static/answer.html +358 -0
  60. ripple/static/answer_twin.js +299 -0
  61. ripple/static/explore.js +133 -0
  62. ripple/usage/__init__.py +18 -0
  63. ripple/usage/cli.py +78 -0
  64. ripple/usage/collect.py +315 -0
  65. ripple/usage/discover.py +190 -0
  66. ripple/usage/ingest.py +414 -0
  67. ripple/usage/report.py +131 -0
  68. ripple_sql-0.1.0.dist-info/METADATA +285 -0
  69. ripple_sql-0.1.0.dist-info/RECORD +72 -0
  70. ripple_sql-0.1.0.dist-info/WHEEL +4 -0
  71. ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
  72. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)