interlaced 2.4.0__tar.gz → 2.4.1__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.4.1}/PKG-INFO +1 -1
- {interlaced-2.4.0 → interlaced-2.4.1}/pyproject.toml +2 -1
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/cli/main.py +3 -1
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/config/config.py +3 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/discovery.py +69 -10
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/project.py +10 -3
- interlaced-2.4.1/src/interlace/ir/macros.py +154 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/apply.py +9 -6
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/project.py +6 -2
- {interlaced-2.4.0 → interlaced-2.4.1/src/interlaced.egg-info}/PKG-INFO +1 -1
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/SOURCES.txt +1 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/LICENSE +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/MANIFEST.in +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/setup.cfg +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/builtin.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/contracts.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/decorators.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/base.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/duckdb.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/exceptions.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/differ.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/run.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/py.typed +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/query.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scaffold.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/engine.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/triggers.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/app.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/auth.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/plan.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/system.js +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sinks.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/interval.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/store.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/log.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.4.0 → interlaced-2.4.1}/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.4.
|
|
3
|
+
Version: 2.4.1
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.4.
|
|
7
|
+
version = "2.4.1"
|
|
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"
|
|
@@ -180,6 +180,7 @@ markers = [
|
|
|
180
180
|
"integration: Integration tests (slower, require database)",
|
|
181
181
|
"slow: Slow tests (may take a long time)",
|
|
182
182
|
"requires_db: Needs a reachable external database (e.g. Postgres)",
|
|
183
|
+
"requires_network: Needs the public internet (e.g. an example reading a remote CSV)",
|
|
183
184
|
]
|
|
184
185
|
asyncio_mode = "auto"
|
|
185
186
|
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
|
+
import time
|
|
7
8
|
from datetime import datetime
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -467,6 +468,7 @@ async def _execute(
|
|
|
467
468
|
restate=restate,
|
|
468
469
|
)
|
|
469
470
|
progress = _build_progress(plan_result)
|
|
471
|
+
started = time.perf_counter()
|
|
470
472
|
try:
|
|
471
473
|
with progress.progress if progress else contextlib.nullcontext():
|
|
472
474
|
result = await apply_plan(
|
|
@@ -487,7 +489,7 @@ async def _execute(
|
|
|
487
489
|
await _render_empty_incrementals(result, compiled, engines)
|
|
488
490
|
verb = "Restated" if restate else "Ran"
|
|
489
491
|
console.print(
|
|
490
|
-
f"[green]{verb} {len(set(result.built))} model(s)
|
|
492
|
+
f"[green]{verb} {len(set(result.built))} model(s) in {time.perf_counter() - started:.2f}s; "
|
|
491
493
|
f"promoted {result.promoted} to '{environment}'.[/green]"
|
|
492
494
|
)
|
|
493
495
|
finally:
|
|
@@ -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.
|
|
@@ -9,8 +9,10 @@ cleared first for a clean load.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import contextlib
|
|
12
13
|
import importlib.util
|
|
13
14
|
import sys
|
|
15
|
+
from collections.abc import Iterator
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any
|
|
16
18
|
|
|
@@ -18,22 +20,61 @@ from interlace.checks.spec import parse_checks
|
|
|
18
20
|
from interlace.dsl.decorators import REGISTRY, ModelDef, _as_columns, _as_tuple, validate_materialise
|
|
19
21
|
from interlace.dsl.sql_config import extract_sql_config
|
|
20
22
|
from interlace.exceptions import DefinitionError, InterlaceError
|
|
23
|
+
from interlace.ir.macros import Macro, parse_macros
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def discover_models(root: Path, model_paths: list[str], default_dialect: str) -> list[ModelDef]:
|
|
24
27
|
REGISTRY.clear()
|
|
25
|
-
|
|
28
|
+
imported = set(sys.modules)
|
|
29
|
+
try:
|
|
30
|
+
for relative in model_paths:
|
|
31
|
+
base = root / relative
|
|
32
|
+
if not base.is_dir():
|
|
33
|
+
continue
|
|
34
|
+
for sql_file in sorted(base.rglob("*.sql")):
|
|
35
|
+
config, sql = extract_sql_config(sql_file.read_text())
|
|
36
|
+
REGISTRY.register_model(_sql_model(_model_name(base, sql_file), sql, config, default_dialect))
|
|
37
|
+
for py_file in sorted(base.rglob("*.py")):
|
|
38
|
+
if py_file.name.startswith("_"):
|
|
39
|
+
continue
|
|
40
|
+
_import_module(base, py_file)
|
|
41
|
+
finally:
|
|
42
|
+
_forget_project_modules(root, imported)
|
|
43
|
+
return list(REGISTRY.models.values())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _forget_project_modules(root: Path, before: set[str]) -> None:
|
|
47
|
+
"""Drop the project's own helper modules from the import cache.
|
|
48
|
+
|
|
49
|
+
A helper is imported under whatever plain name it has (``_macros``), so leaving it
|
|
50
|
+
cached would hand the *next* project — a reload under ``interlace serve``, another
|
|
51
|
+
project in the same process — the first one's version of a file with the same name.
|
|
52
|
+
Only modules loaded from inside this project during this pass are dropped."""
|
|
53
|
+
for name in set(sys.modules) - before:
|
|
54
|
+
module = sys.modules.get(name)
|
|
55
|
+
origin = getattr(module, "__file__", None)
|
|
56
|
+
if origin and Path(origin).is_relative_to(root):
|
|
57
|
+
del sys.modules[name]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover_macros(root: Path, macro_paths: list[str], default_dialect: str) -> dict[str, Macro]:
|
|
61
|
+
"""Parse every ``CREATE MACRO`` under the macro paths, keyed by casefolded name."""
|
|
62
|
+
macros: dict[str, Macro] = {}
|
|
63
|
+
for relative in macro_paths:
|
|
26
64
|
base = root / relative
|
|
27
65
|
if not base.is_dir():
|
|
28
66
|
continue
|
|
29
|
-
for
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
for macro_file in sorted(base.rglob("*.sql")):
|
|
68
|
+
source = str(macro_file.relative_to(root))
|
|
69
|
+
for macro in parse_macros(macro_file.read_text(), default_dialect, source):
|
|
70
|
+
key = macro.name.casefold()
|
|
71
|
+
if key in macros:
|
|
72
|
+
raise DefinitionError(
|
|
73
|
+
f"macro {macro.name!r} is defined twice: {macros[key].source} and {source}",
|
|
74
|
+
details={"macro": macro.name},
|
|
75
|
+
)
|
|
76
|
+
macros[key] = macro
|
|
77
|
+
return macros
|
|
37
78
|
|
|
38
79
|
|
|
39
80
|
def _sql_model(default_name: str, sql: str, config: dict[str, Any], default_dialect: str) -> ModelDef:
|
|
@@ -89,6 +130,18 @@ def _relative_to_cwd(file: Path) -> str:
|
|
|
89
130
|
return str(file)
|
|
90
131
|
|
|
91
132
|
|
|
133
|
+
@contextlib.contextmanager
|
|
134
|
+
def _importable(directory: Path) -> Iterator[None]:
|
|
135
|
+
"""Put ``directory`` at the front of ``sys.path``, and take it back off."""
|
|
136
|
+
entry = str(directory)
|
|
137
|
+
sys.path.insert(0, entry)
|
|
138
|
+
try:
|
|
139
|
+
yield
|
|
140
|
+
finally:
|
|
141
|
+
with contextlib.suppress(ValueError):
|
|
142
|
+
sys.path.remove(entry)
|
|
143
|
+
|
|
144
|
+
|
|
92
145
|
def _import_module(base: Path, file: Path) -> None:
|
|
93
146
|
module_name = "interlace_model_" + "_".join(file.relative_to(base).with_suffix("").parts)
|
|
94
147
|
spec = importlib.util.spec_from_file_location(module_name, file)
|
|
@@ -97,7 +150,13 @@ def _import_module(base: Path, file: Path) -> None:
|
|
|
97
150
|
module = importlib.util.module_from_spec(spec)
|
|
98
151
|
sys.modules[module_name] = module
|
|
99
152
|
try:
|
|
100
|
-
|
|
153
|
+
# The model's own directory goes on the path for the duration of the import, so a
|
|
154
|
+
# model can `from _shared import ...` a helper sitting next to it — the closest
|
|
155
|
+
# thing to a macro. Files starting with `_` are already skipped as models, which
|
|
156
|
+
# is only useful if they can be imported. Removed again straight after: a project's
|
|
157
|
+
# helpers must not leak into the import path of everything that follows.
|
|
158
|
+
with _importable(file.parent):
|
|
159
|
+
spec.loader.exec_module(module)
|
|
101
160
|
except InterlaceError:
|
|
102
161
|
raise # a bad @model config is already a clean, actionable error — don't rewrap it
|
|
103
162
|
except Exception as exc:
|
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import inspect
|
|
14
14
|
import textwrap
|
|
15
|
-
from collections.abc import Iterable
|
|
15
|
+
from collections.abc import Iterable, Mapping
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
17
|
|
|
18
18
|
from sqlglot import exp
|
|
@@ -23,6 +23,7 @@ from interlace.exceptions import CompilationError, DefinitionError
|
|
|
23
23
|
from interlace.graph.dag import DependencyGraph
|
|
24
24
|
from interlace.ir.canonicalize import parse, table_references
|
|
25
25
|
from interlace.ir.fingerprint import canonical_sql, data_fingerprint, metadata_fingerprint
|
|
26
|
+
from interlace.ir.macros import Macro, expand_macros
|
|
26
27
|
from interlace.ir.relation import TableRef
|
|
27
28
|
|
|
28
29
|
_PHYSICAL_PREFIX = "interlace__"
|
|
@@ -93,7 +94,7 @@ def _physical_table(name: str, fingerprint: str, catalog: str | None) -> TableRe
|
|
|
93
94
|
|
|
94
95
|
|
|
95
96
|
def _resolve_dependencies(
|
|
96
|
-
model: ModelDef, names: set[str], default_dialect: str
|
|
97
|
+
model: ModelDef, names: set[str], default_dialect: str, macros: Mapping[str, Macro]
|
|
97
98
|
) -> tuple[tuple[str, ...], exp.Expression | None, str]:
|
|
98
99
|
dialect = model.dialect or default_dialect
|
|
99
100
|
deps: list[str] = []
|
|
@@ -115,6 +116,10 @@ def _resolve_dependencies(
|
|
|
115
116
|
raise CompilationError(
|
|
116
117
|
f"model {model.name!r}: {exc.message}", details={**exc.details, "model": model.name}
|
|
117
118
|
) from exc
|
|
119
|
+
# Before anything reads the AST: the fingerprint is canonical SQL, so expanding
|
|
120
|
+
# here is what makes a macro edit rebuild its callers, and what lets a macro body
|
|
121
|
+
# reference a model and have that count as a dependency.
|
|
122
|
+
ast = expand_macros(ast, macros, model.name)
|
|
118
123
|
for ref in table_references(ast):
|
|
119
124
|
if ref in names:
|
|
120
125
|
add(ref)
|
|
@@ -149,12 +154,14 @@ def compile_models(
|
|
|
149
154
|
known_engines: set[str] | None = None,
|
|
150
155
|
catalog: str | None = None,
|
|
151
156
|
checks: Iterable[CheckDef] = (),
|
|
157
|
+
macros: Mapping[str, Macro] | None = None,
|
|
152
158
|
) -> CompiledProject:
|
|
153
159
|
"""Compile models into a fingerprinted, topologically-ordered project.
|
|
154
160
|
|
|
155
161
|
``checks`` are ``@check``-decorated Python functions, attached by model name.
|
|
156
162
|
``engine_dialects`` maps engine name → sqlglot dialect (used when a model
|
|
157
163
|
omits ``dialect``). ``known_engines`` validates model ``engine`` pins.
|
|
164
|
+
``macros`` are expanded into every model's AST before it is fingerprinted.
|
|
158
165
|
"""
|
|
159
166
|
definitions = {m.name: m for m in models}
|
|
160
167
|
names = set(definitions)
|
|
@@ -178,7 +185,7 @@ def compile_models(
|
|
|
178
185
|
)
|
|
179
186
|
# Authoring dialect: explicit model dialect, else the engine's, else project default.
|
|
180
187
|
model_default_dialect = dialects_by_engine.get(engine, default_dialect)
|
|
181
|
-
deps, ast, dialect = _resolve_dependencies(definition, names, model_default_dialect)
|
|
188
|
+
deps, ast, dialect = _resolve_dependencies(definition, names, model_default_dialect, macros or {})
|
|
182
189
|
resolved[name] = (deps, ast, dialect, engine)
|
|
183
190
|
graph.add_node(name)
|
|
184
191
|
for dep in deps:
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""SQL macros: one definition, expanded into the IR before anything else sees it.
|
|
2
|
+
|
|
3
|
+
A macro is written in SQL, in the engine's own ``CREATE MACRO`` syntax::
|
|
4
|
+
|
|
5
|
+
CREATE MACRO cents_to_dollars(amount) AS (amount / 100)::numeric(16, 2);
|
|
6
|
+
|
|
7
|
+
and called like a function from any model::
|
|
8
|
+
|
|
9
|
+
SELECT cents_to_dollars(subtotal) AS subtotal FROM raw_orders
|
|
10
|
+
|
|
11
|
+
The call is substituted for the macro's body while the model is compiled — before the
|
|
12
|
+
fingerprint, before lineage, before transpilation. That ordering is the whole design:
|
|
13
|
+
|
|
14
|
+
- **The fingerprint covers the macro.** A model's fingerprint is its canonical SQL, and
|
|
15
|
+
the expansion is part of it, so editing a macro rebuilds every model that calls it.
|
|
16
|
+
A macro created in the warehouse instead would be invisible to the fingerprint, and a
|
|
17
|
+
changed one would leave stale tables behind with nothing to notice.
|
|
18
|
+
- **Lineage sees through it.** Column lineage reads the AST, and the AST no longer has
|
|
19
|
+
an opaque function call in it.
|
|
20
|
+
- **One definition, every engine.** The expansion happens in dialect-agnostic AST, so
|
|
21
|
+
the transpiler renders it per engine. This is the job dbt does with adapter dispatch —
|
|
22
|
+
``postgres__cents_to_dollars``, ``bigquery__cents_to_dollars`` — and it is not needed
|
|
23
|
+
here: ``(amount / 100)`` becomes integer division on Postgres by itself, so sqlglot
|
|
24
|
+
emits ``CAST(amount AS DOUBLE PRECISION) / NULLIF(100, 0)`` and the macro stays one
|
|
25
|
+
line.
|
|
26
|
+
|
|
27
|
+
The cost, and it is a real one: the macro exists at build time, not in the warehouse.
|
|
28
|
+
Someone querying the tables by hand cannot call it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
import sqlglot
|
|
37
|
+
from sqlglot import exp
|
|
38
|
+
|
|
39
|
+
from interlace.exceptions import DefinitionError
|
|
40
|
+
|
|
41
|
+
_MAX_DEPTH = 10 # macros may call macros; this is the runaway/recursion stop
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Macro:
|
|
46
|
+
"""A parsed ``CREATE MACRO``: its name, parameter names, and body expression."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
params: tuple[str, ...]
|
|
50
|
+
body: exp.Expression
|
|
51
|
+
source: str # the file it came from, for error messages
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_macros(sql: str, dialect: str, source: str) -> list[Macro]:
|
|
55
|
+
"""Parse the ``CREATE MACRO`` statements in one file."""
|
|
56
|
+
try:
|
|
57
|
+
statements = sqlglot.parse(sql, read=dialect)
|
|
58
|
+
except Exception as exc: # noqa: BLE001 — sqlglot raises a family of parse errors
|
|
59
|
+
raise DefinitionError(f"could not parse macros in {source}: {exc}", details={"source": source}) from exc
|
|
60
|
+
|
|
61
|
+
macros: list[Macro] = []
|
|
62
|
+
for statement in statements:
|
|
63
|
+
if statement is None:
|
|
64
|
+
continue
|
|
65
|
+
if isinstance(statement, exp.Command):
|
|
66
|
+
# sqlglot could not parse it — a table macro (AS TABLE SELECT ...) lands here
|
|
67
|
+
raise DefinitionError(
|
|
68
|
+
f"{source}: could not read this as a macro definition: {statement.sql()[:80]!r}. A macro is a "
|
|
69
|
+
f"scalar expression — CREATE MACRO name(args) AS <expression>. A table macro has no call site "
|
|
70
|
+
f"to expand into; write it as a model.",
|
|
71
|
+
details={"source": source},
|
|
72
|
+
)
|
|
73
|
+
udf = statement.this if isinstance(statement, exp.Create) else None
|
|
74
|
+
if not isinstance(udf, exp.UserDefinedFunction):
|
|
75
|
+
raise DefinitionError(
|
|
76
|
+
f"{source}: expected only CREATE MACRO statements, found {type(statement).__name__.upper()}",
|
|
77
|
+
details={"source": source},
|
|
78
|
+
)
|
|
79
|
+
body = statement.expression
|
|
80
|
+
if body is None or isinstance(body, exp.Query):
|
|
81
|
+
raise DefinitionError(
|
|
82
|
+
f"{source}: macro {udf.this.name!r} must be a scalar expression — a table macro "
|
|
83
|
+
f"(AS TABLE SELECT ...) has no call site to expand into; write it as a model",
|
|
84
|
+
details={"source": source, "macro": udf.this.name},
|
|
85
|
+
)
|
|
86
|
+
# the UDF's `this` is a Table node wrapping the identifier, so the name is a
|
|
87
|
+
# level down from where UserDefinedFunction.name looks
|
|
88
|
+
name = udf.this.name if isinstance(udf.this, exp.Expression) else str(udf.this)
|
|
89
|
+
macros.append(
|
|
90
|
+
Macro(
|
|
91
|
+
name=name,
|
|
92
|
+
params=tuple(param.name for param in udf.expressions),
|
|
93
|
+
body=body,
|
|
94
|
+
source=source,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return macros
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def expand_macros(ast: exp.Expression, macros: Mapping[str, Macro], model: str) -> exp.Expression:
|
|
101
|
+
"""Substitute every macro call in ``ast`` for its body. Returns a new expression."""
|
|
102
|
+
if not macros:
|
|
103
|
+
return ast
|
|
104
|
+
expanded = ast
|
|
105
|
+
for _ in range(_MAX_DEPTH):
|
|
106
|
+
expanded, hits = _expand_once(expanded, macros, model)
|
|
107
|
+
if not hits:
|
|
108
|
+
return expanded
|
|
109
|
+
raise DefinitionError(
|
|
110
|
+
f"model {model!r}: macro expansion did not settle after {_MAX_DEPTH} passes — a macro calls itself, "
|
|
111
|
+
f"directly or through another",
|
|
112
|
+
details={"model": model},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _expand_once(ast: exp.Expression, macros: Mapping[str, Macro], model: str) -> tuple[exp.Expression, int]:
|
|
117
|
+
hits = 0
|
|
118
|
+
|
|
119
|
+
def substitute(node: exp.Expression) -> exp.Expression:
|
|
120
|
+
nonlocal hits
|
|
121
|
+
if not isinstance(node, exp.Anonymous):
|
|
122
|
+
return node
|
|
123
|
+
macro = macros.get(node.name.casefold())
|
|
124
|
+
if macro is None:
|
|
125
|
+
return node # an ordinary engine function
|
|
126
|
+
args = list(node.expressions)
|
|
127
|
+
if len(args) != len(macro.params):
|
|
128
|
+
raise DefinitionError(
|
|
129
|
+
f"model {model!r}: macro {macro.name!r} takes {len(macro.params)} argument(s) "
|
|
130
|
+
f"({', '.join(macro.params) or 'none'}), called with {len(args)}",
|
|
131
|
+
details={"model": model, "macro": macro.name, "source": macro.source},
|
|
132
|
+
)
|
|
133
|
+
hits += 1
|
|
134
|
+
return exp.paren(_bind(macro, args))
|
|
135
|
+
|
|
136
|
+
return ast.transform(substitute, copy=True), hits
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _bind(macro: Macro, args: list[exp.Expression]) -> exp.Expression:
|
|
140
|
+
"""The macro's body with each parameter reference replaced by its argument."""
|
|
141
|
+
body = macro.body.copy()
|
|
142
|
+
if not macro.params:
|
|
143
|
+
return body
|
|
144
|
+
by_name = {param.casefold(): arg for param, arg in zip(macro.params, args, strict=True)}
|
|
145
|
+
|
|
146
|
+
def replace(node: exp.Expression) -> exp.Expression:
|
|
147
|
+
if isinstance(node, exp.Column) and not node.table:
|
|
148
|
+
argument = by_name.get(node.name.casefold())
|
|
149
|
+
if argument is not None:
|
|
150
|
+
return argument.copy()
|
|
151
|
+
return node
|
|
152
|
+
|
|
153
|
+
bound: exp.Expression = body.transform(replace, copy=False)
|
|
154
|
+
return bound
|
|
@@ -733,16 +733,19 @@ async def apply(
|
|
|
733
733
|
return found
|
|
734
734
|
|
|
735
735
|
# Checks that read *other* models (relationships, sql) add scheduling edges the
|
|
736
|
-
# data DAG doesn't have
|
|
737
|
-
#
|
|
738
|
-
#
|
|
739
|
-
#
|
|
740
|
-
|
|
736
|
+
# data DAG doesn't have. Keep one unless it would close a cycle — which it does
|
|
737
|
+
# exactly when the reader is already an ancestor of what its check reads (a check
|
|
738
|
+
# pointing downstream, e.g. order_items testing against orders, which is built
|
|
739
|
+
# from order_items). Those are dropped: the check runs before its target exists
|
|
740
|
+
# and fails, but a cycle would hang the whole apply, and the model can point its
|
|
741
|
+
# check at an upstream instead. Everything else is a plain sibling reference and
|
|
742
|
+
# is enforced — dropping those on a topological-order heuristic failed checks
|
|
743
|
+
# whose target simply happened to sort later.
|
|
741
744
|
blocking: dict[str, set[str]] = {}
|
|
742
745
|
for name in per_model:
|
|
743
746
|
deps = in_plan_ancestors(name)
|
|
744
747
|
for ref in _check_references(compiled.models[name], compiled):
|
|
745
|
-
if ref in per_model and
|
|
748
|
+
if ref in per_model and name not in compiled.graph.ancestors(ref):
|
|
746
749
|
deps.add(ref)
|
|
747
750
|
blocking[name] = deps
|
|
748
751
|
|
|
@@ -9,17 +9,18 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
-
from dataclasses import dataclass
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from interlace.config.config import CONFIG_FILE, EngineConfig, ProjectConfig, SecretConfig, load_config
|
|
16
16
|
from interlace.dsl.decorators import REGISTRY, CheckDef, ModelDef, StreamDef
|
|
17
|
-
from interlace.dsl.discovery import discover_models
|
|
17
|
+
from interlace.dsl.discovery import discover_macros, discover_models
|
|
18
18
|
from interlace.engines.base import EngineAdapter
|
|
19
19
|
from interlace.engines.duckdb import DuckDBAdapter
|
|
20
20
|
from interlace.engines.registry import EngineRegistry
|
|
21
21
|
from interlace.exceptions import ConfigurationError
|
|
22
22
|
from interlace.graph.project import CompiledProject, compile_models
|
|
23
|
+
from interlace.ir.macros import Macro
|
|
23
24
|
from interlace.state.store import SqliteStateStore
|
|
24
25
|
from interlace.streaming.log import SqliteStreamLog
|
|
25
26
|
|
|
@@ -86,6 +87,7 @@ class Project:
|
|
|
86
87
|
models: list[ModelDef]
|
|
87
88
|
checks: list[CheckDef]
|
|
88
89
|
streams: list[StreamDef]
|
|
90
|
+
macros: dict[str, Macro] = field(default_factory=dict)
|
|
89
91
|
|
|
90
92
|
@classmethod
|
|
91
93
|
def load(cls, root: Path | str) -> Project:
|
|
@@ -98,6 +100,7 @@ class Project:
|
|
|
98
100
|
models=models,
|
|
99
101
|
checks=list(REGISTRY.checks),
|
|
100
102
|
streams=list(REGISTRY.streams.values()),
|
|
103
|
+
macros=discover_macros(root, config.macro_paths, config.default_dialect),
|
|
101
104
|
)
|
|
102
105
|
|
|
103
106
|
def compile(self) -> CompiledProject:
|
|
@@ -109,6 +112,7 @@ class Project:
|
|
|
109
112
|
engine_dialects={name: cfg.resolved_dialect() for name, cfg in engine_cfgs.items()},
|
|
110
113
|
known_engines=set(engine_cfgs),
|
|
111
114
|
checks=self.checks,
|
|
115
|
+
macros=self.macros,
|
|
112
116
|
)
|
|
113
117
|
|
|
114
118
|
def open_engine(self, name: str | None = None) -> EngineAdapter:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interlaced
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.1
|
|
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
|
|
@@ -41,6 +41,7 @@ src/interlace/graph/selectors.py
|
|
|
41
41
|
src/interlace/ir/__init__.py
|
|
42
42
|
src/interlace/ir/canonicalize.py
|
|
43
43
|
src/interlace/ir/fingerprint.py
|
|
44
|
+
src/interlace/ir/macros.py
|
|
44
45
|
src/interlace/ir/relation.py
|
|
45
46
|
src/interlace/plan/__init__.py
|
|
46
47
|
src/interlace/plan/apply.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_minute.sql
RENAMED
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_type.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/github_issues.py
RENAMED
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/issues_by_state.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders_by_status.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/enriched_events.py
RENAMED
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/event_summary.sql
RENAMED
|
File without changes
|
{interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/raw_events.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|