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/doctor.py ADDED
@@ -0,0 +1,225 @@
1
+ """`ripple doctor`: prove this install can serve an MCP client before a client tries.
2
+
3
+ The documented MCP failure modes are environmental: the client spawns the wrong
4
+ python, the server prints something that isn't JSON-RPC, or startup blows the
5
+ client's budget (Codex kills servers at 10s). All of them look identical from
6
+ the client side: the tool "connects and then does nothing." So the doctor does
7
+ what a client does: spawns this install's server from an undefined working
8
+ directory and drives a real handshake through it.
9
+
10
+ Warnings never fail the command; only a broken handshake does (exit 1).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ PASS, WARN, FAIL = "ok", "warning", "failed"
24
+ GLYPH = {PASS: "✓", WARN: "!", FAIL: "✗"}
25
+
26
+ # Codex gives a server 10s to start; stay comfortably under it.
27
+ STARTUP_BUDGET_SECONDS = 5.0
28
+
29
+
30
+ def _version() -> str:
31
+ try:
32
+ from importlib.metadata import version
33
+
34
+ return version("ripple-sql")
35
+ except Exception:
36
+ return "unknown"
37
+
38
+
39
+ def server_command() -> list[str]:
40
+ """The command a client should be given, absolute paths only.
41
+
42
+ Clients spawn stdio servers with a minimal environment and an undefined
43
+ working directory, so a bare `ripple` that depends on the user's shell
44
+ PATH is exactly what breaks.
45
+ """
46
+ exe = shutil.which("ripple")
47
+ if exe:
48
+ return [str(Path(exe).resolve()), "mcp"]
49
+ # sys.argv[1:] forwards appended flags: without it the printed
50
+ # `... --path <project>` was decoration and the server silently served
51
+ # the client's working directory, usually /
52
+ return [
53
+ sys.executable,
54
+ "-c",
55
+ "import sys; from ripple.cli import main; main(['mcp'] + sys.argv[1:])",
56
+ ]
57
+
58
+
59
+ def _check_install() -> dict:
60
+ return {
61
+ "name": "install",
62
+ "status": PASS,
63
+ "detail": f"ripple {_version()} on python {sys.version.split()[0]} at {sys.executable}",
64
+ }
65
+
66
+
67
+ def _check_path() -> dict:
68
+ exe = shutil.which("ripple")
69
+ if exe is None:
70
+ return {
71
+ "name": "ripple on PATH",
72
+ "status": WARN,
73
+ "detail": (
74
+ "no `ripple` on PATH (fine for uvx; otherwise give your client "
75
+ "the absolute command printed below)"
76
+ ),
77
+ }
78
+ # no resolve(): a venv python is a symlink out of the venv, and following
79
+ # it would flag every healthy venv as a mismatch
80
+ same_env = Path(exe).parent == Path(sys.executable).parent
81
+ if not same_env:
82
+ return {
83
+ "name": "ripple on PATH",
84
+ "status": WARN,
85
+ "detail": (
86
+ f"{exe} belongs to a different environment than this python "
87
+ f"({sys.executable}); a client using the PATH one may get a "
88
+ "different ripple version"
89
+ ),
90
+ }
91
+ return {"name": "ripple on PATH", "status": PASS, "detail": exe}
92
+
93
+
94
+ def _check_handshake(command: list[str]) -> list[dict]:
95
+ requests = [
96
+ {
97
+ "jsonrpc": "2.0",
98
+ "id": 1,
99
+ "method": "initialize",
100
+ "params": {
101
+ "protocolVersion": "2025-06-18",
102
+ "capabilities": {},
103
+ "clientInfo": {"name": "ripple-doctor", "version": _version()},
104
+ },
105
+ },
106
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
107
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
108
+ ]
109
+ payload = "".join(json.dumps(r) + "\n" for r in requests)
110
+ started = time.monotonic()
111
+ try:
112
+ # cwd "/" reproduces the undefined working directory clients start
113
+ # servers from; initialize and tools/list must not touch the filesystem
114
+ proc = subprocess.run(
115
+ command,
116
+ input=payload,
117
+ capture_output=True,
118
+ text=True,
119
+ cwd="/",
120
+ timeout=30,
121
+ )
122
+ except subprocess.TimeoutExpired:
123
+ return [
124
+ {
125
+ "name": "mcp handshake",
126
+ "status": FAIL,
127
+ "detail": f"server did not answer within 30s: {' '.join(command)}",
128
+ }
129
+ ]
130
+ except OSError as e:
131
+ return [
132
+ {
133
+ "name": "mcp handshake",
134
+ "status": FAIL,
135
+ "detail": f"could not start {' '.join(command)}: {e}",
136
+ }
137
+ ]
138
+ elapsed = time.monotonic() - started
139
+
140
+ checks: list[dict] = []
141
+ responses = {}
142
+ dirty = None
143
+ for raw in proc.stdout.splitlines():
144
+ if not raw.strip():
145
+ continue
146
+ try:
147
+ message = json.loads(raw)
148
+ if message.get("jsonrpc") != "2.0":
149
+ raise ValueError("not a JSON-RPC message")
150
+ except ValueError:
151
+ dirty = raw
152
+ continue
153
+ responses[message.get("id")] = message
154
+
155
+ init = responses.get(1, {}).get("result", {})
156
+ tools = responses.get(2, {}).get("result", {}).get("tools", [])
157
+ if init.get("serverInfo", {}).get("name") == "ripple" and tools:
158
+ checks.append(
159
+ {
160
+ "name": "mcp handshake",
161
+ "status": PASS,
162
+ "detail": f"initialize + tools/list answered, {len(tools)} tools",
163
+ }
164
+ )
165
+ else:
166
+ checks.append(
167
+ {
168
+ "name": "mcp handshake",
169
+ "status": FAIL,
170
+ "detail": (
171
+ f"no valid handshake from {' '.join(command)}"
172
+ + (f"; stderr: {proc.stderr.strip()[:300]}" if proc.stderr.strip() else "")
173
+ ),
174
+ }
175
+ )
176
+
177
+ checks.append(
178
+ {
179
+ "name": "clean stdout",
180
+ "status": PASS if dirty is None else FAIL,
181
+ "detail": (
182
+ "every stdout line is JSON-RPC"
183
+ if dirty is None
184
+ else f"stdout carried a non-protocol line, which breaks clients: {dirty[:200]!r}"
185
+ ),
186
+ }
187
+ )
188
+ slow = elapsed > STARTUP_BUDGET_SECONDS
189
+ checks.append(
190
+ {
191
+ "name": "startup speed",
192
+ "status": WARN if slow else PASS,
193
+ "detail": f"handshake took {elapsed:.1f}s"
194
+ + (" (Codex allows 10s; investigate)" if slow else ""),
195
+ }
196
+ )
197
+ return checks
198
+
199
+
200
+ def run(as_json: bool = False) -> int:
201
+ command = server_command()
202
+ checks = [_check_install(), _check_path(), *_check_handshake(command)]
203
+ ok = all(c["status"] != FAIL for c in checks)
204
+
205
+ if as_json:
206
+ print(json.dumps({"ok": ok, "command": command, "checks": checks}, indent=2))
207
+ return 0 if ok else 1
208
+
209
+ for check in checks:
210
+ print(f"[{GLYPH[check['status']]}] {check['name']}: {check['detail']}")
211
+ print()
212
+ if ok:
213
+ project = Path(os.getcwd()).resolve()
214
+ print("This install can serve MCP clients. Point yours at it with absolute paths")
215
+ print("(clients often start servers from / with a minimal environment):")
216
+ import shlex
217
+
218
+ # shlex, not join: the no-binary fallback is python -c "import ...; ..."
219
+ # and unquoted it splits at the semicolon when pasted into a shell
220
+ printable = shlex.join([*command, "--path", str(project)])
221
+ print(f" {printable}")
222
+ print(f" e.g. claude mcp add ripple -- {printable}")
223
+ else:
224
+ print("Something above is broken. Attach `ripple doctor --json` to a bug report.")
225
+ return 0 if ok else 1
@@ -0,0 +1,111 @@
1
+ """SQL analysis engine.
2
+
3
+ Works on any SQL, dbt projects or plain files alike.
4
+
5
+ Key exports:
6
+
7
+ Column Lineage (C_con - Contributing Columns):
8
+ - extract_column_lineage_fast: Fast single-pass column extraction
9
+ - extract_column_lineage_with_ctes: Full extraction with CTE mappings
10
+ - extract_cte_column_lineage: Per-CTE column lineage
11
+ - extract_cte_column_mappings: CTE name -> column mappings
12
+ - extract_primary_source_from_macro: Primary source detection
13
+ - trace_through_ctes: Recursive CTE tracing
14
+ - extract_lineage_complete: Unified API with C_con + C_ref
15
+
16
+ Column Dependencies (C_ref - Referenced Columns):
17
+ - extract_column_dependencies: WHERE/HAVING/QUALIFY clause columns
18
+ - extract_all_referenced_columns: All column references with context
19
+
20
+ Types:
21
+ - FilterDependency: Column used in filter clauses
22
+ - JoinKeyPair: Columns used in JOIN conditions
23
+ - ColumnDependencies: Complete C_ref result
24
+ - UnifiedLineageResult: Combined C_con + C_ref
25
+
26
+ Tech Debt:
27
+ - analyze_unused_deps_safe: Find unused JOINs/CTEs safely
28
+ - get_analysis_limited_message: Human-readable limitation explanation
29
+
30
+ Dialect Mapping:
31
+ - ADAPTER_TO_SQLGLOT_DIALECT: Adapter -> SQLGlot dialect mapping
32
+ - DEFAULT_DIALECT: Default dialect (snowflake)
33
+ - get_sqlglot_dialect: Get dialect from dbt manifest
34
+ - get_dialect_from_adapter_type: Get dialect from adapter type string
35
+ - get_dialect_from_config: Get dialect from a config dict with adapter_type
36
+ - get_supported_adapters: List of supported adapter types
37
+ """
38
+
39
+ # Column lineage (main extraction)
40
+ from ripple.engine.column_lineage import (
41
+ extract_column_lineage_fast,
42
+ extract_column_lineage_with_ctes,
43
+ extract_cte_column_lineage,
44
+ extract_cte_column_mappings,
45
+ extract_lineage_complete,
46
+ extract_primary_source_from_macro,
47
+ trace_through_ctes,
48
+ )
49
+
50
+ # Column dependencies (C_ref)
51
+ from ripple.engine.dependencies import (
52
+ extract_all_referenced_columns,
53
+ extract_column_dependencies,
54
+ )
55
+
56
+ # Dialect mapping
57
+ from ripple.engine.dialect import (
58
+ ADAPTER_TO_SQLGLOT_DIALECT,
59
+ DEFAULT_DIALECT,
60
+ get_dialect_from_adapter_type,
61
+ get_dialect_from_config,
62
+ get_sqlglot_dialect,
63
+ get_supported_adapters,
64
+ )
65
+
66
+ # Tech debt analysis
67
+ from ripple.engine.tech_debt import (
68
+ analyze_unused_deps_safe,
69
+ get_analysis_limited_message,
70
+ )
71
+
72
+ # Types
73
+ from ripple.engine.types import (
74
+ ColumnDependencies,
75
+ FilterDependency,
76
+ JoinKeyPair,
77
+ TrustLevel,
78
+ UnifiedLineageResult,
79
+ WarehouseColumns,
80
+ )
81
+
82
+ __all__ = [
83
+ # Column lineage (C_con)
84
+ "extract_column_lineage_fast",
85
+ "extract_column_lineage_with_ctes",
86
+ "extract_cte_column_lineage",
87
+ "extract_cte_column_mappings",
88
+ "extract_primary_source_from_macro",
89
+ "trace_through_ctes",
90
+ "extract_lineage_complete",
91
+ # Column dependencies (C_ref) - NEW
92
+ "extract_column_dependencies",
93
+ "extract_all_referenced_columns",
94
+ # Types - NEW
95
+ "TrustLevel",
96
+ "WarehouseColumns",
97
+ "FilterDependency",
98
+ "JoinKeyPair",
99
+ "ColumnDependencies",
100
+ "UnifiedLineageResult",
101
+ # Tech debt
102
+ "analyze_unused_deps_safe",
103
+ "get_analysis_limited_message",
104
+ # Dialect
105
+ "ADAPTER_TO_SQLGLOT_DIALECT",
106
+ "DEFAULT_DIALECT",
107
+ "get_dialect_from_adapter_type",
108
+ "get_dialect_from_config",
109
+ "get_sqlglot_dialect",
110
+ "get_supported_adapters",
111
+ ]
@@ -0,0 +1,86 @@
1
+ """Wall-clock budget for per-file work that can wedge.
2
+
3
+ Pathological SQL can wedge sqlglot for half an hour: redshift-utils admin DDL
4
+ at parse time, and a generated 3.7MB single-line SELECT that parses in 13s and
5
+ then spends another 139s inside qualify_tables during analysis. One budget
6
+ covers both phases (RIPPLE_PARSE_BUDGET_S, default 20s, 0 disables).
7
+
8
+ Work runs on a daemon thread abandoned at the budget. An abandoned thread
9
+ stays busy until process exit, so after MAX_ABANDONED of them further budgeted
10
+ work is not attempted and reports timed out immediately.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import threading
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ MAX_ABANDONED = 8
21
+ _abandoned = 0 # kept for tests that pin the cap; the live count is _zombies
22
+ _zombies: list[threading.Thread] = []
23
+
24
+ TIMED_OUT = object()
25
+
26
+
27
+ def _alive_zombies() -> int:
28
+ """Abandoned workers still burning CPU. A cumulative count never reset,
29
+ so a long-lived MCP session that had ever abandoned eight parses refused
30
+ every budgeted call after that, and each rebuild reported the whole repo
31
+ as timed_out."""
32
+ _zombies[:] = [t for t in _zombies if t.is_alive()]
33
+ return len(_zombies)
34
+
35
+
36
+ def budget_seconds() -> float:
37
+ try:
38
+ return float(os.environ.get("RIPPLE_PARSE_BUDGET_S", "20"))
39
+ except ValueError:
40
+ return 20.0
41
+
42
+
43
+ def max_sql_bytes() -> int:
44
+ """Statements over this size are refused before any parse or analysis.
45
+
46
+ Preferable to the thread budget wherever it applies: an abandoned worker
47
+ keeps holding the GIL, slowing everything after it, while a size refusal
48
+ costs nothing. 0 disables."""
49
+ try:
50
+ return int(os.environ.get("RIPPLE_MAX_SQL_BYTES", "1000000"))
51
+ except ValueError:
52
+ return 1_000_000
53
+
54
+
55
+ def run_within_budget(label: str, fn: Callable[[], Any], budget: float | None = None):
56
+ """fn() under a wall-clock cap; returns TIMED_OUT when the budget blows.
57
+
58
+ fn must not mutate shared state: an abandoned worker runs to completion in
59
+ the background, and anything it wrote would race the caller.
60
+ """
61
+ global _abandoned
62
+ if budget is None:
63
+ budget = budget_seconds()
64
+ if budget <= 0:
65
+ return fn()
66
+ if max(_abandoned, _alive_zombies()) >= MAX_ABANDONED:
67
+ return TIMED_OUT
68
+ box: list = []
69
+
70
+ def run():
71
+ try:
72
+ box.append(fn())
73
+ except Exception as e:
74
+ box.append(e)
75
+
76
+ worker = threading.Thread(target=run, daemon=True, name=f"ripple-budget-{label}")
77
+ worker.start()
78
+ worker.join(budget)
79
+ if worker.is_alive():
80
+ _zombies.append(worker)
81
+ _abandoned = 0 # the live list is the count; _abandoned only pins tests
82
+ return TIMED_OUT
83
+ result = box[0] if box else None
84
+ if isinstance(result, Exception):
85
+ raise result
86
+ return result
@@ -0,0 +1,112 @@
1
+ """Column lineage: re-exports from the engine submodules.
2
+
3
+ Re-exports the engine's public functions from one place. Prefer importing from
4
+ the specific submodule.
5
+
6
+ Submodules:
7
+ - types.py: Type definitions and dataclasses
8
+ - schema_qualification.py: Warehouse schema disambiguation
9
+ - validation.py: Trust scoring and UNION validation
10
+ - cte_tracing.py: CTE extraction and recursive tracing
11
+ - extraction.py: Main extraction entry points
12
+ - dependencies.py: WHERE/JOIN column tracking (C_ref)
13
+
14
+ Example:
15
+ from ripple.engine.extraction import extract_column_lineage_fast
16
+ from ripple.engine.dependencies import extract_column_dependencies
17
+ """
18
+
19
+ # Types (from types.py)
20
+ # CTE tracing (from cte_tracing.py)
21
+ from ripple.engine.cte_tracing import (
22
+ extract_cte_column_lineage,
23
+ extract_cte_column_mappings,
24
+ trace_through_ctes,
25
+ )
26
+
27
+ # Dependencies (from dependencies.py)
28
+ from ripple.engine.dependencies import (
29
+ extract_all_referenced_columns,
30
+ extract_column_dependencies,
31
+ )
32
+
33
+ # Main extraction (from extraction.py)
34
+ from ripple.engine.extraction import (
35
+ extract_column_lineage_fast,
36
+ extract_column_lineage_with_ctes,
37
+ extract_lineage_complete,
38
+ extract_primary_source_from_macro,
39
+ )
40
+
41
+ # Schema qualification (from schema_qualification.py)
42
+ from ripple.engine.schema_qualification import (
43
+ # Underscore-prefixed aliases
44
+ _build_sqlglot_schema,
45
+ _qualify_sql_with_schema,
46
+ _resolve_column_table,
47
+ build_sqlglot_schema,
48
+ qualify_sql_with_schema,
49
+ resolve_column_table,
50
+ )
51
+ from ripple.engine.types import (
52
+ MAX_REAL_TABLES,
53
+ MAX_SQL_LENGTH,
54
+ MAX_WAREHOUSE_TABLES,
55
+ ColumnDependencies,
56
+ FilterDependency,
57
+ JoinKeyPair,
58
+ TrustLevel,
59
+ UnifiedLineageResult,
60
+ WarehouseColumns,
61
+ )
62
+
63
+ # Validation (from validation.py)
64
+ from ripple.engine.validation import (
65
+ _get_select_column_names,
66
+ # Underscore-prefixed aliases
67
+ _get_trust_level,
68
+ _validate_union_columns,
69
+ get_select_column_names,
70
+ get_trust_level,
71
+ validate_union_columns,
72
+ )
73
+
74
+ __all__ = [
75
+ # Main extraction functions
76
+ "extract_column_lineage_fast",
77
+ "extract_column_lineage_with_ctes",
78
+ "extract_lineage_complete",
79
+ # CTE functions
80
+ "extract_cte_column_lineage",
81
+ "extract_cte_column_mappings",
82
+ "extract_primary_source_from_macro",
83
+ "trace_through_ctes",
84
+ # Dependency extraction
85
+ "extract_column_dependencies",
86
+ "extract_all_referenced_columns",
87
+ # Types
88
+ "TrustLevel",
89
+ "WarehouseColumns",
90
+ "FilterDependency",
91
+ "JoinKeyPair",
92
+ "ColumnDependencies",
93
+ "UnifiedLineageResult",
94
+ # Constants
95
+ "MAX_WAREHOUSE_TABLES",
96
+ "MAX_REAL_TABLES",
97
+ "MAX_SQL_LENGTH",
98
+ # Public helpers
99
+ "build_sqlglot_schema",
100
+ "resolve_column_table",
101
+ "qualify_sql_with_schema",
102
+ "get_trust_level",
103
+ "get_select_column_names",
104
+ "validate_union_columns",
105
+ # Underscore-prefixed aliases
106
+ "_build_sqlglot_schema",
107
+ "_resolve_column_table",
108
+ "_qualify_sql_with_schema",
109
+ "_get_trust_level",
110
+ "_validate_union_columns",
111
+ "_get_select_column_names",
112
+ ]