interlaced 2.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. interlace/__init__.py +26 -0
  2. interlace/checks/__init__.py +10 -0
  3. interlace/checks/builtin.py +152 -0
  4. interlace/checks/runner.py +103 -0
  5. interlace/checks/spec.py +113 -0
  6. interlace/cli/__init__.py +7 -0
  7. interlace/cli/main.py +1072 -0
  8. interlace/config/__init__.py +7 -0
  9. interlace/config/config.py +172 -0
  10. interlace/contracts.py +35 -0
  11. interlace/dsl/__init__.py +16 -0
  12. interlace/dsl/decorators.py +222 -0
  13. interlace/dsl/discovery.py +75 -0
  14. interlace/dsl/sql_config.py +47 -0
  15. interlace/engines/__init__.py +16 -0
  16. interlace/engines/base.py +86 -0
  17. interlace/engines/duckdb.py +249 -0
  18. interlace/engines/postgres.py +157 -0
  19. interlace/engines/quack.py +113 -0
  20. interlace/engines/registry.py +125 -0
  21. interlace/exceptions.py +66 -0
  22. interlace/exports.py +145 -0
  23. interlace/graph/__init__.py +17 -0
  24. interlace/graph/column_lineage.py +69 -0
  25. interlace/graph/dag.py +81 -0
  26. interlace/graph/project.py +242 -0
  27. interlace/graph/selectors.py +45 -0
  28. interlace/ir/__init__.py +24 -0
  29. interlace/ir/canonicalize.py +71 -0
  30. interlace/ir/fingerprint.py +55 -0
  31. interlace/ir/relation.py +71 -0
  32. interlace/ir/schema.py +23 -0
  33. interlace/plan/__init__.py +23 -0
  34. interlace/plan/apply.py +527 -0
  35. interlace/plan/differ.py +211 -0
  36. interlace/plan/plan.py +177 -0
  37. interlace/plan/resolve.py +74 -0
  38. interlace/plan/run.py +79 -0
  39. interlace/project.py +278 -0
  40. interlace/py.typed +0 -0
  41. interlace/runtime/__init__.py +6 -0
  42. interlace/runtime/handles.py +48 -0
  43. interlace/runtime/python_model.py +175 -0
  44. interlace/scaffold.py +83 -0
  45. interlace/scheduler/__init__.py +17 -0
  46. interlace/scheduler/engine.py +66 -0
  47. interlace/scheduler/triggers.py +84 -0
  48. interlace/scheduler/worker.py +154 -0
  49. interlace/service/__init__.py +7 -0
  50. interlace/service/app.py +836 -0
  51. interlace/service/auth.py +41 -0
  52. interlace/state/__init__.py +18 -0
  53. interlace/state/interval.py +134 -0
  54. interlace/state/janitor.py +142 -0
  55. interlace/state/snapshot.py +47 -0
  56. interlace/state/store.py +906 -0
  57. interlace/strategies/__init__.py +64 -0
  58. interlace/strategies/base.py +71 -0
  59. interlace/strategies/full.py +38 -0
  60. interlace/strategies/full_merge.py +98 -0
  61. interlace/strategies/incremental_by_time.py +65 -0
  62. interlace/strategies/merge_by_key.py +69 -0
  63. interlace/strategies/scd_type_2.py +122 -0
  64. interlace/strategies/view.py +33 -0
  65. interlace/streaming/__init__.py +18 -0
  66. interlace/streaming/log.py +298 -0
  67. interlace/streaming/materializer.py +166 -0
  68. interlace/streaming/schema.py +218 -0
  69. interlaced-2.0.0a2.dist-info/METADATA +216 -0
  70. interlaced-2.0.0a2.dist-info/RECORD +74 -0
  71. interlaced-2.0.0a2.dist-info/WHEEL +5 -0
  72. interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
  73. interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
  74. interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """Project configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from interlace.config.config import CONFIG_FILE, ProjectConfig, load_config
6
+
7
+ __all__ = ["CONFIG_FILE", "ProjectConfig", "load_config"]
@@ -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)