redcliff 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.
- redcliff/__init__.py +43 -0
- redcliff/cli.py +503 -0
- redcliff/columns.py +70 -0
- redcliff/compile.py +470 -0
- redcliff/dbt.py +263 -0
- redcliff/diff.py +320 -0
- redcliff/expect.py +225 -0
- redcliff/importer.py +569 -0
- redcliff/informatica.py +689 -0
- redcliff/lakeflow.py +169 -0
- redcliff/lineage.py +490 -0
- redcliff/mcp.py +524 -0
- redcliff/pipeline.schema.json +555 -0
- redcliff/powercenter.py +1487 -0
- redcliff/powercenter_workflow.py +358 -0
- redcliff/scorecard.py +117 -0
- redcliff/spec.py +554 -0
- redcliff/ssis.py +1013 -0
- redcliff/ssis_expr.py +447 -0
- redcliff/trace.py +196 -0
- redcliff/web.py +179 -0
- redcliff-0.1.0.dist-info/METADATA +75 -0
- redcliff-0.1.0.dist-info/RECORD +25 -0
- redcliff-0.1.0.dist-info/WHEEL +4 -0
- redcliff-0.1.0.dist-info/entry_points.txt +2 -0
redcliff/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Redcliff: open-source visual pipelines with lineage built in."""
|
|
2
|
+
|
|
3
|
+
from redcliff.columns import infer_columns
|
|
4
|
+
from redcliff.compile import CompileError, compile_pyspark, compile_query, compile_script
|
|
5
|
+
from redcliff.importer import ImportResult, SQLImportError, import_sql
|
|
6
|
+
from redcliff.lineage import Ref, blast_radius, build_graph, column_issues, lineage_report, openlineage_facet
|
|
7
|
+
from redcliff.powercenter import PowerCenterImportError, import_powercenter
|
|
8
|
+
from redcliff.scorecard import Scorecard, ScoreItem
|
|
9
|
+
from redcliff.ssis import SSISImportError, import_ssis
|
|
10
|
+
from redcliff.spec import Issue, Node, Pipeline, SpecError, json_schema, load_spec, parse_spec, validate
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CompileError",
|
|
16
|
+
"compile_pyspark",
|
|
17
|
+
"compile_query",
|
|
18
|
+
"compile_script",
|
|
19
|
+
"infer_columns",
|
|
20
|
+
"ImportResult",
|
|
21
|
+
"SQLImportError",
|
|
22
|
+
"import_sql",
|
|
23
|
+
"PowerCenterImportError",
|
|
24
|
+
"import_powercenter",
|
|
25
|
+
"SSISImportError",
|
|
26
|
+
"import_ssis",
|
|
27
|
+
"Scorecard",
|
|
28
|
+
"ScoreItem",
|
|
29
|
+
"Ref",
|
|
30
|
+
"blast_radius",
|
|
31
|
+
"build_graph",
|
|
32
|
+
"column_issues",
|
|
33
|
+
"lineage_report",
|
|
34
|
+
"openlineage_facet",
|
|
35
|
+
"Issue",
|
|
36
|
+
"Node",
|
|
37
|
+
"Pipeline",
|
|
38
|
+
"SpecError",
|
|
39
|
+
"json_schema",
|
|
40
|
+
"load_spec",
|
|
41
|
+
"parse_spec",
|
|
42
|
+
"validate",
|
|
43
|
+
]
|
redcliff/cli.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""The ``redcliff`` command line: import, validate, compile, inspect lineage and run pipelines.
|
|
2
|
+
|
|
3
|
+
redcliff import legacy.sql --dialect teradata --schema schema.json -o pipeline.yaml
|
|
4
|
+
redcliff import mapping.xml -o pipeline.yaml --scorecard scorecard.md (Informatica PowerCenter)
|
|
5
|
+
redcliff import package.dtsx -o pipeline.yaml --scorecard scorecard.md (SSIS)
|
|
6
|
+
redcliff validate pipeline.yaml
|
|
7
|
+
redcliff compile pipeline.yaml --to spark
|
|
8
|
+
redcliff compile pipeline.yaml --to dbt -o my_dbt_project (or --to lakeflow)
|
|
9
|
+
redcliff lineage pipeline.yaml --impact orders.status
|
|
10
|
+
redcliff run pipeline.yaml --db warehouse.duckdb
|
|
11
|
+
redcliff test pipeline.yaml (data quality expectations)
|
|
12
|
+
redcliff trace pipeline.yaml out --where "region = 'South'"
|
|
13
|
+
redcliff diff old.yaml new.yaml --data (or: redcliff diff --git origin/main)
|
|
14
|
+
redcliff mcp --root . (an MCP server for AI agents, over stdio)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from redcliff.compile import CompileError, compile_pyspark, compile_query, compile_script
|
|
25
|
+
from redcliff.importer import SQLImportError, import_sql
|
|
26
|
+
from redcliff.lineage import Ref, blast_radius, build_graph, openlineage_facet, trace_to_sources
|
|
27
|
+
from redcliff.powercenter import import_powercenter
|
|
28
|
+
from redcliff.spec import SpecError, dump_spec, load_spec
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_mapping(path: str) -> dict:
|
|
32
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
33
|
+
if text.lstrip().startswith("{"):
|
|
34
|
+
return json.loads(text)
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
return yaml.safe_load(text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_spec(data: dict, path: str | None) -> str:
|
|
41
|
+
text = dump_spec(data, "yaml" if path is None or path.endswith((".yaml", ".yml")) else "json")
|
|
42
|
+
if path:
|
|
43
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _spec(args):
|
|
48
|
+
"""Load the spec, with --var overrides applied to its variables."""
|
|
49
|
+
pipeline = load_spec(args.spec)
|
|
50
|
+
for item in getattr(args, "var", None) or []:
|
|
51
|
+
name, sep, value = item.partition("=")
|
|
52
|
+
if not sep or not name.strip():
|
|
53
|
+
raise SystemExit(f"--var expects NAME=VALUE, got {item!r}")
|
|
54
|
+
if name.strip() not in pipeline.variables:
|
|
55
|
+
_err(f"note: {name.strip()} isn't declared in the spec's variables; adding it")
|
|
56
|
+
pipeline.variables[name.strip()] = value
|
|
57
|
+
return pipeline
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ref(text: str) -> Ref:
|
|
61
|
+
node, _, column = text.partition(".")
|
|
62
|
+
if not node or not column:
|
|
63
|
+
raise SystemExit(f"expected NODE.COLUMN, got {text!r}")
|
|
64
|
+
return Ref(node, column)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _err(message: str) -> None:
|
|
68
|
+
print(message, file=sys.stderr)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- commands
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_import(args) -> int:
|
|
75
|
+
text_in = Path(args.sql).read_text(encoding="utf-8")
|
|
76
|
+
from redcliff.ssis import import_ssis, is_ssis
|
|
77
|
+
|
|
78
|
+
if is_ssis(text_in, args.sql):
|
|
79
|
+
result = import_ssis(text_in, name=args.name)
|
|
80
|
+
elif args.sql.lower().endswith(".xml") or text_in.lstrip().startswith("<"):
|
|
81
|
+
params = Path(args.params).read_text(encoding="utf-8") if args.params else None
|
|
82
|
+
result = import_powercenter(text_in, mapping=args.mapping or None, name=args.name, params=params)
|
|
83
|
+
else:
|
|
84
|
+
schema = _read_mapping(args.schema) if args.schema else None
|
|
85
|
+
name = args.name or Path(args.sql).stem
|
|
86
|
+
result = import_sql(text_in, dialect=args.dialect, name=name, schema=schema)
|
|
87
|
+
text = _write_spec(result.pipeline.to_dict(), args.output)
|
|
88
|
+
if not args.output:
|
|
89
|
+
print(text, end="")
|
|
90
|
+
nodes = result.pipeline.nodes
|
|
91
|
+
_err(f"imported {len(nodes)} nodes ({sum(n.type == 'target' for n in nodes)} targets)"
|
|
92
|
+
+ (f" to {args.output}" if args.output else ""))
|
|
93
|
+
for w in result.warnings:
|
|
94
|
+
_err(f"warning: {w}")
|
|
95
|
+
card = result.scorecard
|
|
96
|
+
if card is not None:
|
|
97
|
+
c = card.counts()
|
|
98
|
+
_err(f"scorecard: {c['converted']} of {len(card.items)} converted ({card.automated():.0%}), "
|
|
99
|
+
f"{c['review']} to review, {c['manual']} to rewrite by hand")
|
|
100
|
+
if args.scorecard:
|
|
101
|
+
out = Path(args.scorecard)
|
|
102
|
+
out.write_text(json.dumps(card.to_dict(), indent=2) + "\n" if out.suffix == ".json"
|
|
103
|
+
else card.to_markdown(), encoding="utf-8")
|
|
104
|
+
_err(f"scorecard written to {args.scorecard}")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_validate(args) -> int:
|
|
109
|
+
pipeline = load_spec(args.spec)
|
|
110
|
+
issues = build_graph(pipeline).issues
|
|
111
|
+
for i in issues:
|
|
112
|
+
_err(f"column issue: {i}")
|
|
113
|
+
if issues:
|
|
114
|
+
return 1
|
|
115
|
+
print(f"{args.spec}: valid ({len(pipeline.nodes)} nodes)")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _write_project(files: dict[str, str], out_dir: str | None) -> None:
|
|
120
|
+
if not out_dir: # one stream, a header per file
|
|
121
|
+
for rel, content in files.items():
|
|
122
|
+
print(f"# ==== {rel}\n{content}", end="" if content.endswith("\n") else "\n")
|
|
123
|
+
return
|
|
124
|
+
for rel, content in files.items():
|
|
125
|
+
path = Path(out_dir) / rel
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.write_text(content, encoding="utf-8")
|
|
128
|
+
_err(f"wrote {len(files)} files to {out_dir}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_compile(args) -> int:
|
|
132
|
+
pipeline = _spec(args)
|
|
133
|
+
base = Path(args.spec).parent if args.resolve_paths else None
|
|
134
|
+
if args.to == "dbt":
|
|
135
|
+
from redcliff.dbt import compile_dbt
|
|
136
|
+
|
|
137
|
+
_write_project(compile_dbt(pipeline, args.adapter, base_dir=Path(args.spec).parent), args.output)
|
|
138
|
+
return 0
|
|
139
|
+
if args.to == "lakeflow":
|
|
140
|
+
from redcliff.lakeflow import compile_lakeflow
|
|
141
|
+
|
|
142
|
+
_write_project(compile_lakeflow(pipeline, base_dir=base, storage=args.storage), args.output)
|
|
143
|
+
return 0
|
|
144
|
+
if args.to == "pyspark":
|
|
145
|
+
out = compile_pyspark(pipeline, base_dir=base)
|
|
146
|
+
elif args.node:
|
|
147
|
+
out = compile_query(pipeline, args.node, args.to, base_dir=base) + "\n"
|
|
148
|
+
else:
|
|
149
|
+
out = compile_script(pipeline, args.to, base_dir=base)
|
|
150
|
+
if args.output:
|
|
151
|
+
Path(args.output).write_text(out, encoding="utf-8")
|
|
152
|
+
_err(f"wrote {args.output}")
|
|
153
|
+
else:
|
|
154
|
+
print(out, end="")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_lineage(args) -> int:
|
|
159
|
+
graph = build_graph(_spec(args))
|
|
160
|
+
for i in graph.issues:
|
|
161
|
+
_err(f"column issue: {i}")
|
|
162
|
+
if args.openlineage:
|
|
163
|
+
print(json.dumps(openlineage_facet(graph, args.openlineage), indent=2))
|
|
164
|
+
elif args.impact:
|
|
165
|
+
impact = blast_radius(graph, _ref(args.impact)).to_dict()
|
|
166
|
+
print(f"If {impact['column']} changes:")
|
|
167
|
+
print(f" values change in: {', '.join(impact['values']) or 'nothing'}")
|
|
168
|
+
print(f" rows change in: {', '.join(impact['rows']) or 'nothing'}")
|
|
169
|
+
print(f" a rename breaks: {', '.join(impact['breaks']) or 'nothing'}")
|
|
170
|
+
elif args.column:
|
|
171
|
+
ref = _ref(args.column)
|
|
172
|
+
traced = trace_to_sources(graph, ref)
|
|
173
|
+
print(f"{ref} comes from:")
|
|
174
|
+
for src, (kind, sub) in sorted(traced.items()):
|
|
175
|
+
print(f" {src} ({kind.lower()}, {sub.lower()})")
|
|
176
|
+
if not traced:
|
|
177
|
+
print(" no source columns (a literal or COUNT(*))")
|
|
178
|
+
else:
|
|
179
|
+
for node, cols in graph.columns.items():
|
|
180
|
+
for col in cols:
|
|
181
|
+
srcs = ", ".join(str(s) for s in sorted(trace_to_sources(graph, Ref(node, col))))
|
|
182
|
+
print(f"{node}.{col} <- {srcs or '-'}")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_run(args) -> int:
|
|
187
|
+
try:
|
|
188
|
+
import duckdb
|
|
189
|
+
except ImportError:
|
|
190
|
+
_err("run needs DuckDB: pip install duckdb")
|
|
191
|
+
return 2
|
|
192
|
+
pipeline = _spec(args)
|
|
193
|
+
base = Path(args.spec).parent
|
|
194
|
+
con = duckdb.connect(args.db) if args.db else duckdb.connect()
|
|
195
|
+
if args.node:
|
|
196
|
+
rel = con.sql(compile_query(pipeline, args.node, "duckdb", base_dir=base))
|
|
197
|
+
print(" | ".join(rel.columns))
|
|
198
|
+
for row in rel.fetchmany(args.limit):
|
|
199
|
+
print(" | ".join("" if v is None else str(v) for v in row))
|
|
200
|
+
return 0
|
|
201
|
+
if not args.skip_checks:
|
|
202
|
+
from redcliff.expect import run_checks
|
|
203
|
+
|
|
204
|
+
results = run_checks(con, pipeline, base_dir=base)
|
|
205
|
+
_report_checks(results, quiet=True)
|
|
206
|
+
if any(not r.passed and r.expectation.severity == "error" for r in results):
|
|
207
|
+
_err("not writing: an expectation failed (see above); fix the data or pass --skip-checks")
|
|
208
|
+
return 1
|
|
209
|
+
for target in (n for n in pipeline.nodes if n.type == "target" and n.params["format"] != "table"):
|
|
210
|
+
(base / target.params["path"]).parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
con.execute(compile_script(pipeline, "duckdb", base_dir=base))
|
|
212
|
+
targets = [n.params.get("table") or n.params.get("path") for n in pipeline.nodes if n.type == "target"]
|
|
213
|
+
print(f"ran {pipeline.name}: wrote {', '.join(targets)}")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _report_checks(results, quiet: bool = False) -> None:
|
|
218
|
+
"""Print expectation results; with quiet, only the failures."""
|
|
219
|
+
for r in results:
|
|
220
|
+
e = r.expectation
|
|
221
|
+
if r.passed:
|
|
222
|
+
if not quiet:
|
|
223
|
+
print(f"pass {e.node}.{e.name} ({e.describe()})")
|
|
224
|
+
continue
|
|
225
|
+
label = "warn " if e.severity == "warn" else "FAIL "
|
|
226
|
+
what = r.error or f"{r.failures} failing row{'s' if r.failures != 1 else ''}"
|
|
227
|
+
print(f"{label} {e.node}.{e.name} ({e.describe()}): {what}")
|
|
228
|
+
if r.sample:
|
|
229
|
+
print(" " + " | ".join(r.columns))
|
|
230
|
+
for row in r.sample:
|
|
231
|
+
print(" " + " | ".join("" if v is None else str(v) for v in row))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_test(args) -> int:
|
|
235
|
+
try:
|
|
236
|
+
import duckdb
|
|
237
|
+
except ImportError:
|
|
238
|
+
_err("test needs DuckDB: pip install duckdb")
|
|
239
|
+
return 2
|
|
240
|
+
from redcliff.expect import run_checks
|
|
241
|
+
|
|
242
|
+
pipeline = _spec(args)
|
|
243
|
+
con = duckdb.connect(args.db, read_only=True) if args.db else duckdb.connect()
|
|
244
|
+
results = run_checks(con, pipeline, args.node, Path(args.spec).parent)
|
|
245
|
+
if not results:
|
|
246
|
+
print("no expectations to check; add 'expect' to a step (see docs/spec.md)")
|
|
247
|
+
return 0
|
|
248
|
+
_report_checks(results)
|
|
249
|
+
failed = [r for r in results if not r.passed and r.expectation.severity == "error"]
|
|
250
|
+
warned = [r for r in results if not r.passed and r.expectation.severity == "warn"]
|
|
251
|
+
print(f"\n{len(results) - len(failed) - len(warned)} passed, {len(failed)} failed, {len(warned)} warned")
|
|
252
|
+
return 1 if failed else 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _cell(v) -> str:
|
|
256
|
+
return "" if v is None else str(v)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_trace(args) -> int:
|
|
260
|
+
try:
|
|
261
|
+
import duckdb
|
|
262
|
+
except ImportError:
|
|
263
|
+
_err("trace needs DuckDB: pip install duckdb")
|
|
264
|
+
return 2
|
|
265
|
+
from redcliff.trace import trace_query, trace_rows
|
|
266
|
+
|
|
267
|
+
pipeline = _spec(args)
|
|
268
|
+
base = Path(args.spec).parent
|
|
269
|
+
con = duckdb.connect(args.db, read_only=True) if args.db else duckdb.connect()
|
|
270
|
+
tq = trace_query(pipeline, args.node, base)
|
|
271
|
+
for step in tq.opaque:
|
|
272
|
+
_err(f"note: rows pass through the sql step {step} untraced")
|
|
273
|
+
rows = trace_rows(con, pipeline, args.node, args.where, base)
|
|
274
|
+
if not rows:
|
|
275
|
+
print("no rows" + (f" where {args.where}" if args.where else ""))
|
|
276
|
+
for i, traced in enumerate(rows[: args.limit]):
|
|
277
|
+
if i:
|
|
278
|
+
print()
|
|
279
|
+
print(f"{tq.node}: " + ", ".join(f"{k}={_cell(v)}" for k, v in traced["row"].items()))
|
|
280
|
+
for source, src_rows in traced["sources"].items():
|
|
281
|
+
print(f" from {source} ({len(src_rows)} row{'s' if len(src_rows) != 1 else ''}):")
|
|
282
|
+
for r in src_rows:
|
|
283
|
+
print(f" #{r['row']}: " + ", ".join(f"{k}={_cell(v)}" for k, v in r.items() if k != "row"))
|
|
284
|
+
if len(rows) > args.limit:
|
|
285
|
+
print(f"\n... {len(rows) - args.limit} more rows; narrow them with --where")
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
MARKER = "<!-- redcliff-diff -->"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _git(*args: str, cwd: str | None = None) -> str:
|
|
293
|
+
import subprocess
|
|
294
|
+
|
|
295
|
+
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
296
|
+
if done.returncode:
|
|
297
|
+
raise FileNotFoundError(f"git {' '.join(args)}: {done.stderr.strip()}")
|
|
298
|
+
return done.stdout
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _as_pipeline(text: str | None):
|
|
302
|
+
"""A spec's text as a Pipeline, or None when it isn't a pipeline spec."""
|
|
303
|
+
from redcliff.spec import Pipeline
|
|
304
|
+
|
|
305
|
+
if text is None:
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
if text.lstrip().startswith("{"):
|
|
309
|
+
data = json.loads(text)
|
|
310
|
+
else:
|
|
311
|
+
import yaml
|
|
312
|
+
|
|
313
|
+
data = yaml.safe_load(text)
|
|
314
|
+
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list):
|
|
315
|
+
return None
|
|
316
|
+
return Pipeline.from_dict(data)
|
|
317
|
+
except Exception: # noqa: BLE001 - not a spec, or not parseable: skip it
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _diff_one(base, head, args, base_dir, head_dir):
|
|
322
|
+
from redcliff.diff import data_diff, diff_pipelines
|
|
323
|
+
from redcliff.spec import Pipeline
|
|
324
|
+
|
|
325
|
+
base = base or Pipeline(name=head.name, nodes=[])
|
|
326
|
+
diff = diff_pipelines(base, head)
|
|
327
|
+
if args.data and not diff.issues:
|
|
328
|
+
try:
|
|
329
|
+
import duckdb
|
|
330
|
+
except ImportError:
|
|
331
|
+
_err("--data needs DuckDB: pip install duckdb")
|
|
332
|
+
else:
|
|
333
|
+
con = duckdb.connect(args.db, read_only=True) if args.db else duckdb.connect()
|
|
334
|
+
diff.data = data_diff(base, head, con, base_dir, head_dir)
|
|
335
|
+
return diff
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def cmd_diff(args) -> int:
|
|
339
|
+
|
|
340
|
+
results = [] # (label, diff)
|
|
341
|
+
if args.git:
|
|
342
|
+
root = _git("rev-parse", "--show-toplevel").strip()
|
|
343
|
+
names = _git("diff", "--name-only", "--diff-filter=ACMRD", args.git, "--", *(args.paths or ["."]), cwd=None)
|
|
344
|
+
prefix = _git("rev-parse", "--show-prefix").strip()
|
|
345
|
+
for rel in names.split():
|
|
346
|
+
if not rel.endswith((".yaml", ".yml", ".json")):
|
|
347
|
+
continue
|
|
348
|
+
path = Path(root) / rel
|
|
349
|
+
head_text = path.read_text(encoding="utf-8") if path.exists() else None
|
|
350
|
+
try:
|
|
351
|
+
base_text = _git("show", f"{args.git}:{rel}", cwd=root)
|
|
352
|
+
except FileNotFoundError:
|
|
353
|
+
base_text = None
|
|
354
|
+
base, head = _as_pipeline(base_text), _as_pipeline(head_text)
|
|
355
|
+
if head is None and base is None:
|
|
356
|
+
continue
|
|
357
|
+
label = rel[len(prefix):] if prefix and rel.startswith(prefix) else rel
|
|
358
|
+
if head is None:
|
|
359
|
+
results.append((label, None))
|
|
360
|
+
continue
|
|
361
|
+
results.append((label, _diff_one(base, head, args, path.parent, path.parent)))
|
|
362
|
+
else:
|
|
363
|
+
if not args.paths or len(args.paths) != 2:
|
|
364
|
+
_err("give two specs (BASE HEAD), or --git REF")
|
|
365
|
+
return 2
|
|
366
|
+
a, b = (Path(p) for p in args.paths)
|
|
367
|
+
base = _as_pipeline(a.read_text(encoding="utf-8"))
|
|
368
|
+
head = _as_pipeline(b.read_text(encoding="utf-8"))
|
|
369
|
+
if base is None or head is None:
|
|
370
|
+
_err("both files must be pipeline specs")
|
|
371
|
+
return 1
|
|
372
|
+
results.append((str(b), _diff_one(base, head, args, a.parent, b.parent)))
|
|
373
|
+
|
|
374
|
+
if args.format == "json":
|
|
375
|
+
text = json.dumps({label: (d.to_dict() if d else {"removed": True}) for label, d in results}, indent=2) + "\n"
|
|
376
|
+
else:
|
|
377
|
+
lines = [MARKER, "## Redcliff: what this change does to the pipelines", ""]
|
|
378
|
+
if not results:
|
|
379
|
+
lines.append("No pipeline specs changed.")
|
|
380
|
+
for label, d in results:
|
|
381
|
+
if d is None:
|
|
382
|
+
lines += [f"### `{label}`", "", "**pipeline removed**", ""]
|
|
383
|
+
else:
|
|
384
|
+
lines.append(d.to_markdown(title=f"`{label}`", level=3))
|
|
385
|
+
lines += ["<sub>Generated by [Redcliff](https://github.com/Fullcountr/redcliff) from the pipeline specs; "
|
|
386
|
+
"data compares each target on the sample data in the repository.</sub>" if args.data else
|
|
387
|
+
"<sub>Generated by [Redcliff](https://github.com/Fullcountr/redcliff) from the pipeline specs.</sub>"]
|
|
388
|
+
text = "\n".join(lines).rstrip() + "\n"
|
|
389
|
+
if args.output:
|
|
390
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
391
|
+
_err(f"wrote {args.output}")
|
|
392
|
+
else:
|
|
393
|
+
print(text, end="")
|
|
394
|
+
has_issues = any(d is not None and d.issues for _l, d in results)
|
|
395
|
+
return 1 if has_issues and args.fail_on_issues else 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def cmd_mcp(args) -> int:
|
|
399
|
+
from redcliff.mcp import serve
|
|
400
|
+
|
|
401
|
+
serve(args.root, args.db)
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --------------------------------------------------------------------------- entry point
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
409
|
+
from redcliff import __version__
|
|
410
|
+
|
|
411
|
+
parser = argparse.ArgumentParser(prog="redcliff", description="Legacy ETL in, clean Spark out.")
|
|
412
|
+
parser.add_argument("--version", action="version", version=f"redcliff {__version__}")
|
|
413
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
414
|
+
|
|
415
|
+
p = sub.add_parser("import", help="turn legacy SQL, a PowerCenter export or an SSIS package into a pipeline spec")
|
|
416
|
+
p.add_argument("sql", help="SQL file (one or more statements), a PowerCenter XML export or an SSIS .dtsx")
|
|
417
|
+
p.add_argument("--dialect", help="sqlglot dialect, e.g. teradata, oracle, tsql, snowflake")
|
|
418
|
+
p.add_argument("--schema", help="JSON/YAML mapping of table -> {column: type}")
|
|
419
|
+
p.add_argument("--name", help="pipeline name (default: the file name)")
|
|
420
|
+
p.add_argument("--mapping", action="append", help="PowerCenter: import only this mapping (repeatable)")
|
|
421
|
+
p.add_argument("--scorecard", help="write the migration scorecard here (.md or .json)")
|
|
422
|
+
p.add_argument("--params", help="PowerCenter: a parameter file, for $$PARAMS in mappings and SQL")
|
|
423
|
+
p.add_argument("-o", "--output", help="write the spec here (.yaml or .json); default: stdout")
|
|
424
|
+
p.set_defaults(func=cmd_import)
|
|
425
|
+
|
|
426
|
+
p = sub.add_parser("validate", help="check a spec, including column-level checks")
|
|
427
|
+
p.add_argument("spec")
|
|
428
|
+
p.set_defaults(func=cmd_validate)
|
|
429
|
+
|
|
430
|
+
p = sub.add_parser("compile", help="generate DuckDB SQL, Spark SQL, PySpark, a dbt project or a Lakeflow pipeline")
|
|
431
|
+
p.add_argument("spec")
|
|
432
|
+
p.add_argument("--var", action="append", metavar="NAME=VALUE", help="set a pipeline variable (repeatable)")
|
|
433
|
+
p.add_argument("--to", choices=["duckdb", "spark", "pyspark", "dbt", "lakeflow"], default="duckdb")
|
|
434
|
+
p.add_argument("--adapter", default="duckdb", help="dbt: the adapter to write SQL for (duckdb, databricks, "
|
|
435
|
+
"spark, snowflake, bigquery, postgres, redshift)")
|
|
436
|
+
p.add_argument("--storage", help="lakeflow: checkpoint root, an absolute URI (file://..., s3a://...)")
|
|
437
|
+
p.add_argument("--node", help="compile a SELECT for one node instead of the full script")
|
|
438
|
+
p.add_argument("--resolve-paths", action="store_true", help="make file paths absolute")
|
|
439
|
+
p.add_argument("-o", "--output", help="a file; for dbt and lakeflow, a directory")
|
|
440
|
+
p.set_defaults(func=cmd_compile)
|
|
441
|
+
|
|
442
|
+
p = sub.add_parser("lineage", help="column lineage, blast radius or OpenLineage export")
|
|
443
|
+
p.add_argument("spec")
|
|
444
|
+
p.add_argument("--var", action="append", metavar="NAME=VALUE", help="set a pipeline variable (repeatable)")
|
|
445
|
+
g = p.add_mutually_exclusive_group()
|
|
446
|
+
g.add_argument("--column", help="NODE.COLUMN: where does it come from?")
|
|
447
|
+
g.add_argument("--impact", help="NODE.COLUMN: what does a change to it touch?")
|
|
448
|
+
g.add_argument("--openlineage", metavar="NODE", help="print the OpenLineage column facet for NODE")
|
|
449
|
+
p.set_defaults(func=cmd_lineage)
|
|
450
|
+
|
|
451
|
+
p = sub.add_parser("run", help="run the pipeline locally with DuckDB")
|
|
452
|
+
p.add_argument("spec")
|
|
453
|
+
p.add_argument("--var", action="append", metavar="NAME=VALUE", help="set a pipeline variable (repeatable)")
|
|
454
|
+
p.add_argument("--db", help="DuckDB database file holding table sources and targets")
|
|
455
|
+
p.add_argument("--node", help="preview one node's rows instead of writing targets")
|
|
456
|
+
p.add_argument("--limit", type=int, default=20)
|
|
457
|
+
p.add_argument("--skip-checks", action="store_true", help="write even if an expectation fails")
|
|
458
|
+
p.set_defaults(func=cmd_run)
|
|
459
|
+
|
|
460
|
+
p = sub.add_parser("test", help="run the pipeline's expectations (data quality checks) in DuckDB")
|
|
461
|
+
p.add_argument("spec")
|
|
462
|
+
p.add_argument("--var", action="append", metavar="NAME=VALUE", help="set a pipeline variable (repeatable)")
|
|
463
|
+
p.add_argument("--node", help="check only this step")
|
|
464
|
+
p.add_argument("--db", help="DuckDB database file holding table sources")
|
|
465
|
+
p.set_defaults(func=cmd_test)
|
|
466
|
+
|
|
467
|
+
p = sub.add_parser("trace", help="row-level trace: the source rows behind a step's rows")
|
|
468
|
+
p.add_argument("spec")
|
|
469
|
+
p.add_argument("--var", action="append", metavar="NAME=VALUE", help="set a pipeline variable (repeatable)")
|
|
470
|
+
p.add_argument("node", help="the step (or target) whose rows to trace")
|
|
471
|
+
p.add_argument("--where", help="SQL condition picking the rows, e.g. \"region = 'South'\"")
|
|
472
|
+
p.add_argument("--db", help="DuckDB database file holding table sources")
|
|
473
|
+
p.add_argument("--limit", type=int, default=10)
|
|
474
|
+
p.set_defaults(func=cmd_trace)
|
|
475
|
+
|
|
476
|
+
p = sub.add_parser("diff", help="what a change does: steps, columns, lineage and data")
|
|
477
|
+
p.add_argument("paths", nargs="*", help="BASE HEAD specs; with --git, paths to limit the search to")
|
|
478
|
+
p.add_argument("--git", metavar="REF", help="compare every changed spec with its version at REF")
|
|
479
|
+
p.add_argument("--data", action="store_true", help="also run each target on sample data, both ways")
|
|
480
|
+
p.add_argument("--db", help="DuckDB database file holding table sources")
|
|
481
|
+
p.add_argument("--format", choices=["md", "json"], default="md")
|
|
482
|
+
p.add_argument("--fail-on-issues", action="store_true", help="exit 1 if a new spec has problems")
|
|
483
|
+
p.add_argument("-o", "--output")
|
|
484
|
+
p.set_defaults(func=cmd_diff)
|
|
485
|
+
|
|
486
|
+
p = sub.add_parser("mcp", help="run an MCP server over stdio, so AI agents can read and edit pipelines")
|
|
487
|
+
p.add_argument("--root", default=".", help="the folder whose specs agents may read and edit (default: here)")
|
|
488
|
+
p.add_argument("--db", help="DuckDB database file holding table sources, for previews and traces")
|
|
489
|
+
p.set_defaults(func=cmd_mcp)
|
|
490
|
+
return parser
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def main(argv: list[str] | None = None) -> int:
|
|
494
|
+
args = build_parser().parse_args(argv)
|
|
495
|
+
try:
|
|
496
|
+
return args.func(args)
|
|
497
|
+
except (SpecError, SQLImportError, CompileError, FileNotFoundError, KeyError, ValueError) as e:
|
|
498
|
+
_err(f"error: {e}")
|
|
499
|
+
return 1
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__": # pragma: no cover
|
|
503
|
+
sys.exit(main())
|
redcliff/columns.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Work out each node's output columns from the columns declared on its sources.
|
|
2
|
+
|
|
3
|
+
Knowing columns lets the compiler emit explicit, portable SQL (a derive that replaces a column,
|
|
4
|
+
a join that drops duplicate names) and is the base the lineage engine builds on. When a source
|
|
5
|
+
does not declare its columns, everything downstream of it is ``None`` (unknown).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
|
|
13
|
+
from redcliff.spec import Node, Pipeline, bind
|
|
14
|
+
|
|
15
|
+
Columns = list[str] | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def output_columns(node: Node, inputs: list[Columns]) -> Columns:
|
|
19
|
+
"""Output columns of one node, given its inputs' columns (in input order)."""
|
|
20
|
+
t, p = node.type, node.params
|
|
21
|
+
if t == "source":
|
|
22
|
+
declared = p.get("columns")
|
|
23
|
+
return list(declared) if declared else None
|
|
24
|
+
if t == "target":
|
|
25
|
+
return []
|
|
26
|
+
if t == "select":
|
|
27
|
+
return list(p["columns"])
|
|
28
|
+
if t == "aggregate":
|
|
29
|
+
return list(p.get("group_by", [])) + list(p.get("measures") or {})
|
|
30
|
+
if t == "sql":
|
|
31
|
+
return _sql_columns(p["query"], inputs)
|
|
32
|
+
|
|
33
|
+
first = inputs[0] if inputs else None
|
|
34
|
+
if t in ("filter", "union"):
|
|
35
|
+
return list(first) if first is not None else None
|
|
36
|
+
if t == "derive":
|
|
37
|
+
if first is None:
|
|
38
|
+
return None
|
|
39
|
+
new = [c for c in p["columns"] if c not in first]
|
|
40
|
+
return list(first) + new
|
|
41
|
+
if t == "join":
|
|
42
|
+
left, right = inputs
|
|
43
|
+
if left is None or right is None:
|
|
44
|
+
return None
|
|
45
|
+
return list(left) + [c for c in right if c not in left]
|
|
46
|
+
raise ValueError(f"unknown node type {t!r}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sql_columns(query: str, inputs: list[Columns]) -> Columns:
|
|
50
|
+
"""Output columns of a raw SQL step, when they can be read off its SELECT list."""
|
|
51
|
+
tree = sqlglot.parse_one(query)
|
|
52
|
+
select = tree if isinstance(tree, exp.Select) else tree.find(exp.Select)
|
|
53
|
+
if select is None:
|
|
54
|
+
return None
|
|
55
|
+
projections = select.expressions
|
|
56
|
+
if len(projections) == 1 and isinstance(projections[0], exp.Star) and len(inputs) == 1:
|
|
57
|
+
return list(inputs[0]) if inputs[0] is not None else None
|
|
58
|
+
if any(isinstance(e, exp.Star) or isinstance(e.unalias(), exp.Star) for e in projections):
|
|
59
|
+
return None
|
|
60
|
+
names = select.named_selects
|
|
61
|
+
return names if len(names) == len(projections) and all(names) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def infer_columns(pipeline: Pipeline) -> dict[str, Columns]:
|
|
65
|
+
"""Map every node id to its output column names, or None where they can't be known."""
|
|
66
|
+
pipeline = bind(pipeline)
|
|
67
|
+
cols: dict[str, Columns] = {}
|
|
68
|
+
for node in pipeline.topo_order():
|
|
69
|
+
cols[node.id] = output_columns(node, [cols[i] for i in node.inputs])
|
|
70
|
+
return cols
|