dataform-context-mcp 0.5.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.
- dataform_context_mcp/__init__.py +3 -0
- dataform_context_mcp/cli.py +229 -0
- dataform_context_mcp/compile.py +103 -0
- dataform_context_mcp/db.py +405 -0
- dataform_context_mcp/golden.py +111 -0
- dataform_context_mcp/indexer.py +58 -0
- dataform_context_mcp/layers.py +25 -0
- dataform_context_mcp/lineage.py +188 -0
- dataform_context_mcp/model.py +42 -0
- dataform_context_mcp/server.py +360 -0
- dataform_context_mcp/staleness.py +42 -0
- dataform_context_mcp-0.5.0.dist-info/METADATA +408 -0
- dataform_context_mcp-0.5.0.dist-info/RECORD +16 -0
- dataform_context_mcp-0.5.0.dist-info/WHEEL +4 -0
- dataform_context_mcp-0.5.0.dist-info/entry_points.txt +3 -0
- dataform_context_mcp-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Command-line entry point: index | report | serve | validate-golden."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .compile import load_graph
|
|
12
|
+
from .db import (
|
|
13
|
+
AmbiguousError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
get_action,
|
|
16
|
+
get_meta,
|
|
17
|
+
open_db,
|
|
18
|
+
rebuild,
|
|
19
|
+
resolve,
|
|
20
|
+
traverse,
|
|
21
|
+
)
|
|
22
|
+
from .golden import GOLDEN_RELPATH, locate_golden, validate_entries
|
|
23
|
+
from .indexer import default_db_path, ensure_fresh
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="dataform-context",
|
|
29
|
+
description="Deterministic Dataform pipeline context: indexing CLI and MCP server.",
|
|
30
|
+
)
|
|
31
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
32
|
+
|
|
33
|
+
p_index = subparsers.add_parser(
|
|
34
|
+
"index", help="Compile the Dataform repo and (re)build the context index"
|
|
35
|
+
)
|
|
36
|
+
p_index.add_argument(
|
|
37
|
+
"--repo",
|
|
38
|
+
type=Path,
|
|
39
|
+
default=Path("."),
|
|
40
|
+
help="Path to the Dataform repository (default: current directory)",
|
|
41
|
+
)
|
|
42
|
+
p_index.add_argument("--db", type=Path, help="Index database path (default: cache dir)")
|
|
43
|
+
p_index.add_argument(
|
|
44
|
+
"--from-json",
|
|
45
|
+
dest="from_json",
|
|
46
|
+
type=Path,
|
|
47
|
+
help="Load a captured `dataform compile --json` output instead of compiling (tests)",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
p_report = subparsers.add_parser("report", help="Print a summary of the indexed graph")
|
|
51
|
+
p_report.add_argument(
|
|
52
|
+
"--repo",
|
|
53
|
+
type=Path,
|
|
54
|
+
default=Path("."),
|
|
55
|
+
help="Path to the Dataform repository (default: current directory)",
|
|
56
|
+
)
|
|
57
|
+
p_report.add_argument("--db", type=Path, help="Index database path (default: cache dir)")
|
|
58
|
+
p_report.add_argument("--table", help="Print the full context of one table as JSON")
|
|
59
|
+
|
|
60
|
+
p_serve = subparsers.add_parser("serve", help="Run the MCP server (stdio)")
|
|
61
|
+
p_serve.add_argument(
|
|
62
|
+
"--repo",
|
|
63
|
+
type=Path,
|
|
64
|
+
default=Path("."),
|
|
65
|
+
help="Path to the Dataform repository (default: current directory — MCP clients "
|
|
66
|
+
"launch project servers from the project root)",
|
|
67
|
+
)
|
|
68
|
+
p_serve.add_argument("--db", type=Path)
|
|
69
|
+
|
|
70
|
+
p_golden = subparsers.add_parser(
|
|
71
|
+
"validate-golden",
|
|
72
|
+
help="Validate column lineage against a golden file "
|
|
73
|
+
"(JSON list of {table, column, direction, depth, expected_edges, expect_complete})",
|
|
74
|
+
)
|
|
75
|
+
p_golden.add_argument("--repo", type=Path, default=Path("."))
|
|
76
|
+
p_golden.add_argument("--db", type=Path)
|
|
77
|
+
p_golden.add_argument(
|
|
78
|
+
"--golden",
|
|
79
|
+
type=Path,
|
|
80
|
+
help="Golden file (default: <repo>/.dataform-context/golden_columns.json)",
|
|
81
|
+
)
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_db(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Path:
|
|
86
|
+
if args.db:
|
|
87
|
+
return args.db
|
|
88
|
+
if args.repo:
|
|
89
|
+
return default_db_path(args.repo)
|
|
90
|
+
parser.error(f"{args.command}: --repo or --db is required")
|
|
91
|
+
raise AssertionError # unreachable
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_index(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
95
|
+
db_path = _resolve_db(args, parser)
|
|
96
|
+
started = time.perf_counter()
|
|
97
|
+
if args.from_json:
|
|
98
|
+
graph = load_graph(json.loads(args.from_json.read_text()))
|
|
99
|
+
conn = open_db(db_path)
|
|
100
|
+
rebuild(conn, graph, source_hash="manual")
|
|
101
|
+
meta = get_meta(conn)
|
|
102
|
+
counts = meta["counts"]
|
|
103
|
+
else:
|
|
104
|
+
if not args.repo:
|
|
105
|
+
parser.error("index: --repo is required unless --from-json is given")
|
|
106
|
+
conn, index_meta = ensure_fresh(args.repo, db_path)
|
|
107
|
+
if index_meta["compile_status"] == "error":
|
|
108
|
+
print(f"compile error:\n{index_meta['compile_error']}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
counts = index_meta["counts"]
|
|
111
|
+
elapsed = time.perf_counter() - started
|
|
112
|
+
print(f"db: {db_path}")
|
|
113
|
+
print(f"actions: {counts['actions']}")
|
|
114
|
+
print(f"table_edges: {counts['table_edges']}")
|
|
115
|
+
print(f"compile_seconds: {elapsed:.1f}")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_report(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
120
|
+
db_path = _resolve_db(args, parser)
|
|
121
|
+
if not Path(db_path).exists():
|
|
122
|
+
print(f"no index at {db_path} — run `dataform-context index` first", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
conn = open_db(db_path)
|
|
125
|
+
if args.table:
|
|
126
|
+
try:
|
|
127
|
+
action_id = resolve(conn, args.table)
|
|
128
|
+
except NotFoundError as err:
|
|
129
|
+
print(f"'{args.table}' not found. Suggestions: {err.suggestions}", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
except AmbiguousError as err:
|
|
132
|
+
print(f"'{args.table}' is ambiguous. Candidates: {err.candidates}", file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
action = get_action(conn, action_id)
|
|
135
|
+
detail = {
|
|
136
|
+
"canonical": action["canonical"],
|
|
137
|
+
"type": action["action_type"],
|
|
138
|
+
"layer": action["layer"],
|
|
139
|
+
"file": action["file_name"],
|
|
140
|
+
"description": action["description"],
|
|
141
|
+
"tags": action["tags"],
|
|
142
|
+
"columns": action["columns"],
|
|
143
|
+
"upstream": [
|
|
144
|
+
n["canonical"] for lvl in traverse(conn, action_id, "up", 1) for n in lvl["nodes"]
|
|
145
|
+
],
|
|
146
|
+
"downstream": [
|
|
147
|
+
n["canonical"] for lvl in traverse(conn, action_id, "down", 1) for n in lvl["nodes"]
|
|
148
|
+
],
|
|
149
|
+
}
|
|
150
|
+
print(json.dumps(detail, indent=2))
|
|
151
|
+
return 0
|
|
152
|
+
meta = get_meta(conn)
|
|
153
|
+
print(f"indexed_at: {meta.get('indexed_at', '?')}")
|
|
154
|
+
print(f"actions: {meta['counts']['actions']}")
|
|
155
|
+
print(f"table_edges: {meta['counts']['table_edges']}")
|
|
156
|
+
print("by layer:")
|
|
157
|
+
for layer, count in conn.execute(
|
|
158
|
+
"SELECT COALESCE(layer, '(none)'), COUNT(*) FROM actions GROUP BY layer ORDER BY layer"
|
|
159
|
+
):
|
|
160
|
+
print(f" {layer}: {count}")
|
|
161
|
+
extraction = meta.get("column_extraction", {})
|
|
162
|
+
if extraction:
|
|
163
|
+
analyzable = sum(count for status, count in extraction.items() if status != "source")
|
|
164
|
+
pct_ok = 100 * extraction.get("ok", 0) / analyzable if analyzable else 0.0
|
|
165
|
+
print("column extraction:")
|
|
166
|
+
for status in sorted(extraction):
|
|
167
|
+
print(f" {status}: {extraction[status]}")
|
|
168
|
+
print(f" pct_ok (hors source): {pct_ok:.0f}%")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_validate_golden(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
|
173
|
+
db_path = _resolve_db(args, parser)
|
|
174
|
+
if not Path(db_path).exists():
|
|
175
|
+
print(f"no index at {db_path} — run `dataform-context index` first", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
misplaced = None
|
|
178
|
+
if args.golden:
|
|
179
|
+
golden_path = args.golden
|
|
180
|
+
elif args.repo:
|
|
181
|
+
golden_path, misplaced = locate_golden(args.repo)
|
|
182
|
+
else:
|
|
183
|
+
golden_path = None
|
|
184
|
+
if golden_path is None or not Path(golden_path).exists():
|
|
185
|
+
print(f"golden file not found: {golden_path}", file=sys.stderr)
|
|
186
|
+
if misplaced:
|
|
187
|
+
print(
|
|
188
|
+
f"found one at {misplaced} instead — move it to "
|
|
189
|
+
f"{GOLDEN_RELPATH.as_posix()}, or pass --golden {misplaced}",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
return 2
|
|
193
|
+
entries = json.loads(Path(golden_path).read_text())
|
|
194
|
+
conn = open_db(db_path)
|
|
195
|
+
summary = validate_entries(conn, entries)
|
|
196
|
+
for result in summary["results"]:
|
|
197
|
+
if result["ok"]:
|
|
198
|
+
print(
|
|
199
|
+
f"PASS {result['label']} ({result['edges']} edges, complete={result['complete']})"
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
print(f"FAIL {result['label']}: " + " | ".join(result["problems"]))
|
|
203
|
+
print(f"{summary['passed']}/{summary['total']} golden entries passed")
|
|
204
|
+
return 0 if summary["passed"] == summary["total"] else 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main(argv: list[str] | None = None) -> int:
|
|
208
|
+
parser = build_parser()
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
if args.command is None:
|
|
211
|
+
parser.print_help()
|
|
212
|
+
return 1
|
|
213
|
+
if args.command == "index":
|
|
214
|
+
return cmd_index(args, parser)
|
|
215
|
+
if args.command == "report":
|
|
216
|
+
return cmd_report(args, parser)
|
|
217
|
+
if args.command == "serve":
|
|
218
|
+
from .server import create_server
|
|
219
|
+
|
|
220
|
+
create_server(args.repo, args.db).run("stdio")
|
|
221
|
+
return 0
|
|
222
|
+
if args.command == "validate-golden":
|
|
223
|
+
return cmd_validate_golden(args, parser)
|
|
224
|
+
print(f"{args.command}: not implemented", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
sys.exit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Run `dataform compile --json` and load the CompiledGraph into the typed model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .model import Action, ColumnDoc, CompiledGraphData, Target
|
|
10
|
+
|
|
11
|
+
_STDERR_CAP = 4000
|
|
12
|
+
|
|
13
|
+
# enumType of entries in the `tables` array; anything unexpected degrades to "table".
|
|
14
|
+
_ENUM_TYPES = {"TABLE": "table", "VIEW": "view", "INCREMENTAL": "incremental"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CompileError(Exception):
|
|
18
|
+
def __init__(self, message: str, exit_code: int | None = None, stderr: str = ""):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.exit_code = exit_code
|
|
21
|
+
self.stderr = stderr
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_dataform_compile(repo: Path, timeout: float = 120.0) -> dict:
|
|
25
|
+
repo = Path(repo)
|
|
26
|
+
if not repo.is_dir():
|
|
27
|
+
raise CompileError(f"repo not found: {repo}", stderr=f"not a directory: {repo}")
|
|
28
|
+
try:
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["dataform", "compile", "--json"],
|
|
31
|
+
cwd=repo,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
)
|
|
36
|
+
except FileNotFoundError as err:
|
|
37
|
+
raise CompileError("dataform CLI not found on PATH", stderr=str(err)) from err
|
|
38
|
+
except subprocess.TimeoutExpired as err:
|
|
39
|
+
raise CompileError(f"dataform compile timed out after {timeout}s", stderr=str(err)) from err
|
|
40
|
+
if proc.returncode != 0:
|
|
41
|
+
raise CompileError(
|
|
42
|
+
f"dataform compile failed (exit {proc.returncode})",
|
|
43
|
+
exit_code=proc.returncode,
|
|
44
|
+
stderr=(proc.stderr or proc.stdout)[-_STDERR_CAP:],
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
raw = json.loads(proc.stdout)
|
|
48
|
+
except json.JSONDecodeError as err:
|
|
49
|
+
raise CompileError("dataform compile produced invalid JSON", stderr=str(err)) from err
|
|
50
|
+
compilation_errors = (raw.get("graphErrors") or {}).get("compilationErrors") or []
|
|
51
|
+
if compilation_errors:
|
|
52
|
+
raise CompileError(
|
|
53
|
+
f"dataform compilation errors ({len(compilation_errors)})",
|
|
54
|
+
stderr=json.dumps(compilation_errors)[:_STDERR_CAP],
|
|
55
|
+
)
|
|
56
|
+
return raw
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_graph(raw: dict) -> CompiledGraphData:
|
|
60
|
+
actions: list[Action] = []
|
|
61
|
+
for entry in raw.get("tables") or []:
|
|
62
|
+
actions.append(_load_action(entry, _ENUM_TYPES.get(entry.get("enumType", ""), "table")))
|
|
63
|
+
for entry in raw.get("declarations") or []:
|
|
64
|
+
actions.append(_load_action(entry, "declaration"))
|
|
65
|
+
for entry in raw.get("operations") or []:
|
|
66
|
+
actions.append(_load_action(entry, "operations"))
|
|
67
|
+
for entry in raw.get("assertions") or []:
|
|
68
|
+
actions.append(_load_action(entry, "assertion"))
|
|
69
|
+
return CompiledGraphData(actions=actions, dataform_version=raw.get("dataformCoreVersion"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_target(entry: dict) -> Target:
|
|
73
|
+
return Target(
|
|
74
|
+
database=entry.get("database", ""),
|
|
75
|
+
schema=entry.get("schema", ""),
|
|
76
|
+
name=entry.get("name", ""),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_action(entry: dict, action_type: str) -> Action:
|
|
81
|
+
descriptor = entry.get("actionDescriptor") or {}
|
|
82
|
+
columns = [
|
|
83
|
+
ColumnDoc(name=".".join(col["path"]), description=col.get("description"))
|
|
84
|
+
for col in descriptor.get("columns") or []
|
|
85
|
+
if col.get("path")
|
|
86
|
+
]
|
|
87
|
+
# operations carry a `queries` array instead of `query`
|
|
88
|
+
query = entry.get("query")
|
|
89
|
+
if not query and entry.get("queries"):
|
|
90
|
+
query = ";\n".join(q for q in entry["queries"] if q)
|
|
91
|
+
return Action(
|
|
92
|
+
target=_load_target(entry.get("target") or {}),
|
|
93
|
+
action_type=action_type,
|
|
94
|
+
file_name=entry.get("fileName"),
|
|
95
|
+
tags=list(entry.get("tags") or []),
|
|
96
|
+
description=descriptor.get("description"),
|
|
97
|
+
columns=columns,
|
|
98
|
+
dependency_targets=[_load_target(t) for t in entry.get("dependencyTargets") or []],
|
|
99
|
+
query=query,
|
|
100
|
+
incremental_query=entry.get("incrementalQuery"),
|
|
101
|
+
unique_key=list(entry.get("uniqueKey") or []),
|
|
102
|
+
disabled=bool(entry.get("disabled", False)),
|
|
103
|
+
)
|