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 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