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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Project configuration loaded from ``interlace.yaml``.
|
|
2
|
+
|
|
3
|
+
A small pydantic model with sensible defaults so a project works with no config
|
|
4
|
+
file at all. Paths are relative to the project root.
|
|
5
|
+
|
|
6
|
+
``${VAR}`` references anywhere in the YAML are substituted from the environment
|
|
7
|
+
before parsing (unset variables are left literal), so DSNs and secret values
|
|
8
|
+
never need to be committed: ``database: "ducklake:postgres:${WAREHOUSE_DSN}"``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
19
|
+
|
|
20
|
+
from interlace.exceptions import ConfigurationError
|
|
21
|
+
|
|
22
|
+
CONFIG_FILE = "interlace.yaml"
|
|
23
|
+
|
|
24
|
+
_ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SecretConfig(BaseModel):
|
|
28
|
+
"""A DuckDB ``CREATE SECRET`` issued at engine open (currently ``type: s3``) —
|
|
29
|
+
how a DuckLake warehouse whose ``data_path`` is an object store authenticates.
|
|
30
|
+
Values normally arrive via ``${VAR}`` interpolation."""
|
|
31
|
+
|
|
32
|
+
type: str = "s3"
|
|
33
|
+
key_id: str = ""
|
|
34
|
+
secret: str = ""
|
|
35
|
+
endpoint: str | None = None # host[:port], no scheme; None => AWS default
|
|
36
|
+
region: str | None = None
|
|
37
|
+
url_style: str | None = None # 'path' for MinIO/RustFS-style endpoints
|
|
38
|
+
use_ssl: bool | None = None
|
|
39
|
+
scope: str | None = None # e.g. s3://bucket — pin the secret to one prefix
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_ENGINE_TYPES = frozenset({"duckdb", "ducklake", "quack"})
|
|
43
|
+
_TYPE_DIALECT = {
|
|
44
|
+
"duckdb": "duckdb",
|
|
45
|
+
"ducklake": "duckdb",
|
|
46
|
+
"quack": "duckdb",
|
|
47
|
+
"postgres": "postgres",
|
|
48
|
+
"snowflake": "snowflake",
|
|
49
|
+
"bigquery": "bigquery",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EngineConfig(BaseModel):
|
|
54
|
+
"""One named execution engine (warehouse gateway).
|
|
55
|
+
|
|
56
|
+
DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``) are fully supported.
|
|
57
|
+
Additional types are reserved for remote adapters (Postgres, Snowflake, …);
|
|
58
|
+
declaring them fails at open until an adapter ships.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
type: str = "ducklake"
|
|
62
|
+
# Path / URI for DuckDB-family engines. Also accepted on the project top level
|
|
63
|
+
# as ``database:`` (synthesised into the ``default`` engine).
|
|
64
|
+
database: str | None = None
|
|
65
|
+
# The catalog's ATTACH alias. Defaults to the engine name, or the project name
|
|
66
|
+
# for the ``default`` engine. Set it when a SCHEMA inside the warehouse would
|
|
67
|
+
# otherwise share the alias — DuckDB cannot bind ``x.y`` when ``x`` is both a
|
|
68
|
+
# catalog and a schema, so a project named `seccl` holding a `seccl` schema
|
|
69
|
+
# needs one of the two renamed.
|
|
70
|
+
alias: str | None = None
|
|
71
|
+
data_path: str | None = None
|
|
72
|
+
metadata_schema: str | None = None
|
|
73
|
+
secrets: dict[str, SecretConfig] = Field(default_factory=dict)
|
|
74
|
+
quack_token: str | None = None
|
|
75
|
+
attach: dict[str, str] = Field(default_factory=dict)
|
|
76
|
+
dialect: str | None = None # defaults from type (duckdb for DuckDB-family)
|
|
77
|
+
|
|
78
|
+
def resolved_dialect(self) -> str:
|
|
79
|
+
if self.dialect:
|
|
80
|
+
return self.dialect
|
|
81
|
+
return _TYPE_DIALECT.get(self.type, "duckdb")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ProjectConfig(BaseModel):
|
|
85
|
+
"""Top-level project settings."""
|
|
86
|
+
|
|
87
|
+
name: str = "interlace"
|
|
88
|
+
default_dialect: str = "duckdb"
|
|
89
|
+
# Named engines (see ``engines``). Single-engine projects leave this as
|
|
90
|
+
# ``default`` and use the top-level ``database`` / ``attach`` fields.
|
|
91
|
+
default_engine: str = "default"
|
|
92
|
+
engines: dict[str, EngineConfig] = Field(default_factory=dict)
|
|
93
|
+
state_path: str = ".interlace/state.db" # SQLite control-plane database
|
|
94
|
+
# The warehouse. Default is DuckLake (Parquet data + SQL catalog) via DuckDB.
|
|
95
|
+
# Also accepted: a DuckLake catalog hosted in a SQL database
|
|
96
|
+
# ("ducklake:postgres:dbname=... host=..." — pair with data_path/metadata_schema),
|
|
97
|
+
# a plain DuckDB file path, ":memory:", or "quack:<host>:<port>" to connect to a
|
|
98
|
+
# warehouse served by `interlace serve --quack`.
|
|
99
|
+
# When ``engines.default`` is not set, these top-level fields synthesise it.
|
|
100
|
+
database: str = "ducklake:.interlace/warehouse.ducklake"
|
|
101
|
+
# The warehouse catalog's ATTACH alias (defaults to ``name``). Set it when a
|
|
102
|
+
# schema inside the warehouse shares the project name — see EngineConfig.alias.
|
|
103
|
+
alias: str | None = None
|
|
104
|
+
# DuckLake attach options for non-default layouts: where the Parquet data lives
|
|
105
|
+
# (local dir or s3://bucket/prefix/) and which schema of the catalog database
|
|
106
|
+
# holds this warehouse's ducklake_* metadata (multiple warehouses can share one
|
|
107
|
+
# catalog database, one schema each).
|
|
108
|
+
data_path: str | None = None
|
|
109
|
+
metadata_schema: str | None = None
|
|
110
|
+
# Secrets to CREATE on the engine at open (name -> config), e.g. the S3
|
|
111
|
+
# credential for an object-store data_path.
|
|
112
|
+
secrets: dict[str, SecretConfig] = Field(default_factory=dict)
|
|
113
|
+
quack_token: str | None = None # token for quack: databases (or INTERLACE_QUACK_TOKEN)
|
|
114
|
+
stream_path: str = ".interlace/streams.db" # durable stream log (SQLite WAL)
|
|
115
|
+
# Databases to ATTACH to the warehouse engine at open: alias -> DuckDB attach
|
|
116
|
+
# URI/path (a .duckdb file, "postgres:...", "sqlite:...", ...). Models can read
|
|
117
|
+
# them and table exports can write to them as <alias>.<schema>.<table>.
|
|
118
|
+
# This is T0 federation (all SQL still runs in DuckDB) — see docs/architecture/MULTI_ENGINE.md.
|
|
119
|
+
attach: dict[str, str] = Field(default_factory=dict)
|
|
120
|
+
model_paths: list[str] = Field(default_factory=lambda: ["models"])
|
|
121
|
+
|
|
122
|
+
def engine_configs(self) -> dict[str, EngineConfig]:
|
|
123
|
+
"""Resolved engine map: explicit ``engines`` plus a synthesised ``default``
|
|
124
|
+
from the top-level warehouse fields when that name is not already set."""
|
|
125
|
+
result = dict(self.engines)
|
|
126
|
+
if "default" not in result:
|
|
127
|
+
result["default"] = _default_engine_from_top_level(self)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _infer_engine_type(database: str) -> str:
|
|
132
|
+
if database.startswith("quack:"):
|
|
133
|
+
return "quack"
|
|
134
|
+
if database.startswith("ducklake:"):
|
|
135
|
+
return "ducklake"
|
|
136
|
+
return "duckdb"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _default_engine_from_top_level(config: ProjectConfig) -> EngineConfig:
|
|
140
|
+
return EngineConfig(
|
|
141
|
+
type=_infer_engine_type(config.database),
|
|
142
|
+
database=config.database,
|
|
143
|
+
alias=config.alias,
|
|
144
|
+
data_path=config.data_path,
|
|
145
|
+
metadata_schema=config.metadata_schema,
|
|
146
|
+
secrets=config.secrets,
|
|
147
|
+
quack_token=config.quack_token,
|
|
148
|
+
attach=config.attach,
|
|
149
|
+
dialect=config.default_dialect if config.default_dialect != "duckdb" else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _interpolate_env(text: str) -> str:
|
|
154
|
+
"""Replace ``${VAR}`` with the environment value; unset vars stay literal (so a
|
|
155
|
+
missing variable surfaces as an obvious ``${VAR}`` in errors, never silently '')."""
|
|
156
|
+
return _ENV_REF.sub(lambda m: os.environ.get(m.group(1), m.group(0)), text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_config(path: Path) -> ProjectConfig:
|
|
160
|
+
"""Load and validate config, returning defaults when the file is absent."""
|
|
161
|
+
if not path.exists():
|
|
162
|
+
return ProjectConfig()
|
|
163
|
+
try:
|
|
164
|
+
data = yaml.safe_load(_interpolate_env(path.read_text())) or {}
|
|
165
|
+
except yaml.YAMLError as exc:
|
|
166
|
+
raise ConfigurationError("could not parse config", details={"path": str(path), "error": str(exc)}) from exc
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
raise ConfigurationError("config root must be a mapping", details={"path": str(path)})
|
|
169
|
+
try:
|
|
170
|
+
return ProjectConfig(**data)
|
|
171
|
+
except ValidationError as exc:
|
|
172
|
+
raise ConfigurationError("invalid config", details={"path": str(path), "error": str(exc)}) from exc
|
interlace/contracts.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Model output contracts.
|
|
2
|
+
|
|
3
|
+
A model may declare its expected output columns (and optionally types) via
|
|
4
|
+
per-model config. After a model is built, ``apply`` introspects the physical
|
|
5
|
+
table and validates it against the contract, raising ``SchemaError`` on drift —
|
|
6
|
+
a missing contracted column, or a type mismatch — before the environment is
|
|
7
|
+
promoted. Extra columns beyond the contract are allowed (additive evolution).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from interlace.exceptions import SchemaError
|
|
13
|
+
|
|
14
|
+
Contract = dict[str, str | None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_contract(model: str, actual: dict[str, str], contract: Contract) -> None:
|
|
18
|
+
"""Raise ``SchemaError`` if the built columns violate the contract."""
|
|
19
|
+
missing = [column for column in contract if column not in actual]
|
|
20
|
+
if missing:
|
|
21
|
+
raise SchemaError(
|
|
22
|
+
f"model {model!r} is missing contracted column(s): {', '.join(sorted(missing))}",
|
|
23
|
+
details={"model": model, "missing": sorted(missing)},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
mismatches = {
|
|
27
|
+
column: {"expected": expected, "actual": actual[column]}
|
|
28
|
+
for column, expected in contract.items()
|
|
29
|
+
if expected is not None and actual[column].upper() != expected.upper()
|
|
30
|
+
}
|
|
31
|
+
if mismatches:
|
|
32
|
+
raise SchemaError(
|
|
33
|
+
f"model {model!r} has column type mismatch(es): {sorted(mismatches)}",
|
|
34
|
+
details={"model": model, "mismatches": mismatches},
|
|
35
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Declarative surface: decorators and the project registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from interlace.dsl.decorators import REGISTRY, CheckDef, ModelDef, Registry, StreamDef, check, model, stream
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"REGISTRY",
|
|
9
|
+
"CheckDef",
|
|
10
|
+
"ModelDef",
|
|
11
|
+
"Registry",
|
|
12
|
+
"StreamDef",
|
|
13
|
+
"check",
|
|
14
|
+
"model",
|
|
15
|
+
"stream",
|
|
16
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The ``@model`` / ``@stream`` / ``@check`` decorator DX and the in-process registry.
|
|
2
|
+
|
|
3
|
+
These decorators only *declare* intent — they capture metadata into a registry and
|
|
4
|
+
return the original function unchanged, so models stay ordinary, testable Python.
|
|
5
|
+
Compilation, planning, and execution happen later over the registry. UK spelling
|
|
6
|
+
``materialise`` is intentional (matches v0.x and the config schema).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from interlace.checks.spec import CheckSpec, parse_checks
|
|
16
|
+
from interlace.exceptions import DefinitionError
|
|
17
|
+
from interlace.exports import ExportConfig
|
|
18
|
+
|
|
19
|
+
ModelFn = Callable[..., Any]
|
|
20
|
+
|
|
21
|
+
_MATERIALISATIONS = frozenset({"table", "view", "ephemeral", "incremental", "none"})
|
|
22
|
+
_DRIFT_MODES = frozenset({"evolve", "reject", "quarantine"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_tuple(value: str | Sequence[str]) -> tuple[str, ...]:
|
|
26
|
+
return (value,) if isinstance(value, str) else tuple(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_columns(value: dict[str, str | None] | Sequence[str] | None) -> dict[str, str | None] | None:
|
|
30
|
+
"""Normalise a column contract: a list of names -> {name: None}; a mapping kept as name->type."""
|
|
31
|
+
if value is None:
|
|
32
|
+
return None
|
|
33
|
+
if isinstance(value, dict):
|
|
34
|
+
return {str(name): (str(dtype) if dtype is not None else None) for name, dtype in value.items()}
|
|
35
|
+
return {str(name): None for name in value}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as_export(value: ExportConfig | dict[str, Any] | None) -> ExportConfig | None:
|
|
39
|
+
if value is None or isinstance(value, ExportConfig):
|
|
40
|
+
return value
|
|
41
|
+
return ExportConfig.from_dict(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ModelDef:
|
|
46
|
+
"""Declared metadata for one model (Python or SQL)."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
fn: ModelFn | None = None
|
|
50
|
+
sql: str | None = None
|
|
51
|
+
materialise: str = "table"
|
|
52
|
+
strategy: str = "full"
|
|
53
|
+
key: tuple[str, ...] = ()
|
|
54
|
+
dialect: str | None = None
|
|
55
|
+
engine: str | None = None # named engine from config (None → project default_engine)
|
|
56
|
+
depends_on: tuple[str, ...] = ()
|
|
57
|
+
interval: str | None = None # grain for incremental_by_time (e.g. "1d")
|
|
58
|
+
time_column: str | None = None # partition column for incremental_by_time
|
|
59
|
+
cursor: str | None = None # column whose max is injected into the fn's `cursor` param
|
|
60
|
+
tags: tuple[str, ...] = ()
|
|
61
|
+
owner: str | None = None
|
|
62
|
+
description: str | None = None
|
|
63
|
+
columns: dict[str, str | None] | None = None # output contract: column -> type (None = any)
|
|
64
|
+
export: ExportConfig | None = None # presence makes this model a sink (no table/view)
|
|
65
|
+
schedule: dict[str, str] | None = None # {"cron": "0 * * * *"} or {"every": "5m"} for `interlace serve`
|
|
66
|
+
checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class StreamDef:
|
|
71
|
+
"""Declared metadata for one durable ingestion stream."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
schema: dict[str, str]
|
|
75
|
+
idempotency_key: str | None = None
|
|
76
|
+
retention: str | None = None
|
|
77
|
+
on_schema_drift: str = "reject"
|
|
78
|
+
rate_limit: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CheckDef:
|
|
83
|
+
"""Declared metadata for one data check; results gate promotion."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
model: str
|
|
87
|
+
fn: ModelFn
|
|
88
|
+
severity: str = "error"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Registry:
|
|
93
|
+
"""Holds everything declared in a project. One instance per process."""
|
|
94
|
+
|
|
95
|
+
models: dict[str, ModelDef] = field(default_factory=dict)
|
|
96
|
+
streams: dict[str, StreamDef] = field(default_factory=dict)
|
|
97
|
+
checks: list[CheckDef] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
def register_model(self, definition: ModelDef) -> None:
|
|
100
|
+
if definition.name in self.models:
|
|
101
|
+
raise DefinitionError(f"duplicate model name: {definition.name!r}")
|
|
102
|
+
self.models[definition.name] = definition
|
|
103
|
+
|
|
104
|
+
def register_stream(self, definition: StreamDef) -> None:
|
|
105
|
+
if definition.name in self.streams:
|
|
106
|
+
raise DefinitionError(f"duplicate stream name: {definition.name!r}")
|
|
107
|
+
self.streams[definition.name] = definition
|
|
108
|
+
|
|
109
|
+
def register_check(self, definition: CheckDef) -> None:
|
|
110
|
+
self.checks.append(definition)
|
|
111
|
+
|
|
112
|
+
def clear(self) -> None:
|
|
113
|
+
self.models.clear()
|
|
114
|
+
self.streams.clear()
|
|
115
|
+
self.checks.clear()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
REGISTRY = Registry()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def model(
|
|
122
|
+
name: str | None = None,
|
|
123
|
+
*,
|
|
124
|
+
materialise: str = "table",
|
|
125
|
+
strategy: str = "full",
|
|
126
|
+
key: str | Sequence[str] = (),
|
|
127
|
+
dialect: str | None = None,
|
|
128
|
+
engine: str | None = None,
|
|
129
|
+
depends_on: str | Sequence[str] = (),
|
|
130
|
+
interval: str | None = None,
|
|
131
|
+
time_column: str | None = None,
|
|
132
|
+
cursor: str | None = None,
|
|
133
|
+
tags: str | Sequence[str] = (),
|
|
134
|
+
owner: str | None = None,
|
|
135
|
+
description: str | None = None,
|
|
136
|
+
columns: dict[str, str | None] | Sequence[str] | None = None,
|
|
137
|
+
export: ExportConfig | dict[str, Any] | None = None,
|
|
138
|
+
schedule: dict[str, str] | None = None,
|
|
139
|
+
checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
|
|
140
|
+
) -> Callable[[ModelFn], ModelFn]:
|
|
141
|
+
"""Declare a Python model. The function returns a ``Relation`` (or composes one).
|
|
142
|
+
|
|
143
|
+
``cursor`` names a column of this model's own output; at run time the max of
|
|
144
|
+
that column in the previous materialisation is injected into the function's
|
|
145
|
+
``cursor`` parameter (``None`` on first build), so incremental extractors can
|
|
146
|
+
resume from where the warehouse actually is instead of tracking side state.
|
|
147
|
+
|
|
148
|
+
``engine`` pins the model to a named engine from ``interlace.yaml`` (defaults
|
|
149
|
+
to the project's ``default_engine``).
|
|
150
|
+
"""
|
|
151
|
+
if materialise not in _MATERIALISATIONS:
|
|
152
|
+
raise DefinitionError(f"unknown materialise {materialise!r}; expected one of {sorted(_MATERIALISATIONS)}")
|
|
153
|
+
if materialise == "ephemeral":
|
|
154
|
+
raise DefinitionError("Python models cannot be ephemeral; ephemeral requires SQL (it is inlined as a CTE)")
|
|
155
|
+
if materialise == "view":
|
|
156
|
+
raise DefinitionError("Python models cannot be views; a view requires SQL the engine can evaluate")
|
|
157
|
+
|
|
158
|
+
def decorator(fn: ModelFn) -> ModelFn:
|
|
159
|
+
REGISTRY.register_model(
|
|
160
|
+
ModelDef(
|
|
161
|
+
name=name or fn.__name__,
|
|
162
|
+
fn=fn,
|
|
163
|
+
materialise=materialise,
|
|
164
|
+
strategy=strategy,
|
|
165
|
+
key=_as_tuple(key),
|
|
166
|
+
dialect=dialect,
|
|
167
|
+
engine=engine,
|
|
168
|
+
depends_on=_as_tuple(depends_on),
|
|
169
|
+
interval=interval,
|
|
170
|
+
time_column=time_column,
|
|
171
|
+
cursor=cursor,
|
|
172
|
+
tags=_as_tuple(tags),
|
|
173
|
+
owner=owner,
|
|
174
|
+
description=description,
|
|
175
|
+
columns=_as_columns(columns),
|
|
176
|
+
export=_as_export(export),
|
|
177
|
+
schedule=schedule,
|
|
178
|
+
checks=parse_checks(checks, name or fn.__name__),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return fn
|
|
182
|
+
|
|
183
|
+
return decorator
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stream(
|
|
187
|
+
name: str,
|
|
188
|
+
*,
|
|
189
|
+
schema: dict[str, str],
|
|
190
|
+
idempotency_key: str | None = None,
|
|
191
|
+
retention: str | None = None,
|
|
192
|
+
on_schema_drift: str = "reject",
|
|
193
|
+
rate_limit: str | None = None,
|
|
194
|
+
) -> Callable[[ModelFn], ModelFn]:
|
|
195
|
+
"""Declare a durable ingestion stream with an HTTP publish endpoint."""
|
|
196
|
+
if on_schema_drift not in _DRIFT_MODES:
|
|
197
|
+
raise DefinitionError(f"unknown on_schema_drift {on_schema_drift!r}; expected one of {sorted(_DRIFT_MODES)}")
|
|
198
|
+
|
|
199
|
+
def decorator(fn: ModelFn) -> ModelFn:
|
|
200
|
+
REGISTRY.register_stream(
|
|
201
|
+
StreamDef(
|
|
202
|
+
name=name,
|
|
203
|
+
schema=schema,
|
|
204
|
+
idempotency_key=idempotency_key,
|
|
205
|
+
retention=retention,
|
|
206
|
+
on_schema_drift=on_schema_drift,
|
|
207
|
+
rate_limit=rate_limit,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return fn
|
|
211
|
+
|
|
212
|
+
return decorator
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check(*, model: str, name: str | None = None, severity: str = "error") -> Callable[[ModelFn], ModelFn]:
|
|
216
|
+
"""Declare a data check bound to a model. Failure at error severity blocks promotion."""
|
|
217
|
+
|
|
218
|
+
def decorator(fn: ModelFn) -> ModelFn:
|
|
219
|
+
REGISTRY.register_check(CheckDef(name=name or fn.__name__, model=model, fn=fn, severity=severity))
|
|
220
|
+
return fn
|
|
221
|
+
|
|
222
|
+
return decorator
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Discover models in a project directory.
|
|
2
|
+
|
|
3
|
+
Walks the configured model paths: ``.sql`` files become SQL models named by
|
|
4
|
+
their path (``models/silver/orders.sql`` -> ``silver.orders``); ``.py`` files are
|
|
5
|
+
imported so their ``@model`` decorators register (Python models name themselves
|
|
6
|
+
via the decorator or function name). Both populate the global registry, which is
|
|
7
|
+
cleared first for a clean load.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from interlace.checks.spec import parse_checks
|
|
18
|
+
from interlace.dsl.decorators import _MATERIALISATIONS, REGISTRY, ModelDef, _as_columns, _as_export, _as_tuple
|
|
19
|
+
from interlace.dsl.sql_config import extract_sql_config
|
|
20
|
+
from interlace.exceptions import DefinitionError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def discover_models(root: Path, model_paths: list[str], default_dialect: str) -> list[ModelDef]:
|
|
24
|
+
REGISTRY.clear()
|
|
25
|
+
for relative in model_paths:
|
|
26
|
+
base = root / relative
|
|
27
|
+
if not base.is_dir():
|
|
28
|
+
continue
|
|
29
|
+
for sql_file in sorted(base.rglob("*.sql")):
|
|
30
|
+
config, sql = extract_sql_config(sql_file.read_text())
|
|
31
|
+
REGISTRY.register_model(_sql_model(_model_name(base, sql_file), sql, config, default_dialect))
|
|
32
|
+
for py_file in sorted(base.rglob("*.py")):
|
|
33
|
+
if py_file.name.startswith("_"):
|
|
34
|
+
continue
|
|
35
|
+
_import_module(base, py_file)
|
|
36
|
+
return list(REGISTRY.models.values())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sql_model(default_name: str, sql: str, config: dict[str, Any], default_dialect: str) -> ModelDef:
|
|
40
|
+
materialise = config.get("materialise", "table")
|
|
41
|
+
if materialise not in _MATERIALISATIONS:
|
|
42
|
+
raise DefinitionError(f"unknown materialise {materialise!r}", details={"model": default_name})
|
|
43
|
+
return ModelDef(
|
|
44
|
+
name=config.get("name", default_name),
|
|
45
|
+
sql=sql,
|
|
46
|
+
materialise=materialise,
|
|
47
|
+
strategy=config.get("strategy", "full"),
|
|
48
|
+
key=_as_tuple(config.get("key") or ()),
|
|
49
|
+
dialect=config.get("dialect"), # None → compile fills from engine dialect
|
|
50
|
+
engine=config.get("engine"),
|
|
51
|
+
depends_on=_as_tuple(config.get("depends_on") or ()),
|
|
52
|
+
interval=config.get("interval"),
|
|
53
|
+
time_column=config.get("time_column"),
|
|
54
|
+
tags=_as_tuple(config.get("tags") or ()),
|
|
55
|
+
owner=config.get("owner"),
|
|
56
|
+
description=config.get("description"),
|
|
57
|
+
columns=_as_columns(config.get("columns")),
|
|
58
|
+
export=_as_export(config.get("export")),
|
|
59
|
+
schedule=config.get("schedule"),
|
|
60
|
+
checks=parse_checks(config.get("checks"), default_name),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _model_name(base: Path, file: Path) -> str:
|
|
65
|
+
return ".".join(file.relative_to(base).with_suffix("").parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _import_module(base: Path, file: Path) -> None:
|
|
69
|
+
module_name = "interlace_model_" + "_".join(file.relative_to(base).with_suffix("").parts)
|
|
70
|
+
spec = importlib.util.spec_from_file_location(module_name, file)
|
|
71
|
+
if spec is None or spec.loader is None:
|
|
72
|
+
raise DefinitionError("could not import model module", details={"path": str(file)})
|
|
73
|
+
module = importlib.util.module_from_spec(spec)
|
|
74
|
+
sys.modules[module_name] = module
|
|
75
|
+
spec.loader.exec_module(module)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Per-model config for SQL files.
|
|
2
|
+
|
|
3
|
+
A SQL model may declare its materialisation, strategy, key, etc. via a leading
|
|
4
|
+
block comment whose YAML is namespaced under ``interlace`` — valid SQL, no Jinja,
|
|
5
|
+
ignored by the engine:
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
interlace:
|
|
9
|
+
materialise: view
|
|
10
|
+
key: order_id
|
|
11
|
+
*/
|
|
12
|
+
SELECT ...
|
|
13
|
+
|
|
14
|
+
Only the first block comment is considered, and only when it parses to a mapping
|
|
15
|
+
with a top-level ``interlace`` key; otherwise the SQL is left untouched.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from interlace.exceptions import ConfigurationError
|
|
26
|
+
|
|
27
|
+
_BLOCK_COMMENT = re.compile(r"/\*(.*?)\*/", re.DOTALL)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_sql_config(content: str) -> tuple[dict[str, Any], str]:
|
|
31
|
+
"""Return ``(config, sql)`` — the parsed config and the SQL with the block removed."""
|
|
32
|
+
match = _BLOCK_COMMENT.search(content)
|
|
33
|
+
if match is None:
|
|
34
|
+
return {}, content
|
|
35
|
+
try:
|
|
36
|
+
parsed = yaml.safe_load(match.group(1))
|
|
37
|
+
except yaml.YAMLError:
|
|
38
|
+
return {}, content
|
|
39
|
+
if not isinstance(parsed, dict) or "interlace" not in parsed:
|
|
40
|
+
return {}, content
|
|
41
|
+
|
|
42
|
+
config = parsed["interlace"] or {}
|
|
43
|
+
if not isinstance(config, dict):
|
|
44
|
+
raise ConfigurationError("SQL model config must be a mapping", details={"got": type(config).__name__})
|
|
45
|
+
|
|
46
|
+
sql = (content[: match.start()] + content[match.end() :]).strip()
|
|
47
|
+
return config, sql
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Engine adapters: one per backend, the only home for dialect-specific code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
|
|
6
|
+
from interlace.engines.duckdb import DuckDBAdapter
|
|
7
|
+
from interlace.engines.registry import EngineRegistry, as_registry
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DuckDBAdapter",
|
|
11
|
+
"EngineAdapter",
|
|
12
|
+
"EngineCaps",
|
|
13
|
+
"EngineRegistry",
|
|
14
|
+
"LoadMode",
|
|
15
|
+
"as_registry",
|
|
16
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The engine adapter — the only place dialect-specific code is allowed to live.
|
|
2
|
+
|
|
3
|
+
Every backend (DuckDB, DuckLake, Postgres, Snowflake, BigQuery) implements this
|
|
4
|
+
one interface. The planner and strategies stay dialect-neutral by emitting
|
|
5
|
+
canonical sqlglot ASTs; :meth:`EngineAdapter.transpile` is the single seam where
|
|
6
|
+
a dialect reappears. :class:`EngineCaps` lets strategies degrade gracefully
|
|
7
|
+
(e.g. rewrite ``MERGE`` to ``DELETE`` + ``INSERT``) on engines that lack a feature.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import pyarrow as pa
|
|
18
|
+
import sqlglot
|
|
19
|
+
from sqlglot import exp
|
|
20
|
+
|
|
21
|
+
from interlace.ir.relation import TableRef
|
|
22
|
+
|
|
23
|
+
LoadMode = Literal["create", "append"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class EngineCaps:
|
|
28
|
+
"""Feature flags that drive strategy fallbacks. Conservative defaults (all off)."""
|
|
29
|
+
|
|
30
|
+
supports_create_or_replace: bool = False
|
|
31
|
+
supports_star_exclude: bool = False # SELECT * EXCLUDE (...) — scd_type_2 needs it
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EngineAdapter(ABC):
|
|
35
|
+
"""Executes canonical ASTs and moves Arrow data in and out of one backend."""
|
|
36
|
+
|
|
37
|
+
dialect: str
|
|
38
|
+
caps: EngineCaps
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def execute(self, ast: exp.Expression) -> None:
|
|
42
|
+
"""Run a statement (DDL/DML) with no result set."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def fetch(self, ast: exp.Expression) -> pa.RecordBatchReader:
|
|
46
|
+
"""Extract: evaluate a query and stream the result as Arrow batches."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def load(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
|
|
50
|
+
"""Load: write Arrow batches into a table, creating or appending.
|
|
51
|
+
Returns the number of rows written (0 when the backend cannot tell)."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def create_view(self, name: TableRef, target: TableRef) -> None:
|
|
55
|
+
"""Point a virtual-environment view at a physical snapshot table."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
async def create_schema(self, name: str) -> None:
|
|
59
|
+
"""Create a schema/namespace if it does not already exist."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def describe(self, table: TableRef) -> dict[str, str]:
|
|
63
|
+
"""Return a table or view's columns as an ordered ``{name: type}`` mapping."""
|
|
64
|
+
|
|
65
|
+
async def table_exists(self, table: TableRef) -> bool:
|
|
66
|
+
"""Whether the table (or view) exists. Adapters override with a direct probe."""
|
|
67
|
+
return bool(await self.describe(table))
|
|
68
|
+
|
|
69
|
+
async def execute_all(self, statements: Sequence[exp.Expression]) -> list[int]:
|
|
70
|
+
"""Run statements in order; returns affected-row counts per statement (0 when
|
|
71
|
+
unknown). Override to make the batch atomic (one transaction)."""
|
|
72
|
+
for statement in statements:
|
|
73
|
+
await self.execute(statement)
|
|
74
|
+
return [0] * len(statements)
|
|
75
|
+
|
|
76
|
+
async def execute_sql(self, sql: str) -> None:
|
|
77
|
+
"""Run one raw SQL statement written in this engine's dialect."""
|
|
78
|
+
await self.execute(sqlglot.parse_one(sql, read=self.dialect))
|
|
79
|
+
|
|
80
|
+
async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
|
|
81
|
+
"""Evaluate one raw SQL query written in this engine's dialect."""
|
|
82
|
+
return await self.fetch(sqlglot.parse_one(sql, read=self.dialect))
|
|
83
|
+
|
|
84
|
+
def transpile(self, ast: exp.Expression) -> str:
|
|
85
|
+
"""Canonical AST -> this engine's SQL. The one place dialect leaks back in."""
|
|
86
|
+
return ast.sql(dialect=self.dialect)
|