interlaced 2.4.0__tar.gz → 2.5.0__tar.gz
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.
- {interlaced-2.4.0/src/interlaced.egg-info → interlaced-2.5.0}/PKG-INFO +3 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/README.md +2 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/pyproject.toml +3 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/cli/main.py +194 -63
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/config/config.py +6 -3
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/decorators.py +33 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/discovery.py +73 -10
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/base.py +20 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/bigquery.py +3 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/duckdb.py +27 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/postgres.py +30 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/redshift.py +3 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/snowflake.py +3 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/spark.py +3 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/exceptions.py +4 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/project.py +28 -6
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/fingerprint.py +12 -0
- interlaced-2.5.0/src/interlace/ir/macros.py +154 -0
- interlaced-2.5.0/src/interlace/physical/__init__.py +1 -0
- interlaced-2.5.0/src/interlace/physical/annotate.py +80 -0
- interlaced-2.5.0/src/interlace/physical/ddl.py +177 -0
- interlaced-2.5.0/src/interlace/physical/drift.py +80 -0
- interlaced-2.5.0/src/interlace/physical/reconcile.py +76 -0
- interlaced-2.5.0/src/interlace/physical/spec.py +308 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/apply.py +238 -44
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/differ.py +69 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/plan.py +43 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/project.py +23 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/query.py +31 -6
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/app.py +244 -16
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/auth.py +12 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/app.css +0 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/api.js +34 -28
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/app.js +17 -7
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/checks.js +9 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/environments.js +10 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/models.js +50 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/overview.js +70 -49
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/plan.js +19 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/system.js +51 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/janitor.py +148 -2
- interlaced-2.5.0/src/interlace/state/locks.py +59 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/snapshot.py +3 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/store.py +151 -3
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/log.py +22 -1
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/materializer.py +39 -13
- {interlaced-2.4.0 → interlaced-2.5.0/src/interlaced.egg-info}/PKG-INFO +3 -2
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/SOURCES.txt +8 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/LICENSE +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/MANIFEST.in +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/setup.cfg +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/builtin.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/contracts.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/run.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/py.typed +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scaffold.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/engine.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/triggers.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sinks.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/interval.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interlaced
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres.
|
|
5
5
|
Author-email: Mark <mark@interlace.sh>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -192,7 +192,8 @@ $ interlace plan
|
|
|
192
192
|
- **`interlace env rollback`** repoints an environment's views at any earlier promotion — nothing
|
|
193
193
|
rebuilds, the views move; every apply records a generation, so a bad deploy reverts in one
|
|
194
194
|
command. `interlace gc` removes snapshots no environment references (reference-aware: tables
|
|
195
|
-
shared through reuse survive).
|
|
195
|
+
shared through reuse survive). `interlace reset --yes` wipes owned state for a fresh apply
|
|
196
|
+
without dropping `materialise: table` / `file` destinations.
|
|
196
197
|
|
|
197
198
|
## Streaming
|
|
198
199
|
|
|
@@ -126,7 +126,8 @@ $ interlace plan
|
|
|
126
126
|
- **`interlace env rollback`** repoints an environment's views at any earlier promotion — nothing
|
|
127
127
|
rebuilds, the views move; every apply records a generation, so a bad deploy reverts in one
|
|
128
128
|
command. `interlace gc` removes snapshots no environment references (reference-aware: tables
|
|
129
|
-
shared through reuse survive).
|
|
129
|
+
shared through reuse survive). `interlace reset --yes` wipes owned state for a fresh apply
|
|
130
|
+
without dropping `materialise: table` / `file` destinations.
|
|
130
131
|
|
|
131
132
|
## Streaming
|
|
132
133
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -138,6 +138,7 @@ module = [
|
|
|
138
138
|
"sqlglot.*",
|
|
139
139
|
"pyarrow.*",
|
|
140
140
|
"yaml",
|
|
141
|
+
"adbc_driver_postgresql.*", # optional 'adbc' extra — absent in the base env
|
|
141
142
|
"adbc_driver_snowflake.*", # optional 'adbc-snowflake' extra — absent in the base env
|
|
142
143
|
"adbc_driver_bigquery.*", # optional 'adbc-bigquery' extra — absent in the base env
|
|
143
144
|
"pyspark.*", # optional 'spark' extra — absent in the base env
|
|
@@ -180,6 +181,7 @@ markers = [
|
|
|
180
181
|
"integration: Integration tests (slower, require database)",
|
|
181
182
|
"slow: Slow tests (may take a long time)",
|
|
182
183
|
"requires_db: Needs a reachable external database (e.g. Postgres)",
|
|
184
|
+
"requires_network: Needs the public internet (e.g. an example reading a remote CSV)",
|
|
183
185
|
]
|
|
184
186
|
asyncio_mode = "auto"
|
|
185
187
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
7
9
|
from datetime import datetime
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Any
|
|
@@ -15,10 +17,11 @@ from rich.markup import escape
|
|
|
15
17
|
from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
|
|
16
18
|
from rich.table import Table
|
|
17
19
|
|
|
18
|
-
from interlace.exceptions import CheckError, ConfigurationError, InterlaceError, QueryError, SelectionError
|
|
20
|
+
from interlace.exceptions import CheckError, ConfigurationError, InterlaceError, LockError, QueryError, SelectionError
|
|
19
21
|
from interlace.graph.column_lineage import column_impact, column_lineage, split_target
|
|
20
22
|
from interlace.graph.project import CompiledProject
|
|
21
23
|
from interlace.graph.selectors import select_models, wants_state
|
|
24
|
+
from interlace.physical.annotate import annotate_plan
|
|
22
25
|
from interlace.plan.apply import ApplyResult
|
|
23
26
|
from interlace.plan.apply import apply as apply_plan
|
|
24
27
|
from interlace.plan.differ import diff
|
|
@@ -29,6 +32,7 @@ from interlace.scaffold import list_templates, scaffold_project
|
|
|
29
32
|
from interlace.scheduler.engine import TriggerEngine, build_triggers
|
|
30
33
|
from interlace.scheduler.worker import drain
|
|
31
34
|
from interlace.sinks import target_ref
|
|
35
|
+
from interlace.state.locks import hold_apply_lock
|
|
32
36
|
from interlace.streaming import ensure_stream_tables, flush_streams
|
|
33
37
|
|
|
34
38
|
# pretty_exceptions_enable=False: let InterlaceError propagate out of app() so main()
|
|
@@ -107,9 +111,9 @@ _END = typer.Option("", "--end", help="Window end (ISO), for incremental models.
|
|
|
107
111
|
_FORWARD_ONLY = typer.Option(
|
|
108
112
|
False,
|
|
109
113
|
"--forward-only",
|
|
110
|
-
help="Modified history-keeping models (merge/full_merge/scd/incremental) carry their
|
|
111
|
-
"forward: it is copied to the new version, the new logic applies to the copy, and
|
|
112
|
-
"before views move. Requires a shape-compatible change.",
|
|
114
|
+
help="Modified history-keeping models (merge/full_merge/hash_merge/scd/incremental) carry their "
|
|
115
|
+
"history forward: it is copied to the new version, the new logic applies to the copy, and "
|
|
116
|
+
"checks gate before views move. Requires a shape-compatible change.",
|
|
113
117
|
)
|
|
114
118
|
_JSON = typer.Option(False, "--json", help="Emit JSON instead of a table (for scripts and CI).")
|
|
115
119
|
_PARALLELISM = typer.Option(
|
|
@@ -301,17 +305,22 @@ async def _plan(
|
|
|
301
305
|
project = Project.load(path)
|
|
302
306
|
compiled = project.compile()
|
|
303
307
|
state = await project.open_state()
|
|
308
|
+
engines = project.open_engines()
|
|
304
309
|
try:
|
|
305
310
|
promoted = await _promoted_if_needed(state, environment, select)
|
|
306
311
|
result = await diff(
|
|
307
312
|
compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
|
|
308
313
|
)
|
|
314
|
+
await annotate_plan(result, compiled, engines)
|
|
309
315
|
if as_json:
|
|
310
316
|
_emit_json(_plan_dict(result, environment))
|
|
311
317
|
else:
|
|
312
318
|
_render(result, environment)
|
|
319
|
+
if result.blocking:
|
|
320
|
+
raise typer.Exit(1)
|
|
313
321
|
finally:
|
|
314
322
|
await state.close()
|
|
323
|
+
engines.close()
|
|
315
324
|
|
|
316
325
|
|
|
317
326
|
def _plan_dict(plan: Plan, environment: str) -> dict:
|
|
@@ -334,6 +343,12 @@ def _plan_dict(plan: Plan, environment: str) -> dict:
|
|
|
334
343
|
f"{t.model}: {t.source.name} -> {t.target.name} ({t.via} -> {t.table.schema}.{t.table.name})"
|
|
335
344
|
for t in plan.transfers
|
|
336
345
|
],
|
|
346
|
+
"physical": [
|
|
347
|
+
f"{'+' if change.op == 'add' else '-'} {change.kind} {change.name}"
|
|
348
|
+
for action in plan.physical
|
|
349
|
+
for change in action.changes
|
|
350
|
+
],
|
|
351
|
+
"drift": [note.message for note in plan.drift],
|
|
337
352
|
}
|
|
338
353
|
|
|
339
354
|
|
|
@@ -358,6 +373,7 @@ async def _apply(
|
|
|
358
373
|
plan_result = await diff(
|
|
359
374
|
compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
|
|
360
375
|
)
|
|
376
|
+
await annotate_plan(plan_result, compiled, engines)
|
|
361
377
|
_render(plan_result, environment)
|
|
362
378
|
if plan_result.is_empty:
|
|
363
379
|
return
|
|
@@ -370,18 +386,22 @@ async def _apply(
|
|
|
370
386
|
progress = _build_progress(plan_result)
|
|
371
387
|
try:
|
|
372
388
|
with progress.progress if progress else contextlib.nullcontext():
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
389
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:apply"):
|
|
390
|
+
result = await apply_plan(
|
|
391
|
+
plan_result,
|
|
392
|
+
compiled=compiled,
|
|
393
|
+
engines=engines,
|
|
394
|
+
state=state,
|
|
395
|
+
base_path=project.root,
|
|
396
|
+
on_progress=progress,
|
|
397
|
+
parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
|
|
398
|
+
)
|
|
382
399
|
except CheckError as exc:
|
|
383
400
|
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
384
401
|
raise typer.Exit(1) from exc
|
|
402
|
+
except LockError as exc:
|
|
403
|
+
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
404
|
+
raise typer.Exit(1) from exc
|
|
385
405
|
_render_build_results(result, compiled)
|
|
386
406
|
_render_checks(result)
|
|
387
407
|
await _render_empty_incrementals(result, compiled, engines)
|
|
@@ -467,27 +487,32 @@ async def _execute(
|
|
|
467
487
|
restate=restate,
|
|
468
488
|
)
|
|
469
489
|
progress = _build_progress(plan_result)
|
|
490
|
+
started = time.perf_counter()
|
|
470
491
|
try:
|
|
471
492
|
with progress.progress if progress else contextlib.nullcontext():
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
493
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:run"):
|
|
494
|
+
result = await apply_plan(
|
|
495
|
+
plan_result,
|
|
496
|
+
compiled=compiled,
|
|
497
|
+
engines=engines,
|
|
498
|
+
state=state,
|
|
499
|
+
base_path=project.root,
|
|
500
|
+
on_progress=progress,
|
|
501
|
+
parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
|
|
502
|
+
)
|
|
481
503
|
except CheckError as exc:
|
|
482
504
|
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
483
505
|
raise typer.Exit(1) from exc
|
|
506
|
+
except LockError as exc:
|
|
507
|
+
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
508
|
+
raise typer.Exit(1) from exc
|
|
484
509
|
_render_build_results(result, compiled)
|
|
485
510
|
_render_checks(result)
|
|
486
511
|
_render_warnings(plan_result)
|
|
487
512
|
await _render_empty_incrementals(result, compiled, engines)
|
|
488
513
|
verb = "Restated" if restate else "Ran"
|
|
489
514
|
console.print(
|
|
490
|
-
f"[green]{verb} {len(set(result.built))} model(s)
|
|
515
|
+
f"[green]{verb} {len(set(result.built))} model(s) in {time.perf_counter() - started:.2f}s; "
|
|
491
516
|
f"promoted {result.promoted} to '{environment}'.[/green]"
|
|
492
517
|
)
|
|
493
518
|
finally:
|
|
@@ -519,7 +544,8 @@ async def _gc(path: Path, grace: str, dry_run: bool) -> None:
|
|
|
519
544
|
engines = project.open_engines()
|
|
520
545
|
state = await project.open_state()
|
|
521
546
|
try:
|
|
522
|
-
|
|
547
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:gc"):
|
|
548
|
+
result = await run_gc(state, engines=engines, grace=parsed_grace, dry_run=dry_run)
|
|
523
549
|
verb = "Would remove" if dry_run else "Removed"
|
|
524
550
|
console.print(
|
|
525
551
|
f"{verb} {len(result.removed_snapshots)} snapshot(s), dropped {len(result.dropped_tables)} table(s); "
|
|
@@ -547,6 +573,77 @@ async def _gc(path: Path, grace: str, dry_run: bool) -> None:
|
|
|
547
573
|
engines.close()
|
|
548
574
|
|
|
549
575
|
|
|
576
|
+
@app.command()
|
|
577
|
+
def reset(
|
|
578
|
+
path: Path = _PATH,
|
|
579
|
+
yes: bool = typer.Option(False, "--yes", help="Required. Confirm the reset."),
|
|
580
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would be removed without touching anything."),
|
|
581
|
+
as_json: bool = _JSON,
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Wipe Interlace-owned state for a fresh apply.
|
|
584
|
+
|
|
585
|
+
Drops environment views, snapshot tables, runs, events, and the stream log.
|
|
586
|
+
External table/file destinations are not dropped; terminal models stay
|
|
587
|
+
recorded so the next apply will not re-deliver into them. API keys are kept.
|
|
588
|
+
"""
|
|
589
|
+
asyncio.run(_reset(path, yes, dry_run, as_json))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def _reset(path: Path, yes: bool, dry_run: bool, as_json: bool) -> None:
|
|
593
|
+
from dataclasses import asdict
|
|
594
|
+
|
|
595
|
+
from interlace.state.janitor import reset as run_reset
|
|
596
|
+
|
|
597
|
+
if not yes and not dry_run:
|
|
598
|
+
console.print(
|
|
599
|
+
"This wipes Interlace-owned views, snapshot tables, runs, and stream state. "
|
|
600
|
+
"External table/file destinations are not dropped."
|
|
601
|
+
)
|
|
602
|
+
console.print("Pass [bold]--yes[/bold] to proceed (or [bold]--dry-run[/bold] to preview).")
|
|
603
|
+
raise typer.Exit(1)
|
|
604
|
+
project = Project.load(path)
|
|
605
|
+
compiled = project.compile()
|
|
606
|
+
keep = [model.name for model in compiled.models.values() if model.is_terminal]
|
|
607
|
+
engines = project.open_engines()
|
|
608
|
+
state = await project.open_state()
|
|
609
|
+
stream_log = None
|
|
610
|
+
stream_path = project.root / project.config.stream_path
|
|
611
|
+
if project.streams or stream_path.exists():
|
|
612
|
+
stream_log = await project.open_stream_log()
|
|
613
|
+
try:
|
|
614
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:reset"):
|
|
615
|
+
result = await run_reset(
|
|
616
|
+
state,
|
|
617
|
+
engines=engines,
|
|
618
|
+
keep_models=keep,
|
|
619
|
+
stream_log=stream_log,
|
|
620
|
+
clear_streams=bool(project.streams),
|
|
621
|
+
dry_run=dry_run,
|
|
622
|
+
)
|
|
623
|
+
if as_json:
|
|
624
|
+
_emit_json(asdict(result))
|
|
625
|
+
return
|
|
626
|
+
verb = "Would drop" if dry_run else "Dropped"
|
|
627
|
+
console.print(
|
|
628
|
+
f"{verb} {len(result.dropped_views)} view(s), {len(result.dropped_schemas)} schema(s); "
|
|
629
|
+
f"{'would clear' if dry_run else 'cleared'} {result.cleared_snapshots} snapshot(s)."
|
|
630
|
+
)
|
|
631
|
+
if result.kept_terminals:
|
|
632
|
+
console.print(
|
|
633
|
+
f"[dim]Left {len(result.kept_terminals)} terminal model(s) recorded "
|
|
634
|
+
f"(table/file destinations were not dropped).[/dim]"
|
|
635
|
+
)
|
|
636
|
+
if result.stream_log_cleared:
|
|
637
|
+
console.print("[dim]Stream log cleared.[/dim]")
|
|
638
|
+
if not dry_run:
|
|
639
|
+
console.print("[dim]Next apply rebuilds owned models from scratch.[/dim]")
|
|
640
|
+
finally:
|
|
641
|
+
if stream_log is not None:
|
|
642
|
+
await stream_log.close()
|
|
643
|
+
await state.close()
|
|
644
|
+
engines.close()
|
|
645
|
+
|
|
646
|
+
|
|
550
647
|
@app.command()
|
|
551
648
|
def scheduler(
|
|
552
649
|
environment: str = _ENV,
|
|
@@ -574,16 +671,17 @@ async def _scheduler(environment: str, path: Path, interval: float, once: bool)
|
|
|
574
671
|
await trigger_engine.tick(datetime.now())
|
|
575
672
|
# materialize anything published since the last tick, then run models
|
|
576
673
|
# (an API-only `serve --no-scheduler` process relies on this loop to flush)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
674
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:scheduler"):
|
|
675
|
+
if stream_log is not None:
|
|
676
|
+
await flush_streams(project.streams, stream_log, engines.get())
|
|
677
|
+
ran = await drain(
|
|
678
|
+
state,
|
|
679
|
+
compiled,
|
|
680
|
+
engines=engines,
|
|
681
|
+
environment=environment,
|
|
682
|
+
base_path=project.root,
|
|
683
|
+
parallelism=project.config.parallelism,
|
|
684
|
+
)
|
|
587
685
|
if ran:
|
|
588
686
|
console.print(f"[green]ran {ran} scheduled run(s) in '{environment}'[/green]")
|
|
589
687
|
if stream_log is not None:
|
|
@@ -641,6 +739,11 @@ def serve(
|
|
|
641
739
|
True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
|
|
642
740
|
),
|
|
643
741
|
interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
|
|
742
|
+
allow_open: bool = typer.Option(
|
|
743
|
+
False,
|
|
744
|
+
"--allow-open",
|
|
745
|
+
help="Permit a non-loopback bind with no API keys (insecure). Refused without this flag.",
|
|
746
|
+
),
|
|
644
747
|
) -> None:
|
|
645
748
|
"""Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
|
|
646
749
|
|
|
@@ -657,15 +760,22 @@ def serve(
|
|
|
657
760
|
if bound != port:
|
|
658
761
|
console.print(f"[yellow]port {port} is in use — serving on {bound}[/yellow]")
|
|
659
762
|
port = bound
|
|
660
|
-
# Auth is open until the first API key exists (see auth.py).
|
|
661
|
-
#
|
|
662
|
-
#
|
|
763
|
+
# Auth is open until the first API key exists (see auth.py). Fine on loopback;
|
|
764
|
+
# on a routable bind it exposes apply/gc/query unauthenticated — refuse unless
|
|
765
|
+
# --allow-open (or keys already exist).
|
|
663
766
|
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
664
767
|
keyed = asyncio.run(_has_api_keys(path))
|
|
768
|
+
if not keyed and not allow_open:
|
|
769
|
+
console.print(
|
|
770
|
+
f"[bold red]refusing[/bold red] to serve on [bold]{host}[/bold] with no API keys — "
|
|
771
|
+
"create one ([bold]interlace apikey create <name> --scope admin[/bold]) or pass "
|
|
772
|
+
"[bold]--allow-open[/bold] (insecure)."
|
|
773
|
+
)
|
|
774
|
+
raise typer.Exit(1)
|
|
665
775
|
if not keyed:
|
|
666
776
|
console.print(
|
|
667
777
|
f"[bold red]WARNING[/bold red] serving on [bold]{host}[/bold] with no API keys — "
|
|
668
|
-
"the API is open to the network
|
|
778
|
+
"the API is open to the network (--allow-open)."
|
|
669
779
|
)
|
|
670
780
|
console.print(f"UI at [bold cyan]http://{host}:{port}/ui[/bold cyan]")
|
|
671
781
|
token = quack_token
|
|
@@ -819,7 +929,8 @@ async def _env_drop(name: str, path: Path, force: bool) -> None:
|
|
|
819
929
|
if not await state.get_environment(name):
|
|
820
930
|
console.print(f"No environment {name!r}.")
|
|
821
931
|
raise typer.Exit(1)
|
|
822
|
-
|
|
932
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:env-drop"):
|
|
933
|
+
dropped = await drop_environment(state, engines=engines, environment=name)
|
|
823
934
|
console.print(f"Dropped environment [bold]{name}[/bold] ({len(dropped)} view(s) removed).")
|
|
824
935
|
console.print("[dim]Its snapshots are now unreferenced — `interlace gc` reclaims their tables.[/dim]")
|
|
825
936
|
finally:
|
|
@@ -867,10 +978,14 @@ async def _env_rollback(name: str, path: Path, to: int | None, history: bool, as
|
|
|
867
978
|
return
|
|
868
979
|
engines = project.open_engines()
|
|
869
980
|
try:
|
|
870
|
-
|
|
981
|
+
async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:env-rollback"):
|
|
982
|
+
result = await rollback_environment(state, engines=engines, environment=name, to_generation=to)
|
|
871
983
|
except PlanError as exc:
|
|
872
984
|
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
873
985
|
raise typer.Exit(1) from exc
|
|
986
|
+
except LockError as exc:
|
|
987
|
+
console.print(f"[red]{escape(exc.message)}[/red]")
|
|
988
|
+
raise typer.Exit(1) from exc
|
|
874
989
|
if as_json:
|
|
875
990
|
_emit_json(result)
|
|
876
991
|
return
|
|
@@ -1407,39 +1522,55 @@ async def _apikey_list(path: Path) -> None:
|
|
|
1407
1522
|
|
|
1408
1523
|
|
|
1409
1524
|
def _render(plan: Plan, environment: str) -> None:
|
|
1410
|
-
if plan.
|
|
1525
|
+
if not plan.changes and not plan.physical and not plan.transfers and not plan.drift and not plan.warnings:
|
|
1411
1526
|
console.print(f"No changes for [bold]{environment}[/bold].")
|
|
1412
1527
|
return
|
|
1413
1528
|
reused = {snapshot.name for snapshot in plan.reuses}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1529
|
+
if plan.changes:
|
|
1530
|
+
table = _table(f"Plan · {environment}")
|
|
1531
|
+
table.add_column("Model")
|
|
1532
|
+
table.add_column("Change")
|
|
1533
|
+
table.add_column("Category")
|
|
1534
|
+
table.add_column("Build")
|
|
1535
|
+
change_colours = {"added": "green", "removed": "red", "modified": "yellow"}
|
|
1536
|
+
category_colours = {"breaking": "red", "non_breaking": "green", "forward_only": "cyan"}
|
|
1537
|
+
for change in plan.changes:
|
|
1538
|
+
build = (
|
|
1539
|
+
"[cyan]reuse[/]"
|
|
1540
|
+
if change.name in reused
|
|
1541
|
+
else ("[dim]—[/]" if change.change_type is ChangeType.REMOVED else "rebuild")
|
|
1542
|
+
)
|
|
1543
|
+
kind = change.change_type.value
|
|
1544
|
+
category = change.category.value if change.category else None
|
|
1545
|
+
table.add_row(
|
|
1546
|
+
change.name,
|
|
1547
|
+
f"[{change_colours.get(kind, 'white')}]{kind}[/]",
|
|
1548
|
+
f"[{category_colours.get(category, 'white')}]{category}[/]" if category else "[dim]—[/]",
|
|
1549
|
+
build,
|
|
1550
|
+
)
|
|
1551
|
+
console.print(table)
|
|
1436
1552
|
if reused:
|
|
1437
1553
|
console.print(f"[dim]{len(reused)} model(s) have provably identical output — reusing existing tables.[/dim]")
|
|
1554
|
+
for action in plan.physical:
|
|
1555
|
+
for physical in action.changes:
|
|
1556
|
+
mark = "[green]+[/]" if physical.op == "add" else "[red]-[/]"
|
|
1557
|
+
console.print(f"{mark} {physical.kind} {physical.name} [dim]{action.name}[/]")
|
|
1558
|
+
for warning in action.warnings:
|
|
1559
|
+
console.print(f"[yellow]note:[/yellow] {warning}")
|
|
1438
1560
|
for transfer in plan.transfers:
|
|
1439
1561
|
console.print(
|
|
1440
1562
|
f"[cyan]transfer[/cyan] {transfer.model}: {transfer.source.name} → {transfer.target.name} "
|
|
1441
1563
|
f"({transfer.via} → {transfer.table.schema}.{transfer.table.name})"
|
|
1442
1564
|
)
|
|
1565
|
+
for note in plan.drift:
|
|
1566
|
+
style = "red" if note.blocking else "yellow"
|
|
1567
|
+
console.print(f"[{style}]drift:[/{style}] {note.message}")
|
|
1568
|
+
for warning in plan.warnings:
|
|
1569
|
+
if any(warning == note.message for note in plan.drift):
|
|
1570
|
+
continue
|
|
1571
|
+
if any(warning in action.warnings for action in plan.physical):
|
|
1572
|
+
continue
|
|
1573
|
+
console.print(f"[yellow]note:[/yellow] {warning}")
|
|
1443
1574
|
|
|
1444
1575
|
|
|
1445
1576
|
def _flatten_exceptions(exc: BaseException) -> list[BaseException]:
|
|
@@ -79,9 +79,9 @@ _TYPE_DIALECT = {
|
|
|
79
79
|
class EngineConfig(BaseModel):
|
|
80
80
|
"""One named execution engine (warehouse gateway).
|
|
81
81
|
|
|
82
|
-
DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``)
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``) and ``postgres`` are
|
|
83
|
+
the tested set. ``spark`` is beta; ``motherduck`` / ``redshift`` / ``snowflake`` /
|
|
84
|
+
``bigquery`` are alpha (wired and dialect-correct, not yet live-validated).
|
|
85
85
|
"""
|
|
86
86
|
|
|
87
87
|
type: str = "duckdb"
|
|
@@ -145,6 +145,9 @@ class ProjectConfig(BaseModel):
|
|
|
145
145
|
# This is T0 federation (all SQL still runs in DuckDB) — see docs/architecture/MULTI_ENGINE.md.
|
|
146
146
|
attach: dict[str, str] = Field(default_factory=dict)
|
|
147
147
|
model_paths: list[str] = Field(default_factory=lambda: ["models"])
|
|
148
|
+
# Directories of CREATE MACRO definitions, expanded into every model at compile
|
|
149
|
+
# time (see interlace.ir.macros). Missing directories are ignored.
|
|
150
|
+
macro_paths: list[str] = Field(default_factory=lambda: ["macros"])
|
|
148
151
|
# How many models build concurrently. Lower it to serialise a build end-to-end
|
|
149
152
|
# when diagnosing a crash or a warehouse that dislikes parallel writers; `run`
|
|
150
153
|
# and `apply` also take --parallelism, which wins over this.
|
|
@@ -8,12 +8,22 @@ Compilation, planning, and execution happen later over the registry. UK spelling
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import re
|
|
11
12
|
from collections.abc import Callable, Sequence
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
from interlace.checks.spec import CheckSpec, parse_checks
|
|
16
17
|
from interlace.exceptions import DefinitionError
|
|
18
|
+
from interlace.physical.spec import (
|
|
19
|
+
ConstraintSpec,
|
|
20
|
+
IndexSpec,
|
|
21
|
+
SchemaPolicy,
|
|
22
|
+
parse_constraints,
|
|
23
|
+
parse_indexes,
|
|
24
|
+
parse_schema_policy,
|
|
25
|
+
validate_physical_allowed,
|
|
26
|
+
)
|
|
17
27
|
from interlace.sinks import FILE_FORMATS
|
|
18
28
|
|
|
19
29
|
ModelFn = Callable[..., Any]
|
|
@@ -24,6 +34,8 @@ ModelFn = Callable[..., Any]
|
|
|
24
34
|
_MATERIALISATIONS = frozenset({"virtual", "view", "ephemeral", "table", "file"})
|
|
25
35
|
_KEYED_STRATEGIES = frozenset({"merge", "full_merge", "hash_merge", "scd"})
|
|
26
36
|
_DRIFT_MODES = frozenset({"evolve", "reject", "quarantine"})
|
|
37
|
+
# Stream names become SQL identifiers / watermark keys — keep them safe to quote.
|
|
38
|
+
_STREAM_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
def _as_tuple(value: str | Sequence[str]) -> tuple[str, ...]:
|
|
@@ -113,6 +125,9 @@ class ModelDef:
|
|
|
113
125
|
environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
|
|
114
126
|
schedule: dict[str, str] | None = None # {"cron": "0 * * * *"} or {"every": "5m"} for `interlace serve`
|
|
115
127
|
checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
|
|
128
|
+
indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
|
|
129
|
+
constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
|
|
130
|
+
schema_policy: SchemaPolicy = SchemaPolicy() # external-table drift: columns / indexes / constraints
|
|
116
131
|
|
|
117
132
|
def __post_init__(self) -> None:
|
|
118
133
|
# `@model(checks=…)` normalises through parse_checks, but a ModelDef built
|
|
@@ -125,6 +140,10 @@ class ModelDef:
|
|
|
125
140
|
# (TypeError on a CheckSpec; a dict silently degraded to its keys). Always run,
|
|
126
141
|
# so `checks=[]` normalises to the declared tuple rather than staying a list.
|
|
127
142
|
self.checks = parse_checks(self.checks, self.name)
|
|
143
|
+
self.indexes = parse_indexes(self.indexes, self.name)
|
|
144
|
+
self.constraints = parse_constraints(self.constraints, self.name)
|
|
145
|
+
self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
|
|
146
|
+
validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
|
|
128
147
|
|
|
129
148
|
@property
|
|
130
149
|
def is_terminal(self) -> bool:
|
|
@@ -202,6 +221,9 @@ def model(
|
|
|
202
221
|
columns: dict[str, str | None] | Sequence[str] | None = None,
|
|
203
222
|
schedule: dict[str, str] | None = None,
|
|
204
223
|
checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
|
|
224
|
+
indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
|
|
225
|
+
constraints: Sequence[dict[str, Any] | ConstraintSpec] | None = None,
|
|
226
|
+
schema: dict[str, str] | None = None,
|
|
205
227
|
export: Any = None, # removed in 2.0 — kept only to raise a migration error
|
|
206
228
|
) -> Callable[[ModelFn], ModelFn]:
|
|
207
229
|
"""Declare a Python model. The function returns a ``Relation`` (or composes one).
|
|
@@ -218,6 +240,10 @@ def model(
|
|
|
218
240
|
spelling for both surfaces — e.g. ``checks=[{"not_null": "customer_id"},
|
|
219
241
|
{"row_count": {"min": 1}}]``. A :class:`~interlace.CheckSpec` also works if you
|
|
220
242
|
prefer the typed form.
|
|
243
|
+
|
|
244
|
+
``indexes`` and ``constraints`` are physical DDL applied after the table
|
|
245
|
+
exists. They do not change the data fingerprint. ``schema`` is the external
|
|
246
|
+
table drift policy (``columns`` / ``indexes`` / ``constraints``).
|
|
221
247
|
"""
|
|
222
248
|
model_name = name or "<model>"
|
|
223
249
|
if export is not None:
|
|
@@ -260,6 +286,9 @@ def model(
|
|
|
260
286
|
columns=_as_columns(columns),
|
|
261
287
|
schedule=schedule,
|
|
262
288
|
checks=parse_checks(checks, name or fn.__name__),
|
|
289
|
+
indexes=parse_indexes(indexes, name or fn.__name__),
|
|
290
|
+
constraints=parse_constraints(constraints, name or fn.__name__),
|
|
291
|
+
schema_policy=parse_schema_policy(schema, name or fn.__name__),
|
|
263
292
|
)
|
|
264
293
|
)
|
|
265
294
|
return fn
|
|
@@ -276,6 +305,10 @@ def stream(
|
|
|
276
305
|
on_schema_drift: str = "reject",
|
|
277
306
|
) -> Callable[[ModelFn], ModelFn]:
|
|
278
307
|
"""Declare a durable ingestion stream with an HTTP publish endpoint."""
|
|
308
|
+
if not _STREAM_NAME.fullmatch(name):
|
|
309
|
+
raise DefinitionError(
|
|
310
|
+
f"stream name {name!r} must match [A-Za-z_][A-Za-z0-9_]* (it becomes streams.<name> and a watermark key)"
|
|
311
|
+
)
|
|
279
312
|
if on_schema_drift not in _DRIFT_MODES:
|
|
280
313
|
raise DefinitionError(f"unknown on_schema_drift {on_schema_drift!r}; expected one of {sorted(_DRIFT_MODES)}")
|
|
281
314
|
|