interlaced 2.0.0a2__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 (74) hide show
  1. interlace/__init__.py +26 -0
  2. interlace/checks/__init__.py +10 -0
  3. interlace/checks/builtin.py +152 -0
  4. interlace/checks/runner.py +103 -0
  5. interlace/checks/spec.py +113 -0
  6. interlace/cli/__init__.py +7 -0
  7. interlace/cli/main.py +1072 -0
  8. interlace/config/__init__.py +7 -0
  9. interlace/config/config.py +172 -0
  10. interlace/contracts.py +35 -0
  11. interlace/dsl/__init__.py +16 -0
  12. interlace/dsl/decorators.py +222 -0
  13. interlace/dsl/discovery.py +75 -0
  14. interlace/dsl/sql_config.py +47 -0
  15. interlace/engines/__init__.py +16 -0
  16. interlace/engines/base.py +86 -0
  17. interlace/engines/duckdb.py +249 -0
  18. interlace/engines/postgres.py +157 -0
  19. interlace/engines/quack.py +113 -0
  20. interlace/engines/registry.py +125 -0
  21. interlace/exceptions.py +66 -0
  22. interlace/exports.py +145 -0
  23. interlace/graph/__init__.py +17 -0
  24. interlace/graph/column_lineage.py +69 -0
  25. interlace/graph/dag.py +81 -0
  26. interlace/graph/project.py +242 -0
  27. interlace/graph/selectors.py +45 -0
  28. interlace/ir/__init__.py +24 -0
  29. interlace/ir/canonicalize.py +71 -0
  30. interlace/ir/fingerprint.py +55 -0
  31. interlace/ir/relation.py +71 -0
  32. interlace/ir/schema.py +23 -0
  33. interlace/plan/__init__.py +23 -0
  34. interlace/plan/apply.py +527 -0
  35. interlace/plan/differ.py +211 -0
  36. interlace/plan/plan.py +177 -0
  37. interlace/plan/resolve.py +74 -0
  38. interlace/plan/run.py +79 -0
  39. interlace/project.py +278 -0
  40. interlace/py.typed +0 -0
  41. interlace/runtime/__init__.py +6 -0
  42. interlace/runtime/handles.py +48 -0
  43. interlace/runtime/python_model.py +175 -0
  44. interlace/scaffold.py +83 -0
  45. interlace/scheduler/__init__.py +17 -0
  46. interlace/scheduler/engine.py +66 -0
  47. interlace/scheduler/triggers.py +84 -0
  48. interlace/scheduler/worker.py +154 -0
  49. interlace/service/__init__.py +7 -0
  50. interlace/service/app.py +836 -0
  51. interlace/service/auth.py +41 -0
  52. interlace/state/__init__.py +18 -0
  53. interlace/state/interval.py +134 -0
  54. interlace/state/janitor.py +142 -0
  55. interlace/state/snapshot.py +47 -0
  56. interlace/state/store.py +906 -0
  57. interlace/strategies/__init__.py +64 -0
  58. interlace/strategies/base.py +71 -0
  59. interlace/strategies/full.py +38 -0
  60. interlace/strategies/full_merge.py +98 -0
  61. interlace/strategies/incremental_by_time.py +65 -0
  62. interlace/strategies/merge_by_key.py +69 -0
  63. interlace/strategies/scd_type_2.py +122 -0
  64. interlace/strategies/view.py +33 -0
  65. interlace/streaming/__init__.py +18 -0
  66. interlace/streaming/log.py +298 -0
  67. interlace/streaming/materializer.py +166 -0
  68. interlace/streaming/schema.py +218 -0
  69. interlaced-2.0.0a2.dist-info/METADATA +216 -0
  70. interlaced-2.0.0a2.dist-info/RECORD +74 -0
  71. interlaced-2.0.0a2.dist-info/WHEEL +5 -0
  72. interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
  73. interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
  74. interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
interlace/cli/main.py ADDED
@@ -0,0 +1,1072 @@
1
+ """The ``interlace`` CLI: plan and apply against a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
15
+ from rich.table import Table
16
+
17
+ from interlace.exceptions import CheckError, ConfigurationError, SelectionError
18
+ from interlace.graph.column_lineage import column_lineage
19
+ from interlace.graph.project import CompiledProject
20
+ from interlace.graph.selectors import select_models
21
+ from interlace.plan.apply import ApplyResult
22
+ from interlace.plan.apply import apply as apply_plan
23
+ from interlace.plan.differ import diff
24
+ from interlace.plan.plan import ChangeType, Plan
25
+ from interlace.plan.run import run_plan
26
+ from interlace.project import Project
27
+ from interlace.scaffold import scaffold_project
28
+ from interlace.scheduler.engine import TriggerEngine, build_triggers
29
+ from interlace.scheduler.worker import drain
30
+ from interlace.streaming import ensure_stream_tables
31
+
32
+ app = typer.Typer(no_args_is_help=True, help="Python/SQL-first data platform.")
33
+ console = Console()
34
+
35
+
36
+ class _BuildProgress:
37
+ """Live per-model build rows: a row appears when a model starts, ✓/✗ when it ends.
38
+
39
+ Doubles as the ``apply(on_progress=...)`` callback; use ``.progress`` as the
40
+ context manager around the apply call.
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ self.progress = Progress(
45
+ SpinnerColumn(finished_text=" "),
46
+ TextColumn("{task.description}"),
47
+ TextColumn("{task.fields[status]}"),
48
+ TimeElapsedColumn(),
49
+ console=console,
50
+ )
51
+ self._rows: dict[str, TaskID] = {}
52
+
53
+ def __call__(self, model: str, event: str) -> None:
54
+ if event == "start":
55
+ self._rows[model] = self.progress.add_task(model, total=1, status="")
56
+ elif event == "done":
57
+ self.progress.update(self._rows[model], completed=1, status="[green]✓[/green]")
58
+ elif event == "cancelled": # collateral of a sibling's failure, not a failure itself
59
+ self.progress.update(self._rows[model], completed=1, status="[dim]⊘[/dim]")
60
+ else: # failed
61
+ self.progress.update(self._rows[model], completed=1, status="[red]✗[/red]")
62
+
63
+
64
+ def _build_progress(plan_result: Plan) -> _BuildProgress | None:
65
+ """Progress display when it can render live and there is something to watch."""
66
+ return _BuildProgress() if console.is_terminal and plan_result.backfills else None
67
+
68
+
69
+ def _version_callback(value: bool) -> None:
70
+ if value:
71
+ from interlace import __version__
72
+
73
+ console.print(f"interlace {__version__}")
74
+ raise typer.Exit()
75
+
76
+
77
+ @app.callback()
78
+ def _root(
79
+ version: bool = typer.Option(
80
+ False, "--version", "-v", callback=_version_callback, is_eager=True, help="Show the version and exit."
81
+ ),
82
+ ) -> None:
83
+ pass
84
+
85
+
86
+ _ENV = typer.Option(
87
+ "prod",
88
+ "--env",
89
+ "-e",
90
+ envvar="INTERLACE_ENV",
91
+ help="Target data environment (prod = the unprefixed namespace).",
92
+ )
93
+ _PATH = typer.Option(Path("."), "--path", "-p", help="Project root.")
94
+ _SELECT = typer.Option([], "--select", "-s", help="Model selectors: name, +name, name+, tag:x.")
95
+ _START = typer.Option("", "--start", help="Window start (ISO), for incremental models.")
96
+ _END = typer.Option("", "--end", help="Window end (ISO), for incremental models.")
97
+ _FORWARD_ONLY = typer.Option(
98
+ False,
99
+ "--forward-only",
100
+ help="Modified history-keeping models (merge/full_merge/scd2/incremental) carry their history "
101
+ "forward: it is copied to the new version, the new logic applies to the copy, and checks gate "
102
+ "before views move. Requires a shape-compatible change.",
103
+ )
104
+ _JSON = typer.Option(False, "--json", help="Emit JSON instead of a table (for scripts and CI).")
105
+
106
+
107
+ def _emit_json(data: object) -> None:
108
+ import json
109
+
110
+ typer.echo(json.dumps(data, indent=2, default=str))
111
+
112
+
113
+ def _table(title: str) -> Table:
114
+ """The house table style: no grid, a thin rule under the header, left title.
115
+
116
+ Primary columns keep the default style; add secondary columns with
117
+ ``style="dim"`` so the eye lands on the values that matter.
118
+ """
119
+ return Table(
120
+ title=title,
121
+ title_justify="left",
122
+ title_style="bold",
123
+ box=box.SIMPLE_HEAD,
124
+ border_style="dim",
125
+ pad_edge=False,
126
+ )
127
+
128
+
129
+ def _render_build_results(result: ApplyResult, compiled: CompiledProject) -> None:
130
+ """Per-model outcome table: what was built, how it writes, where it ran, what
131
+ it read, what it did to the rows, and how long."""
132
+ built = set(result.built)
133
+ if not built:
134
+ return
135
+ table = _table("Build results")
136
+ table.add_column("Model")
137
+ table.add_column("Output", style="dim")
138
+ table.add_column("Strategy", style="dim")
139
+ table.add_column("Engine", style="dim")
140
+ table.add_column("Depends on", style="dim", no_wrap=True)
141
+ table.add_column("Rows", justify="right")
142
+ table.add_column("Time", justify="right", style="dim")
143
+ for model in compiled.ordered():
144
+ if model.name not in built:
145
+ continue
146
+ counts = result.rows.get(model.name)
147
+ parts = []
148
+ if counts is not None:
149
+ if counts.inserted:
150
+ parts.append(f"[green]+{counts.inserted:,}[/]")
151
+ if counts.updated:
152
+ parts.append(f"[yellow]~{counts.updated:,}[/]")
153
+ if counts.deleted:
154
+ parts.append(f"[red]-{counts.deleted:,}[/]")
155
+ seconds = result.timings.get(model.name)
156
+ table.add_row(
157
+ model.name,
158
+ "sink" if model.export is not None else model.materialise,
159
+ model.export.mode if model.export is not None else model.strategy, # sinks: the delivery mode
160
+ model.engine,
161
+ ", ".join(model.dependencies) or "—",
162
+ " ".join(parts) or "[dim]—[/]",
163
+ f"{seconds:.2f}s" if seconds is not None else "—",
164
+ )
165
+ console.print(table)
166
+
167
+
168
+ def _render_checks(result: ApplyResult) -> None:
169
+ if not result.checks:
170
+ return
171
+ passed = sum(1 for c in result.checks if c.status == "passed")
172
+ warned = [c for c in result.checks if c.status != "passed"]
173
+ line = f"Checks: {passed}/{len(result.checks)} passed"
174
+ if warned:
175
+ line += "; " + ", ".join(f"[yellow]{c.model}.{c.name} {c.status} ({c.severity})[/yellow]" for c in warned)
176
+ console.print(line)
177
+
178
+
179
+ def _selection(compiled: CompiledProject, selectors: list[str]) -> set[str] | None:
180
+ if not selectors:
181
+ return None
182
+ try:
183
+ return select_models(selectors, compiled)
184
+ except SelectionError as exc:
185
+ console.print(f"[red]{exc.message}[/red]")
186
+ raise typer.Exit(1) from exc
187
+
188
+
189
+ @app.command()
190
+ def init(
191
+ path: Path = typer.Argument(Path("."), help="Directory to initialise."),
192
+ name: str = typer.Option("", "--name", "-n", help="Project name (defaults to the directory name)."),
193
+ ) -> None:
194
+ """Scaffold a new interlace project."""
195
+ try:
196
+ written = scaffold_project(path, name or None)
197
+ except ConfigurationError as exc:
198
+ console.print(f"[red]{exc.message}[/red] ({exc.details.get('path', '')})")
199
+ raise typer.Exit(1) from exc
200
+ console.print(f"[green]Initialised interlace project in {path}[/green]")
201
+ for written_path in written:
202
+ console.print(f" + {written_path}")
203
+ console.print("\nNext: [bold]interlace apply[/bold] (or --env dev for a sandbox)")
204
+
205
+
206
+ @app.command()
207
+ def plan(
208
+ environment: str = _ENV,
209
+ path: Path = _PATH,
210
+ select: list[str] = _SELECT,
211
+ forward_only: bool = _FORWARD_ONLY,
212
+ as_json: bool = _JSON,
213
+ ) -> None:
214
+ """Show what apply would change in an environment."""
215
+ asyncio.run(_plan(environment, path, select, forward_only, as_json))
216
+
217
+
218
+ @app.command()
219
+ def apply(
220
+ environment: str = _ENV,
221
+ path: Path = _PATH,
222
+ select: list[str] = _SELECT,
223
+ forward_only: bool = _FORWARD_ONLY,
224
+ force: bool = typer.Option(False, "--force", help="Proceed even when the plan contains breaking changes."),
225
+ ) -> None:
226
+ """Build changed models and promote the environment."""
227
+ asyncio.run(_apply(environment, path, select, forward_only, force))
228
+
229
+
230
+ async def _plan(
231
+ environment: str, path: Path, select: list[str], forward_only: bool = False, as_json: bool = False
232
+ ) -> None:
233
+ project = Project.load(path)
234
+ compiled = project.compile()
235
+ state = await project.open_state()
236
+ try:
237
+ result = await diff(
238
+ compiled, environment, state, select=_selection(compiled, select), forward_only=forward_only
239
+ )
240
+ if as_json:
241
+ _emit_json(_plan_dict(result, environment))
242
+ else:
243
+ _render(result, environment)
244
+ finally:
245
+ await state.close()
246
+
247
+
248
+ def _plan_dict(plan: Plan, environment: str) -> dict:
249
+ """The plan as data — mirrors the HTTP API's PlanResponse shape."""
250
+ reused = {snapshot.name for snapshot in plan.reuses}
251
+ return {
252
+ "environment": environment,
253
+ "changes": [
254
+ {
255
+ "name": change.name,
256
+ "change_type": change.change_type.value,
257
+ "category": change.category.value if change.category else None,
258
+ "reused": change.name in reused,
259
+ "previous_fingerprint": change.previous_fingerprint,
260
+ "new_fingerprint": change.new_fingerprint,
261
+ }
262
+ for change in plan.changes
263
+ ],
264
+ "transfers": [
265
+ f"{t.model}: {t.source.name} -> {t.target.name} ({t.via} -> {t.table.schema}.{t.table.name})"
266
+ for t in plan.transfers
267
+ ],
268
+ }
269
+
270
+
271
+ async def _apply(
272
+ environment: str, path: Path, select: list[str], forward_only: bool = False, force: bool = False
273
+ ) -> None:
274
+ project = Project.load(path)
275
+ compiled = project.compile()
276
+ engines = project.open_engines()
277
+ state = await project.open_state()
278
+ try:
279
+ # Stream-fed projects must build without the daemon ever having run:
280
+ # declared stream tables are ensured (empty) so models reading them work.
281
+ if project.streams:
282
+ await ensure_stream_tables(project.streams, engines.get())
283
+ plan_result = await diff(
284
+ compiled, environment, state, select=_selection(compiled, select), forward_only=forward_only
285
+ )
286
+ _render(plan_result, environment)
287
+ if plan_result.is_empty:
288
+ return
289
+ if plan_result.has_breaking_changes and not force: # same guard the HTTP API enforces
290
+ breaking = ", ".join(
291
+ c.name for c in plan_result.changes if c.category is not None and c.category.value == "breaking"
292
+ )
293
+ console.print(f"[red]plan has breaking changes ({breaking}); re-run with --force to proceed[/red]")
294
+ raise typer.Exit(1)
295
+ progress = _build_progress(plan_result)
296
+ try:
297
+ with progress.progress if progress else contextlib.nullcontext():
298
+ result = await apply_plan(
299
+ plan_result,
300
+ compiled=compiled,
301
+ engines=engines,
302
+ state=state,
303
+ base_path=project.root,
304
+ on_progress=progress,
305
+ )
306
+ except CheckError as exc:
307
+ console.print(f"[red]{exc.message}[/red]")
308
+ raise typer.Exit(1) from exc
309
+ _render_build_results(result, compiled)
310
+ _render_checks(result)
311
+ console.print(
312
+ f"[green]Built {len(set(result.built))} model(s); promoted {result.promoted} to '{environment}'.[/green]"
313
+ )
314
+ finally:
315
+ await state.close()
316
+ engines.close()
317
+
318
+
319
+ @app.command()
320
+ def run(
321
+ environment: str = _ENV, path: Path = _PATH, select: list[str] = _SELECT, start: str = _START, end: str = _END
322
+ ) -> None:
323
+ """Force-build models and promote, ignoring change detection.
324
+
325
+ For incremental_by_time models, --start/--end set the catchup window
326
+ (default: the latest grain interval).
327
+ """
328
+ asyncio.run(_execute(environment, path, select, start, end, restate=False))
329
+
330
+
331
+ @app.command()
332
+ def restate(
333
+ environment: str = _ENV, path: Path = _PATH, select: list[str] = _SELECT, start: str = _START, end: str = _END
334
+ ) -> None:
335
+ """Reprocess incremental models over a window, ignoring the ledger (vs run, which skips filled)."""
336
+ asyncio.run(_execute(environment, path, select, start, end, restate=True))
337
+
338
+
339
+ def _window(value: str, flag: str) -> datetime | None:
340
+ if not value:
341
+ return None
342
+ try:
343
+ parsed = datetime.fromisoformat(value)
344
+ except ValueError as exc:
345
+ console.print(f"[red]{flag} must be an ISO timestamp (e.g. 2026-07-01T00:00:00); got {value!r}[/red]")
346
+ raise typer.Exit(2) from exc
347
+ if parsed.tzinfo is not None:
348
+ # the interval ledger stores naive local timestamps; one aware window would
349
+ # poison every later naive/aware comparison for that model
350
+ parsed = parsed.astimezone().replace(tzinfo=None)
351
+ return parsed
352
+
353
+
354
+ async def _execute(environment: str, path: Path, select: list[str], start: str, end: str, *, restate: bool) -> None:
355
+ window_start = _window(start, "--start")
356
+ window_end = _window(end, "--end")
357
+ project = Project.load(path)
358
+ compiled = project.compile()
359
+ engines = project.open_engines()
360
+ state = await project.open_state()
361
+ try:
362
+ if project.streams: # as in _apply: stream tables must exist daemon or not
363
+ await ensure_stream_tables(project.streams, engines.get())
364
+ plan_result = await run_plan(
365
+ compiled,
366
+ environment,
367
+ state,
368
+ start=window_start,
369
+ end=window_end,
370
+ select=_selection(compiled, select),
371
+ restate=restate,
372
+ )
373
+ progress = _build_progress(plan_result)
374
+ try:
375
+ with progress.progress if progress else contextlib.nullcontext():
376
+ result = await apply_plan(
377
+ plan_result,
378
+ compiled=compiled,
379
+ engines=engines,
380
+ state=state,
381
+ base_path=project.root,
382
+ on_progress=progress,
383
+ )
384
+ except CheckError as exc:
385
+ console.print(f"[red]{exc.message}[/red]")
386
+ raise typer.Exit(1) from exc
387
+ _render_build_results(result, compiled)
388
+ _render_checks(result)
389
+ verb = "Restated" if restate else "Ran"
390
+ console.print(
391
+ f"[green]{verb} {len(set(result.built))} model(s) ({len(result.built)} task(s)); "
392
+ f"promoted {result.promoted} to '{environment}'.[/green]"
393
+ )
394
+ finally:
395
+ await state.close()
396
+ engines.close()
397
+
398
+
399
+ @app.command()
400
+ def gc(
401
+ path: Path = _PATH,
402
+ grace: str = typer.Option("7d", "--grace", help="Keep unreferenced snapshots younger than this (e.g. 7d, 12h)."),
403
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would be removed without touching anything."),
404
+ ) -> None:
405
+ """Garbage-collect snapshots no environment references, and their physical tables."""
406
+ asyncio.run(_gc(path, grace, dry_run))
407
+
408
+
409
+ async def _gc(path: Path, grace: str, dry_run: bool) -> None:
410
+ from interlace.state.interval import parse_grain
411
+ from interlace.state.janitor import gc as run_gc
412
+ from interlace.streaming.materializer import sweep_streams
413
+
414
+ try:
415
+ parsed_grace = parse_grain(grace)
416
+ except ValueError as exc:
417
+ console.print(f"[red]--grace must be a grain like 7d or 12h; got {grace!r}[/red]")
418
+ raise typer.Exit(2) from exc
419
+ project = Project.load(path)
420
+ engines = project.open_engines()
421
+ state = await project.open_state()
422
+ try:
423
+ result = await run_gc(state, engines=engines, grace=parsed_grace, dry_run=dry_run)
424
+ verb = "Would remove" if dry_run else "Removed"
425
+ console.print(
426
+ f"{verb} {len(result.removed_snapshots)} snapshot(s), dropped {len(result.dropped_tables)} table(s); "
427
+ f"{result.kept_snapshots} snapshot(s) kept."
428
+ )
429
+ for table in result.dropped_tables:
430
+ console.print(f" - {table}")
431
+ if project.streams and not dry_run:
432
+ log = await project.open_stream_log()
433
+ try:
434
+ swept = await sweep_streams(project.streams, log, engines.get())
435
+ finally:
436
+ await log.close()
437
+ if swept:
438
+ console.print("Stream retention: " + ", ".join(f"{k} -{v}" for k, v in swept.items()))
439
+ finally:
440
+ await state.close()
441
+ engines.close()
442
+
443
+
444
+ @app.command()
445
+ def scheduler(
446
+ environment: str = _ENV,
447
+ path: Path = _PATH,
448
+ interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
449
+ once: bool = typer.Option(False, "--once", help="Run a single tick + drain, then exit."),
450
+ ) -> None:
451
+ """Run the scheduler: tick triggers, enqueue due runs, and execute them."""
452
+ asyncio.run(_scheduler(environment, path, interval, once))
453
+
454
+
455
+ async def _scheduler(environment: str, path: Path, interval: float, once: bool) -> None:
456
+ project = Project.load(path)
457
+ compiled = project.compile()
458
+ engines = project.open_engines()
459
+ state = await project.open_state()
460
+ trigger_engine = TriggerEngine(build_triggers(compiled), state)
461
+ try:
462
+ while True:
463
+ await trigger_engine.tick(datetime.now())
464
+ ran = await drain(state, compiled, engines=engines, environment=environment, base_path=project.root)
465
+ if ran:
466
+ console.print(f"[green]ran {ran} scheduled run(s) in '{environment}'[/green]")
467
+ if once:
468
+ break
469
+ await asyncio.sleep(interval)
470
+ finally:
471
+ await state.close()
472
+ engines.close()
473
+
474
+
475
+ @app.command()
476
+ def serve(
477
+ environment: str = _ENV,
478
+ path: Path = _PATH,
479
+ host: str = typer.Option("127.0.0.1", "--host", help="Bind host."),
480
+ port: int = typer.Option(8000, "--port", help="Bind port."),
481
+ quack: str = typer.Option(
482
+ "", "--quack", help="Also serve the warehouse over the quack protocol, e.g. quack:localhost:4213."
483
+ ),
484
+ quack_token: str = typer.Option(
485
+ "", "--quack-token", help="Auth token for --quack (default: generated and printed)."
486
+ ),
487
+ scheduler: bool = typer.Option(
488
+ True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
489
+ ),
490
+ interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
491
+ ) -> None:
492
+ """Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
493
+
494
+ Use --no-scheduler for an API-only process (run `interlace scheduler` separately).
495
+ """
496
+ try:
497
+ import uvicorn
498
+
499
+ from interlace.service.app import create_app
500
+ except ImportError as exc:
501
+ console.print("[red]The HTTP API needs the 'service' extra: pip install 'interlaced[service]'[/red]")
502
+ raise typer.Exit(1) from exc
503
+ token = quack_token
504
+ if quack and not token:
505
+ import secrets
506
+
507
+ token = secrets.token_hex(8)
508
+ console.print(f"[bold]quack[/bold] warehouse at [cyan]{quack}[/cyan] · token [yellow]{token}[/yellow]")
509
+ console.print("Clients: set [bold]database: quack:...[/bold] and INTERLACE_QUACK_TOKEN in the environment.")
510
+ uvicorn.run(
511
+ create_app(
512
+ path,
513
+ environment,
514
+ quack=quack or None,
515
+ quack_token=token or None,
516
+ scheduler=scheduler,
517
+ scheduler_interval=interval,
518
+ ),
519
+ host=host,
520
+ port=port,
521
+ )
522
+
523
+
524
+ @app.command("models")
525
+ def list_models(path: Path = _PATH, select: list[str] = _SELECT, as_json: bool = _JSON) -> None:
526
+ """List models with their materialisation, strategy, engine, and dependencies."""
527
+ project = Project.load(path)
528
+ compiled = project.compile()
529
+ chosen = _selection(compiled, select)
530
+ rows: list[dict[str, Any]] = [
531
+ {
532
+ "name": name,
533
+ "output": "sink" if compiled.models[name].export is not None else compiled.models[name].materialise,
534
+ "strategy": (
535
+ compiled.models[name].export.mode # type: ignore[union-attr]
536
+ if compiled.models[name].export is not None
537
+ else compiled.models[name].strategy
538
+ ),
539
+ "engine": compiled.models[name].engine,
540
+ "depends_on": list(compiled.models[name].dependencies),
541
+ }
542
+ for name in compiled.graph.topological_sort()
543
+ if chosen is None or name in chosen
544
+ ]
545
+ if as_json:
546
+ _emit_json(rows)
547
+ return
548
+ multi_engine = len({m.engine for m in compiled.models.values()}) > 1
549
+ table = _table("Models")
550
+ table.add_column("Model")
551
+ table.add_column("Output", style="dim")
552
+ table.add_column("Strategy", style="dim")
553
+ if multi_engine:
554
+ table.add_column("Engine", style="dim")
555
+ table.add_column("Depends on", style="dim", no_wrap=True)
556
+ for row in rows:
557
+ cells = [row["name"], row["output"], row["strategy"]]
558
+ if multi_engine:
559
+ cells.append(row["engine"])
560
+ table.add_row(*cells, ", ".join(row["depends_on"]) or "—")
561
+ console.print(table)
562
+
563
+
564
+ app.command("list", hidden=True)(list_models) # deprecated alias for `models`
565
+
566
+
567
+ env_app = typer.Typer(no_args_is_help=True, help="Inspect and manage environments.")
568
+ app.add_typer(env_app, name="env")
569
+
570
+
571
+ @env_app.command("list")
572
+ def env_list(path: Path = _PATH, as_json: bool = _JSON) -> None:
573
+ """List environments: promoted models and drift against the compiled project."""
574
+ asyncio.run(_envs(path, as_json))
575
+
576
+
577
+ @env_app.command("drop")
578
+ def env_drop(
579
+ name: str = typer.Argument(..., help="Environment to remove."),
580
+ path: Path = _PATH,
581
+ force: bool = typer.Option(False, "--force", help="Required to drop the production environment."),
582
+ ) -> None:
583
+ """Drop an environment: its views go, its snapshots become reclaimable by gc."""
584
+ asyncio.run(_env_drop(name, path, force))
585
+
586
+
587
+ async def _env_drop(name: str, path: Path, force: bool) -> None:
588
+ from interlace.plan.plan import PRODUCTION_ENV
589
+ from interlace.state.janitor import drop_environment
590
+
591
+ if name == PRODUCTION_ENV and not force:
592
+ console.print(f"[red]{name!r} is the production environment (unprefixed views); pass --force to drop it.[/red]")
593
+ raise typer.Exit(1)
594
+ project = Project.load(path)
595
+ engines = project.open_engines()
596
+ state = await project.open_state()
597
+ try:
598
+ if not await state.get_environment(name):
599
+ console.print(f"No environment {name!r}.")
600
+ raise typer.Exit(1)
601
+ dropped = await drop_environment(state, engines=engines, environment=name)
602
+ console.print(f"Dropped environment [bold]{name}[/bold] ({len(dropped)} view(s) removed).")
603
+ console.print("[dim]Its snapshots are now unreferenced — `interlace gc` reclaims their tables.[/dim]")
604
+ finally:
605
+ await state.close()
606
+ engines.close()
607
+
608
+
609
+ async def _envs(path: Path, as_json: bool = False) -> None:
610
+ from interlace.plan.plan import PRODUCTION_ENV
611
+
612
+ project = Project.load(path)
613
+ compiled = project.compile()
614
+ state = await project.open_state()
615
+ try:
616
+ names = await state.list_environments()
617
+ rows: list[dict[str, Any]] = []
618
+ for name in names:
619
+ promoted = await state.get_environment(name)
620
+ drift = sum(1 for m in compiled.models.values() if promoted.get(m.name) != m.fingerprint)
621
+ views = "main.* (production)" if name == PRODUCTION_ENV else f"{name}__*.*"
622
+ rows.append({"name": name, "views": views, "models": len(promoted), "drift": drift})
623
+ if as_json:
624
+ _emit_json(rows)
625
+ return
626
+ if not names:
627
+ console.print("No environments promoted yet — run [bold]interlace apply[/bold].")
628
+ return
629
+ table = _table("Environments")
630
+ table.add_column("Environment")
631
+ table.add_column("Views", style="dim")
632
+ table.add_column("Models", justify="right")
633
+ table.add_column("Drift", justify="right")
634
+ for row in rows:
635
+ drift_cell = f"[yellow]{row['drift']}[/]" if row["drift"] else "[dim]—[/]"
636
+ table.add_row(str(row["name"]), str(row["views"]), str(row["models"]), drift_cell)
637
+ console.print(table)
638
+ finally:
639
+ await state.close()
640
+
641
+
642
+ @app.command()
643
+ def runs(
644
+ path: Path = _PATH,
645
+ limit: int = typer.Option(20, "--limit", "-n", help="Rows to show."),
646
+ as_json: bool = _JSON,
647
+ ) -> None:
648
+ """Recent runs from the durable queue (newest first)."""
649
+ asyncio.run(_runs(path, limit, as_json))
650
+
651
+
652
+ async def _runs(path: Path, limit: int, as_json: bool = False) -> None:
653
+ project = Project.load(path)
654
+ state = await project.open_state()
655
+ try:
656
+ recorded = await state.list_runs(limit)
657
+ if as_json:
658
+ _emit_json(recorded)
659
+ return
660
+ if not recorded:
661
+ console.print(
662
+ "No runs recorded. The queue holds daemon-triggered work — schedules, stream flushes, "
663
+ "POST /runs — while [bold]interlace apply[/bold]/[bold]run[/bold] execute immediately "
664
+ "without enqueueing. Start one with [bold]interlace serve[/bold] or "
665
+ "[bold]interlace scheduler[/bold]."
666
+ )
667
+ return
668
+ table = _table("Runs")
669
+ table.add_column("Id", style="dim")
670
+ table.add_column("State")
671
+ table.add_column("Trigger", style="dim")
672
+ table.add_column("Models")
673
+ table.add_column("Enqueued", style="dim")
674
+ table.add_column("Error")
675
+ state_colours = {"succeeded": "green", "failed": "red", "running": "cyan", "cancelled": "dim"}
676
+ for run in recorded:
677
+ key = str(run["idempotency_key"] or "")
678
+ trigger = key.split(":", 1)[0] if ":" in key else "manual"
679
+ models = ", ".join(run["flow_selector"][:3]) + (" …" if len(run["flow_selector"]) > 3 else "")
680
+ enqueued = str(run["enqueued_at"] or "")[:19]
681
+ state_cell = f"[{state_colours.get(str(run['state']), 'yellow')}]{run['state']}[/]"
682
+ error = f"[red]{str(run['error'])[:60]}[/]" if run["error"] else "[dim]—[/]"
683
+ table.add_row(str(run["id"]), state_cell, trigger, models, enqueued, error)
684
+ console.print(table)
685
+ finally:
686
+ await state.close()
687
+
688
+
689
+ @app.command()
690
+ def cancel(run_id: int = typer.Argument(..., help="Run id (see `interlace runs`)."), path: Path = _PATH) -> None:
691
+ """Cancel a run: queued cancels now; running cancels at the worker's next heartbeat."""
692
+ asyncio.run(_cancel(run_id, path))
693
+
694
+
695
+ async def _cancel(run_id: int, path: Path) -> None:
696
+ project = Project.load(path)
697
+ state = await project.open_state()
698
+ try:
699
+ outcome = await state.request_cancel(run_id)
700
+ if outcome is None:
701
+ console.print(f"[red]run {run_id} is unknown or already finished[/red]")
702
+ raise typer.Exit(1)
703
+ console.print(f"run {run_id}: [bold]{outcome}[/bold]")
704
+ finally:
705
+ await state.close()
706
+
707
+
708
+ checks_app = typer.Typer(no_args_is_help=True, help="Run and inspect data-quality checks.")
709
+ app.add_typer(checks_app, name="checks")
710
+
711
+
712
+ @checks_app.command("list")
713
+ def checks_list(
714
+ path: Path = _PATH,
715
+ model: str = typer.Option("", "--model", "-m", help="Filter to one model."),
716
+ limit: int = typer.Option(20, "--limit", "-n", help="Rows to show."),
717
+ as_json: bool = _JSON,
718
+ ) -> None:
719
+ """Recent data-quality check results (newest first)."""
720
+ asyncio.run(_checks(path, model or None, limit, as_json))
721
+
722
+
723
+ @checks_app.command("run")
724
+ def checks_run(environment: str = _ENV, path: Path = _PATH, select: list[str] = _SELECT, as_json: bool = _JSON) -> None:
725
+ """Run checks against an environment's promoted tables — no rebuild.
726
+
727
+ Results are recorded, so `interlace checks list` shows them. Exits 1 when
728
+ any error-severity check fails.
729
+ """
730
+ asyncio.run(_checks_run(environment, path, select, as_json))
731
+
732
+
733
+ async def _checks_run(environment: str, path: Path, select: list[str], as_json: bool = False) -> None:
734
+ from dataclasses import asdict
735
+
736
+ from interlace.checks.runner import CheckOutcome, run_checks
737
+
738
+ project = Project.load(path)
739
+ compiled = project.compile()
740
+ chosen = _selection(compiled, select)
741
+ engines_registry = project.open_engines()
742
+ state = await project.open_state()
743
+ try:
744
+ promoted = await state.get_environment(environment)
745
+ if not promoted:
746
+ console.print(f"[red]no environment {environment!r} — run `interlace apply` first[/red]")
747
+ raise typer.Exit(1)
748
+ snapshots = await state.get_snapshots(promoted.items())
749
+ physical = {name: snapshot.physical_table for (name, _), snapshot in snapshots.items()}
750
+ outcomes: list[CheckOutcome] = []
751
+ skipped: list[str] = []
752
+ for name, model in compiled.models.items():
753
+ if chosen is not None and name not in chosen:
754
+ continue
755
+ if not model.checks and not compiled.python_checks.get(name):
756
+ continue
757
+ if model.export is not None: # sinks have no physical table to check
758
+ continue
759
+ snapshot = snapshots.get((name, promoted.get(name, "")))
760
+ if snapshot is None: # declared but never promoted here: nothing to check against
761
+ skipped.append(name)
762
+ continue
763
+ # the SNAPSHOT's engine, not the compiled model's: an engine re-pin since
764
+ # the last promote means the promoted table still lives on the old engine
765
+ engine = engines_registry.require(snapshot.engine, model=name)
766
+ results = await run_checks(
767
+ model, compiled, engine, snapshot.physical_table, compiled.python_checks.get(name, ()), physical
768
+ )
769
+ if results:
770
+ await state.record_check_results(environment, snapshot.fingerprint, results)
771
+ outcomes.extend(results)
772
+ finally:
773
+ await state.close()
774
+ engines_registry.close()
775
+
776
+ blocking = [o for o in outcomes if o.blocking]
777
+ if as_json:
778
+ _emit_json([asdict(o) for o in outcomes])
779
+ else:
780
+ colours = {"passed": "green", "failed": "red", "error": "yellow"}
781
+ for outcome in outcomes:
782
+ colour = colours.get(outcome.status, "white")
783
+ failures = f" ({outcome.failures} failing)" if outcome.failures else ""
784
+ console.print(f"[{colour}]{outcome.status:6}[/] {outcome.model}.{outcome.name}{failures}")
785
+ for name in skipped:
786
+ console.print(f"[dim]skip {name} — not promoted in '{environment}'[/dim]")
787
+ passed = sum(1 for o in outcomes if o.status == "passed")
788
+ console.print(f"Checks: {passed}/{len(outcomes)} passed against '{environment}'.")
789
+ if blocking:
790
+ raise typer.Exit(1)
791
+
792
+
793
+ async def _checks(path: Path, model: str | None, limit: int, as_json: bool = False) -> None:
794
+ project = Project.load(path)
795
+ state = await project.open_state()
796
+ try:
797
+ rows = await state.list_check_results(model, limit)
798
+ if as_json:
799
+ _emit_json(rows)
800
+ return
801
+ table = _table("Check results")
802
+ table.add_column("Env", style="dim")
803
+ table.add_column("Model")
804
+ table.add_column("Check")
805
+ table.add_column("Severity", style="dim")
806
+ table.add_column("Status")
807
+ table.add_column("Failures", justify="right")
808
+ table.add_column("At", style="dim")
809
+ colours = {"passed": "green", "failed": "red", "error": "yellow"}
810
+ for row in rows:
811
+ status = str(row["status"])
812
+ table.add_row(
813
+ str(row["environment"]),
814
+ str(row["model"]),
815
+ str(row["check_name"]),
816
+ str(row["severity"]),
817
+ f"[{colours.get(status, 'white')}]{status}[/]",
818
+ str(row["failures"] or "—"),
819
+ str(row["executed_at"])[:19],
820
+ )
821
+ console.print(table)
822
+ finally:
823
+ await state.close()
824
+
825
+
826
+ @app.command()
827
+ def streams(path: Path = _PATH, as_json: bool = _JSON) -> None:
828
+ """Declared streams with their log head and warehouse watermark."""
829
+ asyncio.run(_streams(path, as_json))
830
+
831
+
832
+ async def _streams(path: Path, as_json: bool = False) -> None:
833
+ from interlace.streaming.materializer import ensure_stream_tables, stream_watermark
834
+
835
+ project = Project.load(path)
836
+ if not project.streams:
837
+ _emit_json([]) if as_json else console.print("No streams declared.")
838
+ return
839
+ engines = project.open_engines()
840
+ log = await project.open_stream_log()
841
+ try:
842
+ engine = engines.get()
843
+ await ensure_stream_tables(project.streams, engine)
844
+ rows: list[dict[str, Any]] = []
845
+ for stream in project.streams:
846
+ head = await log.head(stream.name)
847
+ watermark = await stream_watermark(stream, engine)
848
+ rows.append(
849
+ {
850
+ "name": stream.name,
851
+ "table": f"streams.{stream.name}",
852
+ "on_schema_drift": stream.on_schema_drift,
853
+ "retention": stream.retention,
854
+ "head": head,
855
+ "watermark": watermark,
856
+ "pending": max(0, head - watermark),
857
+ }
858
+ )
859
+ if as_json:
860
+ _emit_json(rows)
861
+ return
862
+ table = _table("Streams")
863
+ table.add_column("Stream")
864
+ table.add_column("Table", style="dim")
865
+ table.add_column("Drift", style="dim")
866
+ table.add_column("Retention", style="dim")
867
+ table.add_column("Head", justify="right")
868
+ table.add_column("Watermark", justify="right")
869
+ table.add_column("Pending", justify="right")
870
+ for row in rows:
871
+ table.add_row(
872
+ row["name"],
873
+ row["table"],
874
+ row["on_schema_drift"],
875
+ row["retention"] or "—",
876
+ str(row["head"]),
877
+ str(row["watermark"]),
878
+ f"[yellow]{row['pending']}[/]" if row["pending"] else "[dim]—[/]",
879
+ )
880
+ console.print(table)
881
+ finally:
882
+ await log.close()
883
+ engines.close()
884
+
885
+
886
+ @app.command()
887
+ def engines(path: Path = _PATH, as_json: bool = _JSON) -> None:
888
+ """Configured execution engines (models pin to these with `engine:`)."""
889
+ project = Project.load(path)
890
+ configs = project.config.engine_configs()
891
+ rows: list[dict[str, Any]] = []
892
+ for name in sorted(configs):
893
+ cfg = configs[name]
894
+ database = cfg.database or ""
895
+ if cfg.type == "postgres" and "@" in database: # never print credentials
896
+ database = "postgresql://…" + database.rsplit("@", 1)[-1]
897
+ rows.append(
898
+ {
899
+ "name": name,
900
+ "default": name == project.config.default_engine,
901
+ "type": cfg.type,
902
+ "dialect": cfg.resolved_dialect(),
903
+ "database": database,
904
+ }
905
+ )
906
+ if as_json:
907
+ _emit_json(rows)
908
+ return
909
+ table = _table("Engines")
910
+ table.add_column("Engine")
911
+ table.add_column("Type", style="dim")
912
+ table.add_column("Dialect", style="dim")
913
+ table.add_column("Database", style="dim")
914
+ for row in rows:
915
+ marker = " (default)" if row["default"] else ""
916
+ table.add_row(f"{row['name']}{marker}", row["type"], row["dialect"], row["database"] or "—")
917
+ console.print(table)
918
+
919
+
920
+ @app.command()
921
+ def lineage(
922
+ model: str = typer.Argument(..., help="Model name."),
923
+ path: Path = _PATH,
924
+ columns: bool = typer.Option(False, "--columns", "-c", help="Show column-level lineage."),
925
+ fmt: str = typer.Option("text", "--format", "-f", help="Output format: text, json, or dot (Graphviz)."),
926
+ ) -> None:
927
+ """Show a model's lineage — table-level, or column-level with --columns."""
928
+ project = Project.load(path)
929
+ compiled = project.compile()
930
+ if model not in compiled.models:
931
+ console.print(f"[red]unknown model: {model}[/red]")
932
+ raise typer.Exit(1)
933
+ if fmt not in ("text", "json", "dot"):
934
+ console.print(f"[red]unknown format {fmt!r}; expected text, json, or dot[/red]")
935
+ raise typer.Exit(2)
936
+
937
+ upstream = sorted(compiled.graph.ancestors(model))
938
+ downstream = sorted(compiled.graph.descendants(model))
939
+ sources = column_lineage(compiled).get(model, {}) if columns else {}
940
+
941
+ if fmt == "dot":
942
+ typer.echo(_lineage_dot(compiled, model, upstream, downstream, sources))
943
+ return
944
+ if fmt == "json":
945
+ data: dict = {"model": model, "upstream": upstream, "downstream": downstream}
946
+ if columns:
947
+ data["columns"] = {out: [f"{table}.{col}" for table, col in refs] for out, refs in sources.items()}
948
+ _emit_json(data)
949
+ return
950
+
951
+ if columns:
952
+ console.print(f"[bold]{model}[/bold] columns")
953
+ if not sources:
954
+ console.print(" (column lineage unavailable)")
955
+ for output, refs in sources.items():
956
+ rendered = ", ".join(f"{table}.{column}" for table, column in refs) or "—"
957
+ console.print(f" {output} ← {rendered}")
958
+ return
959
+ console.print(f"[bold]{model}[/bold]")
960
+ console.print(f" upstream: {', '.join(upstream) or '—'}")
961
+ console.print(f" downstream: {', '.join(downstream) or '—'}")
962
+
963
+
964
+ def _lineage_dot(
965
+ compiled: CompiledProject,
966
+ model: str,
967
+ upstream: list[str],
968
+ downstream: list[str],
969
+ sources: dict[str, list[tuple[str, str]]],
970
+ ) -> str:
971
+ """The model's dependency neighbourhood as a Graphviz digraph (pipe to `dot -Tsvg`)."""
972
+
973
+ def node(name: str) -> str:
974
+ return '"' + name.replace('"', '\\"') + '"'
975
+
976
+ subgraph = {model, *upstream, *downstream}
977
+ lines = ["digraph lineage {", " rankdir=LR;", f" {node(model)} [style=bold];"]
978
+ for name in sorted(subgraph):
979
+ for dep in compiled.models[name].dependencies:
980
+ if dep in subgraph:
981
+ lines.append(f" {node(dep)} -> {node(name)};")
982
+ for output, refs in sources.items(): # column edges when --columns
983
+ for table, column in refs:
984
+ lines.append(f" {node(f'{table}.{column}')} -> {node(f'{model}.{output}')} [color=gray];")
985
+ lines.append("}")
986
+ return "\n".join(lines)
987
+
988
+
989
+ apikey_app = typer.Typer(no_args_is_help=True, help="Manage HTTP API keys.")
990
+ app.add_typer(apikey_app, name="apikey")
991
+
992
+
993
+ @apikey_app.command("create")
994
+ def apikey_create(
995
+ name: str = typer.Argument(..., help="A label for the key."),
996
+ path: Path = _PATH,
997
+ scope: list[str] = typer.Option(["read"], "--scope", help="Scopes: read, write, admin."),
998
+ ) -> None:
999
+ """Create an API key and print it once."""
1000
+ asyncio.run(_apikey_create(name, path, scope))
1001
+
1002
+
1003
+ async def _apikey_create(name: str, path: Path, scopes: list[str]) -> None:
1004
+ state = await Project.load(path).open_state()
1005
+ try:
1006
+ token = await state.create_api_key(name, scopes)
1007
+ finally:
1008
+ await state.close()
1009
+ console.print(f"[green]created API key '{name}' ({', '.join(scopes)})[/green]")
1010
+ console.print(f" {token}")
1011
+ console.print("[yellow]store it now — it will not be shown again[/yellow]")
1012
+
1013
+
1014
+ @apikey_app.command("list")
1015
+ def apikey_list(path: Path = _PATH) -> None:
1016
+ """List API keys (names and scopes, not the secrets)."""
1017
+ asyncio.run(_apikey_list(path))
1018
+
1019
+
1020
+ async def _apikey_list(path: Path) -> None:
1021
+ state = await Project.load(path).open_state()
1022
+ try:
1023
+ keys = await state.list_api_keys()
1024
+ finally:
1025
+ await state.close()
1026
+ table = _table("API keys")
1027
+ table.add_column("Name")
1028
+ table.add_column("Scopes")
1029
+ table.add_column("Created", style="dim")
1030
+ for key in keys:
1031
+ table.add_row(str(key["name"]), ", ".join(key["scopes"]), str(key["created_at"])) # type: ignore[arg-type]
1032
+ console.print(table)
1033
+
1034
+
1035
+ def _render(plan: Plan, environment: str) -> None:
1036
+ if plan.is_empty:
1037
+ console.print(f"No changes for [bold]{environment}[/bold].")
1038
+ return
1039
+ reused = {snapshot.name for snapshot in plan.reuses}
1040
+ table = _table(f"Plan · {environment}")
1041
+ table.add_column("Model")
1042
+ table.add_column("Change")
1043
+ table.add_column("Category")
1044
+ table.add_column("Build")
1045
+ change_colours = {"added": "green", "removed": "red", "modified": "yellow"}
1046
+ category_colours = {"breaking": "red", "non_breaking": "green", "forward_only": "cyan"}
1047
+ for change in plan.changes:
1048
+ build = (
1049
+ "[cyan]reuse[/]"
1050
+ if change.name in reused
1051
+ else ("[dim]—[/]" if change.change_type is ChangeType.REMOVED else "rebuild")
1052
+ )
1053
+ kind = change.change_type.value
1054
+ category = change.category.value if change.category else None
1055
+ table.add_row(
1056
+ change.name,
1057
+ f"[{change_colours.get(kind, 'white')}]{kind}[/]",
1058
+ f"[{category_colours.get(category, 'white')}]{category}[/]" if category else "[dim]—[/]",
1059
+ build,
1060
+ )
1061
+ console.print(table)
1062
+ if reused:
1063
+ console.print(f"[dim]{len(reused)} model(s) have provably identical output — reusing existing tables.[/dim]")
1064
+ for transfer in plan.transfers:
1065
+ console.print(
1066
+ f"[cyan]transfer[/cyan] {transfer.model}: {transfer.source.name} → {transfer.target.name} "
1067
+ f"({transfer.via} → {transfer.table.schema}.{transfer.table.name})"
1068
+ )
1069
+
1070
+
1071
+ def main() -> None:
1072
+ app()