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.
- interlace/__init__.py +26 -0
- interlace/checks/__init__.py +10 -0
- interlace/checks/builtin.py +152 -0
- interlace/checks/runner.py +103 -0
- interlace/checks/spec.py +113 -0
- interlace/cli/__init__.py +7 -0
- interlace/cli/main.py +1072 -0
- interlace/config/__init__.py +7 -0
- interlace/config/config.py +172 -0
- interlace/contracts.py +35 -0
- interlace/dsl/__init__.py +16 -0
- interlace/dsl/decorators.py +222 -0
- interlace/dsl/discovery.py +75 -0
- interlace/dsl/sql_config.py +47 -0
- interlace/engines/__init__.py +16 -0
- interlace/engines/base.py +86 -0
- interlace/engines/duckdb.py +249 -0
- interlace/engines/postgres.py +157 -0
- interlace/engines/quack.py +113 -0
- interlace/engines/registry.py +125 -0
- interlace/exceptions.py +66 -0
- interlace/exports.py +145 -0
- interlace/graph/__init__.py +17 -0
- interlace/graph/column_lineage.py +69 -0
- interlace/graph/dag.py +81 -0
- interlace/graph/project.py +242 -0
- interlace/graph/selectors.py +45 -0
- interlace/ir/__init__.py +24 -0
- interlace/ir/canonicalize.py +71 -0
- interlace/ir/fingerprint.py +55 -0
- interlace/ir/relation.py +71 -0
- interlace/ir/schema.py +23 -0
- interlace/plan/__init__.py +23 -0
- interlace/plan/apply.py +527 -0
- interlace/plan/differ.py +211 -0
- interlace/plan/plan.py +177 -0
- interlace/plan/resolve.py +74 -0
- interlace/plan/run.py +79 -0
- interlace/project.py +278 -0
- interlace/py.typed +0 -0
- interlace/runtime/__init__.py +6 -0
- interlace/runtime/handles.py +48 -0
- interlace/runtime/python_model.py +175 -0
- interlace/scaffold.py +83 -0
- interlace/scheduler/__init__.py +17 -0
- interlace/scheduler/engine.py +66 -0
- interlace/scheduler/triggers.py +84 -0
- interlace/scheduler/worker.py +154 -0
- interlace/service/__init__.py +7 -0
- interlace/service/app.py +836 -0
- interlace/service/auth.py +41 -0
- interlace/state/__init__.py +18 -0
- interlace/state/interval.py +134 -0
- interlace/state/janitor.py +142 -0
- interlace/state/snapshot.py +47 -0
- interlace/state/store.py +906 -0
- interlace/strategies/__init__.py +64 -0
- interlace/strategies/base.py +71 -0
- interlace/strategies/full.py +38 -0
- interlace/strategies/full_merge.py +98 -0
- interlace/strategies/incremental_by_time.py +65 -0
- interlace/strategies/merge_by_key.py +69 -0
- interlace/strategies/scd_type_2.py +122 -0
- interlace/strategies/view.py +33 -0
- interlace/streaming/__init__.py +18 -0
- interlace/streaming/log.py +298 -0
- interlace/streaming/materializer.py +166 -0
- interlace/streaming/schema.py +218 -0
- interlaced-2.0.0a2.dist-info/METADATA +216 -0
- interlaced-2.0.0a2.dist-info/RECORD +74 -0
- interlaced-2.0.0a2.dist-info/WHEEL +5 -0
- interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
- interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
- 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()
|