interlaced 2.3.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.3.0/src/interlaced.egg-info → interlaced-2.4.1}/PKG-INFO +47 -11
- {interlaced-2.3.0 → interlaced-2.4.1}/README.md +43 -9
- {interlaced-2.3.0 → interlaced-2.4.1}/pyproject.toml +7 -2
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/cli/main.py +3 -1
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/config/config.py +11 -7
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/dsl/decorators.py +12 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/dsl/discovery.py +69 -10
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/duckdb.py +54 -3
- {interlaced-2.3.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.3.0 → interlaced-2.4.1}/src/interlace/plan/apply.py +126 -30
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/project.py +12 -3
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/scaffold.py +19 -2
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/append.py +12 -4
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/base.py +13 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/hash_merge.py +40 -18
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/incremental.py +6 -0
- interlaced-2.4.1/src/interlace/strategies/merge.py +188 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/scd.py +9 -1
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/interlace.yaml +2 -1
- interlaced-2.4.1/src/interlace/templates/quickstart/interlace.yaml +6 -0
- {interlaced-2.3.0 → interlaced-2.4.1/src/interlaced.egg-info}/PKG-INFO +47 -11
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlaced.egg-info/SOURCES.txt +1 -0
- interlaced-2.3.0/src/interlace/strategies/merge.py +0 -115
- interlaced-2.3.0/src/interlace/templates/quickstart/interlace.yaml +0 -6
- {interlaced-2.3.0 → interlaced-2.4.1}/LICENSE +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/MANIFEST.in +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/setup.cfg +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/checks/builtin.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/contracts.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/adbc.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/base.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/postgres.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/registry.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/snowflake.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/exceptions.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/ir/fingerprint.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/plan/differ.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/plan/run.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/py.typed +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/query.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/runtime/python_model.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/scheduler/engine.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/scheduler/triggers.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/scheduler/worker.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/app.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/auth.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/plan.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/system.js +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/sinks.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/state/interval.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/state/janitor.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/state/store.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/full_merge.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/streaming/log.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/streaming/materializer.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/streaming/schema.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlaced.egg-info/requires.txt +0 -0
- {interlaced-2.3.0 → interlaced-2.4.1}/src/interlaced.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: interlaced
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: Python
|
|
3
|
+
Version: 2.4.1
|
|
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
|
|
7
7
|
Project-URL: Homepage, https://interlace.sh
|
|
@@ -13,8 +13,10 @@ Keywords: data,pipeline,orchestration,transformation,etl,streaming,dbt,sqlmesh,d
|
|
|
13
13
|
Classifier: Development Status :: 5 - Production/Stable
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Classifier: Topic :: Database
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Classifier: Programming Language :: SQL
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
20
|
Requires-Python: >=3.12
|
|
19
21
|
Description-Content-Type: text/markdown
|
|
20
22
|
License-File: LICENSE
|
|
@@ -64,15 +66,41 @@ Dynamic: license-file
|
|
|
64
66
|
|
|
65
67
|
# interlace
|
|
66
68
|
|
|
67
|
-
**Python
|
|
69
|
+
**Python and SQL models are the same kind of node in one DAG.**
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
A `.py` model sits mid-graph with SQL either side, in both directions, with no bridge and no
|
|
72
|
+
separate runtime — running in-process on DuckDB and Postgres, not only on a cloud warehouse.
|
|
73
|
+
The Python model stays a plain function: call it in a test with no warehouse and no session.
|
|
74
|
+
|
|
75
|
+
How that compares with dbt and SQLMesh, including where they are ahead, is on
|
|
76
|
+
[interlace.sh/why](https://interlace.sh/why).
|
|
77
|
+
|
|
78
|
+
```sql
|
|
79
|
+
-- models/raw_events.sql SQL
|
|
80
|
+
SELECT event_id, user_id, kind, amount, country, ts FROM read_parquet('events/*.parquet')
|
|
81
|
+
```
|
|
82
|
+
```python
|
|
83
|
+
# models/enriched_events.py Python, mid-DAG
|
|
84
|
+
@model() # the parameter name IS the dependency — no depends_on
|
|
85
|
+
def enriched_events(raw_events):
|
|
86
|
+
for batch in raw_events.reader(): # Arrow in, Arrow out, bounded memory
|
|
87
|
+
yield add_revenue(batch)
|
|
88
|
+
```
|
|
89
|
+
```sql
|
|
90
|
+
-- models/event_summary.sql SQL again, straight over the Python
|
|
91
|
+
SELECT country, count(*) FILTER (WHERE is_conversion) AS conversions
|
|
92
|
+
FROM enriched_events GROUP BY country
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`interlace init` scaffolds exactly this shape, runnable, with no external source.
|
|
96
|
+
|
|
97
|
+
That is the wedge. The rest is the reveal: interlace is an independent, MIT-licensed alternative
|
|
98
|
+
to dbt/SQLMesh that also replaces the orchestrator (no Airflow) and the ingestion layer
|
|
99
|
+
(Cloudflare-Pipelines-style durable streams). State is versioned snapshots with virtual
|
|
72
100
|
environments and a terraform-style plan/apply; everything runs in a single daemon on
|
|
73
|
-
DuckDB
|
|
101
|
+
DuckDB by default (DuckLake one config line away).
|
|
74
102
|
|
|
75
|
-
> **
|
|
103
|
+
> **2.x — see [releases](https://github.com/interlace-sh/interlace/releases).** Requires Python 3.12+.
|
|
76
104
|
> The package is published to PyPI as **`interlaced`**; the import name and CLI are `interlace`.
|
|
77
105
|
|
|
78
106
|
```bash
|
|
@@ -80,6 +108,13 @@ pip install 'interlaced[service]' # the CLI + daemon; core CLI only: pip insta
|
|
|
80
108
|
# more extras: [adbc] postgres/redshift · [spark] · [polars] · [all]
|
|
81
109
|
```
|
|
82
110
|
|
|
111
|
+
> **Platforms.** Developed on Linux; CI runs Linux only. Nothing in the codebase is
|
|
112
|
+
> platform-specific — no `fork`, no signal handling, no POSIX-only calls, no shelling out — and
|
|
113
|
+
> every dependency ships macOS and Windows wheels, so both are expected to work. But
|
|
114
|
+
> **neither is tested**, so treat them as unverified rather than supported. If you run interlace
|
|
115
|
+
> on macOS or Windows, please open an issue either way; that is the fastest route to changing
|
|
116
|
+
> this paragraph.
|
|
117
|
+
|
|
83
118
|
## Sixty seconds
|
|
84
119
|
|
|
85
120
|
```bash
|
|
@@ -210,7 +245,7 @@ prod, so a dev apply never writes to a live external table (opt in with
|
|
|
210
245
|
|
|
211
246
|
## Multi-engine
|
|
212
247
|
|
|
213
|
-
Models run on **named engines**: DuckDB
|
|
248
|
+
Models run on **named engines**: DuckDB by default (DuckLake opt-in), Postgres natively over ADBC
|
|
214
249
|
(`pip install 'interlaced[adbc]'`), Spark (beta, `[spark]` extra), plus alpha adapters for
|
|
215
250
|
MotherDuck, Redshift, Snowflake and BigQuery (wired and dialect-correct, not yet run against a
|
|
216
251
|
live account), with per-model pinning:
|
|
@@ -252,7 +287,8 @@ other processes (CLI runs, ad-hoc DuckDB clients) then share it concurrently by
|
|
|
252
287
|
|
|
253
288
|
- The IR is a **sqlglot AST**; the wire format is an **Arrow RecordBatchReader**; strategies
|
|
254
289
|
are AST builders and dialect appears only at `transpile()`.
|
|
255
|
-
- Storage defaults to **DuckLake** (Parquet + SQL catalog
|
|
290
|
+
- Storage defaults to a plain **DuckDB** file; **DuckLake** (Parquet + SQL catalog, and
|
|
291
|
+
concurrent writers) is one `database: ducklake:…` line away.
|
|
256
292
|
- Control plane (snapshots, intervals, queue, events, keys) is **SQLite WAL**; Postgres is the
|
|
257
293
|
scale-out swap.
|
|
258
294
|
- Streams live in their own durable log; the materializer commits data + watermark in one
|
|
@@ -269,7 +305,7 @@ Toolchain is pinned with [proto](https://moonrepo.dev/proto), tasks run via
|
|
|
269
305
|
```bash
|
|
270
306
|
proto install
|
|
271
307
|
moon run interlace:sync # install deps
|
|
272
|
-
moon run interlace:test #
|
|
308
|
+
moon run interlace:test # 500+ tests
|
|
273
309
|
moon run interlace:check # black + ruff (CI equivalent)
|
|
274
310
|
moon run interlace:typecheck # mypy
|
|
275
311
|
```
|
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
# interlace
|
|
2
2
|
|
|
3
|
-
**Python
|
|
3
|
+
**Python and SQL models are the same kind of node in one DAG.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
A `.py` model sits mid-graph with SQL either side, in both directions, with no bridge and no
|
|
6
|
+
separate runtime — running in-process on DuckDB and Postgres, not only on a cloud warehouse.
|
|
7
|
+
The Python model stays a plain function: call it in a test with no warehouse and no session.
|
|
8
|
+
|
|
9
|
+
How that compares with dbt and SQLMesh, including where they are ahead, is on
|
|
10
|
+
[interlace.sh/why](https://interlace.sh/why).
|
|
11
|
+
|
|
12
|
+
```sql
|
|
13
|
+
-- models/raw_events.sql SQL
|
|
14
|
+
SELECT event_id, user_id, kind, amount, country, ts FROM read_parquet('events/*.parquet')
|
|
15
|
+
```
|
|
16
|
+
```python
|
|
17
|
+
# models/enriched_events.py Python, mid-DAG
|
|
18
|
+
@model() # the parameter name IS the dependency — no depends_on
|
|
19
|
+
def enriched_events(raw_events):
|
|
20
|
+
for batch in raw_events.reader(): # Arrow in, Arrow out, bounded memory
|
|
21
|
+
yield add_revenue(batch)
|
|
22
|
+
```
|
|
23
|
+
```sql
|
|
24
|
+
-- models/event_summary.sql SQL again, straight over the Python
|
|
25
|
+
SELECT country, count(*) FILTER (WHERE is_conversion) AS conversions
|
|
26
|
+
FROM enriched_events GROUP BY country
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`interlace init` scaffolds exactly this shape, runnable, with no external source.
|
|
30
|
+
|
|
31
|
+
That is the wedge. The rest is the reveal: interlace is an independent, MIT-licensed alternative
|
|
32
|
+
to dbt/SQLMesh that also replaces the orchestrator (no Airflow) and the ingestion layer
|
|
33
|
+
(Cloudflare-Pipelines-style durable streams). State is versioned snapshots with virtual
|
|
8
34
|
environments and a terraform-style plan/apply; everything runs in a single daemon on
|
|
9
|
-
DuckDB
|
|
35
|
+
DuckDB by default (DuckLake one config line away).
|
|
10
36
|
|
|
11
|
-
> **
|
|
37
|
+
> **2.x — see [releases](https://github.com/interlace-sh/interlace/releases).** Requires Python 3.12+.
|
|
12
38
|
> The package is published to PyPI as **`interlaced`**; the import name and CLI are `interlace`.
|
|
13
39
|
|
|
14
40
|
```bash
|
|
@@ -16,6 +42,13 @@ pip install 'interlaced[service]' # the CLI + daemon; core CLI only: pip insta
|
|
|
16
42
|
# more extras: [adbc] postgres/redshift · [spark] · [polars] · [all]
|
|
17
43
|
```
|
|
18
44
|
|
|
45
|
+
> **Platforms.** Developed on Linux; CI runs Linux only. Nothing in the codebase is
|
|
46
|
+
> platform-specific — no `fork`, no signal handling, no POSIX-only calls, no shelling out — and
|
|
47
|
+
> every dependency ships macOS and Windows wheels, so both are expected to work. But
|
|
48
|
+
> **neither is tested**, so treat them as unverified rather than supported. If you run interlace
|
|
49
|
+
> on macOS or Windows, please open an issue either way; that is the fastest route to changing
|
|
50
|
+
> this paragraph.
|
|
51
|
+
|
|
19
52
|
## Sixty seconds
|
|
20
53
|
|
|
21
54
|
```bash
|
|
@@ -146,7 +179,7 @@ prod, so a dev apply never writes to a live external table (opt in with
|
|
|
146
179
|
|
|
147
180
|
## Multi-engine
|
|
148
181
|
|
|
149
|
-
Models run on **named engines**: DuckDB
|
|
182
|
+
Models run on **named engines**: DuckDB by default (DuckLake opt-in), Postgres natively over ADBC
|
|
150
183
|
(`pip install 'interlaced[adbc]'`), Spark (beta, `[spark]` extra), plus alpha adapters for
|
|
151
184
|
MotherDuck, Redshift, Snowflake and BigQuery (wired and dialect-correct, not yet run against a
|
|
152
185
|
live account), with per-model pinning:
|
|
@@ -188,7 +221,8 @@ other processes (CLI runs, ad-hoc DuckDB clients) then share it concurrently by
|
|
|
188
221
|
|
|
189
222
|
- The IR is a **sqlglot AST**; the wire format is an **Arrow RecordBatchReader**; strategies
|
|
190
223
|
are AST builders and dialect appears only at `transpile()`.
|
|
191
|
-
- Storage defaults to **DuckLake** (Parquet + SQL catalog
|
|
224
|
+
- Storage defaults to a plain **DuckDB** file; **DuckLake** (Parquet + SQL catalog, and
|
|
225
|
+
concurrent writers) is one `database: ducklake:…` line away.
|
|
192
226
|
- Control plane (snapshots, intervals, queue, events, keys) is **SQLite WAL**; Postgres is the
|
|
193
227
|
scale-out swap.
|
|
194
228
|
- Streams live in their own durable log; the materializer commits data + watermark in one
|
|
@@ -205,7 +239,7 @@ Toolchain is pinned with [proto](https://moonrepo.dev/proto), tasks run via
|
|
|
205
239
|
```bash
|
|
206
240
|
proto install
|
|
207
241
|
moon run interlace:sync # install deps
|
|
208
|
-
moon run interlace:test #
|
|
242
|
+
moon run interlace:test # 500+ tests
|
|
209
243
|
moon run interlace:check # black + ruff (CI equivalent)
|
|
210
244
|
moon run interlace:typecheck # mypy
|
|
211
245
|
```
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "interlaced"
|
|
7
|
-
version = "2.
|
|
8
|
-
description = "Python
|
|
7
|
+
version = "2.4.1"
|
|
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"
|
|
11
11
|
license = "MIT"
|
|
@@ -17,8 +17,12 @@ classifiers = [
|
|
|
17
17
|
"Development Status :: 5 - Production/Stable",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Topic :: Software Development :: Build Tools",
|
|
20
|
+
"Topic :: Database",
|
|
20
21
|
"Programming Language :: Python :: 3.12",
|
|
21
22
|
"Programming Language :: SQL",
|
|
23
|
+
# Linux is what CI runs and what this is developed on. Nothing in the codebase
|
|
24
|
+
# is platform-specific, but macOS and Windows are untested — see the README.
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
22
26
|
]
|
|
23
27
|
|
|
24
28
|
# Core: the IR + engine + state + plan spine (Phase 1). Everything is Arrow- and
|
|
@@ -176,6 +180,7 @@ markers = [
|
|
|
176
180
|
"integration: Integration tests (slower, require database)",
|
|
177
181
|
"slow: Slow tests (may take a long time)",
|
|
178
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)",
|
|
179
184
|
]
|
|
180
185
|
asyncio_mode = "auto"
|
|
181
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:
|
|
@@ -84,7 +84,7 @@ class EngineConfig(BaseModel):
|
|
|
84
84
|
declaring them fails at open until an adapter ships.
|
|
85
85
|
"""
|
|
86
86
|
|
|
87
|
-
type: str = "
|
|
87
|
+
type: str = "duckdb"
|
|
88
88
|
# Path / URI for DuckDB-family engines. Also accepted on the project top level
|
|
89
89
|
# as ``database:`` (synthesised into the ``default`` engine).
|
|
90
90
|
database: str | None = None
|
|
@@ -117,13 +117,14 @@ class ProjectConfig(BaseModel):
|
|
|
117
117
|
default_engine: str = "default"
|
|
118
118
|
engines: dict[str, EngineConfig] = Field(default_factory=dict)
|
|
119
119
|
state_path: str = ".interlace/state.db" # SQLite control-plane database
|
|
120
|
-
# The warehouse. Default is
|
|
121
|
-
# Also accepted: a DuckLake catalog
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
120
|
+
# The warehouse. Default is a plain DuckDB file — simplest, single-process.
|
|
121
|
+
# Also accepted: a DuckLake catalog (``ducklake:.interlace/warehouse.ducklake``, or
|
|
122
|
+
# hosted in a SQL DB: ``ducklake:postgres:dbname=... host=...`` — pair with
|
|
123
|
+
# data_path/metadata_schema) which serialises catalog writes so `interlace serve`
|
|
124
|
+
# and a separate CLI can share the warehouse concurrently; ":memory:"; or
|
|
125
|
+
# "quack:<host>:<port>" to connect to a warehouse served by `interlace serve --quack`.
|
|
125
126
|
# When ``engines.default`` is not set, these top-level fields synthesise it.
|
|
126
|
-
database: str = "
|
|
127
|
+
database: str = ".interlace/warehouse.duckdb"
|
|
127
128
|
# The warehouse catalog's ATTACH alias (defaults to ``name``). Set it when a
|
|
128
129
|
# schema inside the warehouse shares the project name — see EngineConfig.alias.
|
|
129
130
|
alias: str | None = None
|
|
@@ -144,6 +145,9 @@ class ProjectConfig(BaseModel):
|
|
|
144
145
|
# This is T0 federation (all SQL still runs in DuckDB) — see docs/architecture/MULTI_ENGINE.md.
|
|
145
146
|
attach: dict[str, str] = Field(default_factory=dict)
|
|
146
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"])
|
|
147
151
|
# How many models build concurrently. Lower it to serialise a build end-to-end
|
|
148
152
|
# when diagnosing a crash or a warehouse that dislikes parallel writers; `run`
|
|
149
153
|
# and `apply` also take --parallelism, which wins over this.
|
|
@@ -114,6 +114,18 @@ class ModelDef:
|
|
|
114
114
|
schedule: dict[str, str] | None = None # {"cron": "0 * * * *"} or {"every": "5m"} for `interlace serve`
|
|
115
115
|
checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
|
|
116
116
|
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
# `@model(checks=…)` normalises through parse_checks, but a ModelDef built
|
|
119
|
+
# directly — the dynamic-model path, which is what generated models and dbt
|
|
120
|
+
# migrations use — stored the dicts raw and only failed at compile time with
|
|
121
|
+
# `AttributeError: 'dict' object has no attribute 'type'`. Normalise here too,
|
|
122
|
+
# so one spelling works on both surfaces and a bad check fails at declaration.
|
|
123
|
+
# Passed through as-is, not as list(...): parse_checks already handles a bare
|
|
124
|
+
# CheckSpec and reports a non-list clearly, both of which list() would mangle
|
|
125
|
+
# (TypeError on a CheckSpec; a dict silently degraded to its keys). Always run,
|
|
126
|
+
# so `checks=[]` normalises to the declared tuple rather than staying a list.
|
|
127
|
+
self.checks = parse_checks(self.checks, self.name)
|
|
128
|
+
|
|
117
129
|
@property
|
|
118
130
|
def is_terminal(self) -> bool:
|
|
119
131
|
"""A terminal model delivers into an external destination (table/file):
|
|
@@ -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:
|
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import asyncio
|
|
30
30
|
import contextlib
|
|
31
|
+
import re
|
|
31
32
|
import threading
|
|
32
33
|
from collections.abc import Iterator, Sequence
|
|
33
34
|
from uuid import uuid4
|
|
@@ -38,6 +39,7 @@ import tenacity
|
|
|
38
39
|
from sqlglot import exp
|
|
39
40
|
|
|
40
41
|
from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
|
|
42
|
+
from interlace.exceptions import ConfigurationError
|
|
41
43
|
from interlace.ir.relation import TableRef
|
|
42
44
|
|
|
43
45
|
_DUCKDB_CAPS = EngineCaps(
|
|
@@ -58,6 +60,39 @@ _commit_retry = tenacity.retry(
|
|
|
58
60
|
)
|
|
59
61
|
|
|
60
62
|
|
|
63
|
+
@contextlib.contextmanager
|
|
64
|
+
def _clean_lock_error(database: str) -> Iterator[None]:
|
|
65
|
+
"""Translate DuckDB's file-lock conflict into one actionable line.
|
|
66
|
+
|
|
67
|
+
A DuckDB/DuckLake database is held by a single process. The common way to hit
|
|
68
|
+
that is running a CLI command (`interlace query`, `plan`, `apply`) while
|
|
69
|
+
`interlace serve` is up — an obvious thing to do, since serve is the daemon
|
|
70
|
+
and query is the console's CLI counterpart. Raw, that surfaces as a dozen
|
|
71
|
+
frames ending in `duckdb.IOException`, which names neither the cause nor the
|
|
72
|
+
fix. The fix is `--quack`, and it is already documented; the error just never
|
|
73
|
+
said so.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
yield
|
|
77
|
+
except duckdb.IOException as exc:
|
|
78
|
+
message = str(exc)
|
|
79
|
+
# "Conflicting lock is held in <exe> (PID n)" is the Linux rendering — DuckDB
|
|
80
|
+
# names the holder from /proc/locks. Elsewhere the message is the bare "Could
|
|
81
|
+
# not set lock on file", and a same-process conflict says "already held", so
|
|
82
|
+
# match all three or macOS/Windows keep the raw traceback.
|
|
83
|
+
if not any(marker in message for marker in ("Conflicting lock", "Could not set lock", "already held")):
|
|
84
|
+
raise
|
|
85
|
+
holder = re.search(r"\(PID (\d+)\)", message)
|
|
86
|
+
held_by = f" (PID {holder.group(1)})" if holder else ""
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
f"the warehouse {database!r} is already open in another process{held_by}. "
|
|
89
|
+
"DuckDB allows one process at a time — stop `interlace serve`, or serve the "
|
|
90
|
+
"warehouse over the quack protocol (`interlace serve --quack quack:localhost:4213`) "
|
|
91
|
+
"and point this process at `database: quack:localhost:4213` to share it.",
|
|
92
|
+
details={"database": database},
|
|
93
|
+
) from None
|
|
94
|
+
|
|
95
|
+
|
|
61
96
|
def _affected(cur: duckdb.DuckDBPyConnection) -> int:
|
|
62
97
|
"""DML/CTAS/COPY return their affected-row count as a one-cell result; DDL returns
|
|
63
98
|
nothing. Never raises — row stats are best-effort decoration, not correctness."""
|
|
@@ -108,7 +143,9 @@ class DuckDBAdapter(EngineAdapter):
|
|
|
108
143
|
|
|
109
144
|
@classmethod
|
|
110
145
|
def connect(cls, path: str) -> DuckDBAdapter:
|
|
111
|
-
|
|
146
|
+
with _clean_lock_error(path):
|
|
147
|
+
conn = duckdb.connect(path)
|
|
148
|
+
return cls(conn, serialise_writes=path.startswith("ducklake:"))
|
|
112
149
|
|
|
113
150
|
@classmethod
|
|
114
151
|
def connect_ducklake(
|
|
@@ -139,7 +176,8 @@ class DuckDBAdapter(EngineAdapter):
|
|
|
139
176
|
options_sql = f" ({', '.join(options)})" if options else ""
|
|
140
177
|
escaped = catalog.replace("'", "''")
|
|
141
178
|
alias_sql = exp.to_identifier(alias).sql("duckdb")
|
|
142
|
-
|
|
179
|
+
with _clean_lock_error(catalog):
|
|
180
|
+
conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {alias_sql}{options_sql}")
|
|
143
181
|
conn.execute(f"USE {alias_sql}")
|
|
144
182
|
# LOAD, secrets, and ATTACH are all instance-wide — they carry into every
|
|
145
183
|
# cursor and must run ONCE (re-running CREATE OR REPLACE SECRET per cursor
|
|
@@ -162,10 +200,23 @@ class DuckDBAdapter(EngineAdapter):
|
|
|
162
200
|
with contextlib.suppress(Exception):
|
|
163
201
|
self._conn.interrupt()
|
|
164
202
|
|
|
203
|
+
def search_files_from(self, directory: str) -> None:
|
|
204
|
+
"""Resolve relative read paths (``read_csv_auto('seeds/x.csv')``) against
|
|
205
|
+
``directory`` — the project root — as well as the process CWD.
|
|
206
|
+
|
|
207
|
+
Additive: a CWD-relative path still resolves, so this only ever widens what a
|
|
208
|
+
model can find. GLOBAL scope because a plain ``SET`` is session-scoped and would
|
|
209
|
+
not reach the per-task cursors that actually run the queries. Reads only —
|
|
210
|
+
``COPY`` targets stay CWD-relative, which is why exports resolve their own paths
|
|
211
|
+
against the root (``plan.apply._resolve_export_path``)."""
|
|
212
|
+
escaped = directory.replace("'", "''")
|
|
213
|
+
self._conn.execute(f"SET GLOBAL file_search_path='{escaped}'")
|
|
214
|
+
|
|
165
215
|
def attach(self, alias: str, uri: str) -> None:
|
|
166
216
|
"""ATTACH another database (duckdb/sqlite/postgres/... URI) under ``alias``."""
|
|
167
217
|
escaped = uri.replace("'", "''")
|
|
168
|
-
|
|
218
|
+
with _clean_lock_error(uri): # attaching a held duckdb/ducklake file conflicts just like opening one
|
|
219
|
+
self._conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {exp.to_identifier(alias).sql('duckdb')}")
|
|
169
220
|
self._attached.append(alias)
|
|
170
221
|
if uri.startswith("ducklake:"): # writes may now reach a DuckLake catalog (e.g. table sinks)
|
|
171
222
|
if isinstance(self._write_lock, contextlib.nullcontext):
|
|
@@ -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:
|