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,249 @@
1
+ """DuckDB engine adapter — the default local engine and federation hub.
2
+
3
+ Everything crosses the boundary as Arrow: :meth:`fetch` streams results as a
4
+ ``pyarrow.RecordBatchReader`` (zero-copy, single pass) and :meth:`load` registers
5
+ an Arrow reader and writes it with one ``CREATE TABLE AS`` / ``INSERT``. Blocking
6
+ DuckDB calls run in a worker thread; each call uses its own ``cursor()`` so reads
7
+ proceed concurrently (DuckDB MVCC), while the DAG guarantees no two tasks write
8
+ the same table at once.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ from collections.abc import Sequence
16
+ from uuid import uuid4
17
+
18
+ import duckdb
19
+ import pyarrow as pa
20
+ import tenacity
21
+ from sqlglot import exp
22
+
23
+ from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
24
+ from interlace.ir.relation import TableRef
25
+
26
+ _DUCKDB_CAPS = EngineCaps(
27
+ supports_create_or_replace=True,
28
+ supports_star_exclude=True,
29
+ )
30
+
31
+
32
+ # DuckLake uses optimistic concurrency: a concurrent writer's commit surfaces as
33
+ # a TransactionException. Our write batches are whole-transaction idempotent
34
+ # (CREATE OR REPLACE / DELETE+INSERT run as one unit), so a short retry is safe.
35
+ _commit_retry = tenacity.retry(
36
+ retry=tenacity.retry_if_exception_type(duckdb.TransactionException),
37
+ stop=tenacity.stop_after_attempt(3),
38
+ wait=tenacity.wait_exponential_jitter(initial=0.1, max=1.0),
39
+ reraise=True,
40
+ )
41
+
42
+
43
+ def _affected(cur: duckdb.DuckDBPyConnection) -> int:
44
+ """DML/CTAS/COPY return their affected-row count as a one-cell result; DDL returns
45
+ nothing. Never raises — row stats are best-effort decoration, not correctness."""
46
+ try:
47
+ rows = cur.fetchall()
48
+ return int(rows[0][0]) if rows and rows[0] and isinstance(rows[0][0], int) else 0
49
+ except Exception:
50
+ return 0
51
+
52
+
53
+ class DuckDBAdapter(EngineAdapter):
54
+ """Executes canonical ASTs and moves Arrow data in and out of a DuckDB database."""
55
+
56
+ dialect = "duckdb"
57
+ caps = _DUCKDB_CAPS
58
+
59
+ def __init__(self, connection: duckdb.DuckDBPyConnection, session_init: Sequence[str] = ()) -> None:
60
+ self._conn = connection
61
+ # Statements re-applied on every cursor — SESSION-LOCAL state only (USE).
62
+ # Anything instance-wide (LOAD, secrets, ATTACH) belongs at connect time:
63
+ # re-running catalog writes here races across concurrent cursors.
64
+ self._session_init = list(session_init)
65
+ self._attached: list[str] = [] # aliases to DETACH on close (see close())
66
+
67
+ def _cursor(self) -> duckdb.DuckDBPyConnection:
68
+ cur = self._conn.cursor()
69
+ for statement in self._session_init:
70
+ cur.execute(statement)
71
+ return cur
72
+
73
+ @classmethod
74
+ def in_memory(cls) -> DuckDBAdapter:
75
+ return cls(duckdb.connect(":memory:"))
76
+
77
+ @classmethod
78
+ def connect(cls, path: str) -> DuckDBAdapter:
79
+ return cls(duckdb.connect(path))
80
+
81
+ @classmethod
82
+ def connect_ducklake(
83
+ cls,
84
+ catalog: str,
85
+ *,
86
+ alias: str = "warehouse",
87
+ data_path: str | None = None,
88
+ metadata_schema: str | None = None,
89
+ secrets: Sequence[str] = (),
90
+ extensions: Sequence[str] = (),
91
+ ) -> DuckDBAdapter:
92
+ """Open a DuckLake warehouse that needs attach options and/or credentials —
93
+ remote catalogs (``postgres:…``) and object-store ``data_path``s can't ride the
94
+ plain ``duckdb.connect("ducklake:…")`` shortcut. Opens ``:memory:``, installs
95
+ the extensions, issues the ``CREATE SECRET`` statements, ATTACHes the DuckLake
96
+ with the options, and makes it the default catalog."""
97
+ conn = duckdb.connect(":memory:")
98
+ for extension in extensions:
99
+ conn.execute(f"INSTALL {extension}; LOAD {extension};")
100
+ for statement in secrets:
101
+ conn.execute(statement)
102
+ options: list[str] = []
103
+ if data_path:
104
+ options.append(f"DATA_PATH '{data_path.replace(chr(39), chr(39) * 2)}'")
105
+ if metadata_schema:
106
+ options.append(f"METADATA_SCHEMA '{metadata_schema.replace(chr(39), chr(39) * 2)}'")
107
+ options_sql = f" ({', '.join(options)})" if options else ""
108
+ escaped = catalog.replace("'", "''")
109
+ alias_sql = exp.to_identifier(alias).sql("duckdb")
110
+ conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {alias_sql}{options_sql}")
111
+ conn.execute(f"USE {alias_sql}")
112
+ # LOAD, secrets, and ATTACH are all instance-wide — they carry into every
113
+ # cursor and must run ONCE (re-running CREATE OR REPLACE SECRET per cursor
114
+ # races: concurrent cursors hit "catalog write-write conflict on alter").
115
+ # Only the default catalog is session state, so that is all a cursor re-applies.
116
+ return cls(conn, session_init=[f"USE {alias_sql}"])
117
+
118
+ def close(self) -> None:
119
+ # DETACH long-lived attaches first: DuckLake leaks its DatabaseInstance when
120
+ # concurrent cursors were used (duckdb 1.5.4), which would otherwise keep the
121
+ # attached databases' file handles locked for the rest of the process.
122
+ for alias in self._attached:
123
+ with contextlib.suppress(Exception):
124
+ self._conn.execute(f"DETACH {exp.to_identifier(alias).sql('duckdb')}")
125
+ self._attached.clear()
126
+ self._conn.close()
127
+
128
+ def attach(self, alias: str, uri: str) -> None:
129
+ """ATTACH another database (duckdb/sqlite/postgres/... URI) under ``alias``."""
130
+ escaped = uri.replace("'", "''")
131
+ self._conn.execute(f"ATTACH IF NOT EXISTS '{escaped}' AS {exp.to_identifier(alias).sql('duckdb')}")
132
+ self._attached.append(alias)
133
+
134
+ # --- identifier helpers -------------------------------------------------
135
+
136
+ def _table_sql(self, table: TableRef) -> str:
137
+ return table.to_expr().sql(dialect=self.dialect)
138
+
139
+ # --- EngineAdapter ------------------------------------------------------
140
+
141
+ async def execute(self, ast: exp.Expression) -> None:
142
+ await self.execute_sql(self.transpile(ast))
143
+
144
+ async def execute_all(self, statements: Sequence[exp.Expression]) -> list[int]:
145
+ return await asyncio.to_thread(self._execute_all_sync, [self.transpile(s) for s in statements])
146
+
147
+ async def fetch(self, ast: exp.Expression) -> pa.RecordBatchReader:
148
+ return await self.fetch_sql(self.transpile(ast))
149
+
150
+ async def load(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
151
+ return await asyncio.to_thread(self._load_sync, table, reader, mode)
152
+
153
+ async def create_view(self, name: TableRef, target: TableRef) -> None:
154
+ await self.execute_sql(
155
+ f"CREATE OR REPLACE VIEW {self._table_sql(name)} AS SELECT * FROM {self._table_sql(target)}"
156
+ )
157
+
158
+ # --- raw / convenience (used by the state store and tests) --------------
159
+
160
+ async def execute_sql(self, sql: str) -> None:
161
+ await asyncio.to_thread(self._execute_sync, sql)
162
+
163
+ async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
164
+ return await asyncio.to_thread(self._fetch_sync, sql)
165
+
166
+ async def create_schema(self, name: str) -> None:
167
+ await self.execute_sql(f"CREATE SCHEMA IF NOT EXISTS {exp.to_identifier(name).sql(dialect=self.dialect)}")
168
+
169
+ async def table_exists(self, table: TableRef) -> bool:
170
+ return await asyncio.to_thread(self._table_exists_sync, table)
171
+
172
+ async def describe(self, table: TableRef) -> dict[str, str]:
173
+ return await asyncio.to_thread(self._describe_sync, table)
174
+
175
+ # --- sync workers (run in a thread) -------------------------------------
176
+
177
+ @_commit_retry
178
+ def _execute_sync(self, sql: str) -> None:
179
+ cur = self._cursor()
180
+ try:
181
+ cur.execute(sql)
182
+ finally:
183
+ cur.close()
184
+
185
+ @_commit_retry
186
+ def _execute_all_sync(self, sqls: list[str]) -> list[int]:
187
+ cur = self._cursor()
188
+ counts: list[int] = []
189
+ try:
190
+ cur.execute("BEGIN")
191
+ for sql in sqls:
192
+ cur.execute(sql)
193
+ counts.append(_affected(cur))
194
+ cur.execute("COMMIT")
195
+ except Exception:
196
+ cur.execute("ROLLBACK")
197
+ raise
198
+ finally:
199
+ cur.close()
200
+ return counts
201
+
202
+ def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
203
+ # The reader keeps the underlying result alive after the cursor is dropped.
204
+ cur = self._cursor()
205
+ cur.execute(sql)
206
+ return cur.to_arrow_reader()
207
+
208
+ @_commit_retry
209
+ def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
210
+ cur = self._cursor()
211
+ src = f"__interlace_src_{uuid4().hex}"
212
+ cur.register(src, reader)
213
+ try:
214
+ target = self._table_sql(table)
215
+ if mode == "create":
216
+ cur.execute(f"CREATE OR REPLACE TABLE {target} AS SELECT * FROM {src}")
217
+ else:
218
+ cur.execute(f"INSERT INTO {target} SELECT * FROM {src}")
219
+ return _affected(cur)
220
+ finally:
221
+ cur.unregister(src)
222
+ cur.close()
223
+
224
+ def _table_exists_sync(self, table: TableRef) -> bool:
225
+ # information_schema spans every attached catalog: pin to the ref's catalog
226
+ # (or the session default) so same-named tables elsewhere don't collide.
227
+ cur = self._cursor()
228
+ try:
229
+ row = cur.execute(
230
+ "SELECT count(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ? "
231
+ "AND table_catalog = coalesce(?, current_database())",
232
+ [table.schema, table.name, table.catalog],
233
+ ).fetchone()
234
+ finally:
235
+ cur.close()
236
+ return bool(row and row[0])
237
+
238
+ def _describe_sync(self, table: TableRef) -> dict[str, str]:
239
+ cur = self._cursor()
240
+ try:
241
+ rows = cur.execute(
242
+ "SELECT column_name, data_type FROM information_schema.columns "
243
+ "WHERE table_schema = ? AND table_name = ? "
244
+ "AND table_catalog = coalesce(?, current_database()) ORDER BY ordinal_position",
245
+ [table.schema, table.name, table.catalog],
246
+ ).fetchall()
247
+ finally:
248
+ cur.close()
249
+ return dict(rows)
@@ -0,0 +1,157 @@
1
+ """Postgres engine adapter — the first native remote engine (ADBC transport).
2
+
3
+ Strategies execute *inside* Postgres: canonical ASTs transpile to the postgres
4
+ dialect and run over one ADBC connection; results come back as Arrow and bulk
5
+ loads go in via ``adbc_ingest`` — columnar end to end, no row-format hop.
6
+
7
+ Capability honesty drives the strategy fallbacks: Postgres has no
8
+ ``CREATE OR REPLACE TABLE`` (FullRefresh falls back to DROP+CREATE) and no
9
+ star-EXCLUDE projection (scd_type_2 refuses with a clear error). ``merge_by_key``
10
+ and ``full_merge`` are portable by construction (DELETE+INSERT / set difference).
11
+
12
+ The ADBC connection is synchronous: calls run in a worker thread behind a lock
13
+ (one statement at a time per engine — remote engines parallelise internally).
14
+ Requires the ``adbc`` extra (``pip install 'interlaced[adbc]'``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import threading
21
+ from collections.abc import Sequence
22
+ from typing import Any
23
+
24
+ import pyarrow as pa
25
+ from sqlglot import exp
26
+
27
+ from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode
28
+ from interlace.exceptions import ConfigurationError
29
+ from interlace.ir.relation import TableRef
30
+
31
+ _POSTGRES_CAPS = EngineCaps(
32
+ supports_create_or_replace=False, # no CREATE OR REPLACE TABLE -> DROP+CREATE fallback
33
+ supports_star_exclude=False, # no SELECT * EXCLUDE -> scd_type_2 unsupported for now
34
+ )
35
+
36
+
37
+ class PostgresAdapter(EngineAdapter):
38
+ """Executes canonical ASTs inside Postgres; Arrow in and out via ADBC."""
39
+
40
+ dialect = "postgres"
41
+ caps = _POSTGRES_CAPS
42
+
43
+ def __init__(self, connection: Any) -> None: # adbc_driver_postgresql.dbapi.Connection
44
+ self._conn = connection
45
+ self._lock = threading.Lock()
46
+
47
+ @classmethod
48
+ def connect(cls, dsn: str) -> PostgresAdapter:
49
+ try:
50
+ import adbc_driver_postgresql.dbapi as dbapi # type: ignore[import-untyped]
51
+ except ImportError as exc: # pragma: no cover - import guard
52
+ raise ConfigurationError(
53
+ "the postgres engine needs the 'adbc' extra: pip install 'interlaced[adbc]'"
54
+ ) from exc
55
+ return cls(dbapi.connect(dsn))
56
+
57
+ def close(self) -> None:
58
+ self._conn.close()
59
+
60
+ # --- EngineAdapter ------------------------------------------------------
61
+
62
+ async def execute(self, ast: exp.Expression) -> None:
63
+ await self.execute_sql(self.transpile(ast))
64
+
65
+ async def execute_all(self, statements: Sequence[exp.Expression]) -> list[int]:
66
+ return await asyncio.to_thread(self._execute_all_sync, [self.transpile(s) for s in statements])
67
+
68
+ async def fetch(self, ast: exp.Expression) -> pa.RecordBatchReader:
69
+ return await self.fetch_sql(self.transpile(ast))
70
+
71
+ async def load(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
72
+ return await asyncio.to_thread(self._load_sync, table, reader, mode)
73
+
74
+ async def create_view(self, name: TableRef, target: TableRef) -> None:
75
+ await self.execute_sql(
76
+ f"CREATE OR REPLACE VIEW {self._table_sql(name)} AS SELECT * FROM {self._table_sql(target)}"
77
+ )
78
+
79
+ async def execute_sql(self, sql: str) -> None:
80
+ await asyncio.to_thread(self._execute_sync, sql)
81
+
82
+ async def fetch_sql(self, sql: str) -> pa.RecordBatchReader:
83
+ return await asyncio.to_thread(self._fetch_sync, sql)
84
+
85
+ async def create_schema(self, name: str) -> None:
86
+ await self.execute_sql(f"CREATE SCHEMA IF NOT EXISTS {exp.to_identifier(name).sql(dialect=self.dialect)}")
87
+
88
+ async def table_exists(self, table: TableRef) -> bool:
89
+ return await asyncio.to_thread(self._table_exists_sync, table)
90
+
91
+ async def describe(self, table: TableRef) -> dict[str, str]:
92
+ return await asyncio.to_thread(self._describe_sync, table)
93
+
94
+ # --- sync workers (one connection; ADBC is synchronous) ------------------
95
+
96
+ def _table_sql(self, table: TableRef) -> str:
97
+ return table.to_expr().sql(dialect=self.dialect)
98
+
99
+ def _execute_sync(self, sql: str) -> None:
100
+ with self._lock, self._conn.cursor() as cur:
101
+ cur.execute(sql)
102
+ self._conn.commit()
103
+
104
+ def _execute_all_sync(self, sqls: list[str]) -> list[int]:
105
+ counts: list[int] = []
106
+ with self._lock:
107
+ try:
108
+ with self._conn.cursor() as cur:
109
+ for sql in sqls:
110
+ cur.execute(sql)
111
+ rowcount = getattr(cur, "rowcount", -1)
112
+ counts.append(rowcount if isinstance(rowcount, int) and rowcount > 0 else 0)
113
+ self._conn.commit() # ADBC autocommit is off: this is one transaction
114
+ except Exception:
115
+ self._conn.rollback()
116
+ raise
117
+ return counts
118
+
119
+ def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
120
+ with self._lock, self._conn.cursor() as cur:
121
+ cur.execute(sql)
122
+ table = cur.fetch_arrow_table() # materialised: keeps the reader cursor-independent
123
+ self._conn.commit()
124
+ return table.to_reader()
125
+
126
+ def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
127
+ ingest_mode = "replace" if mode == "create" else "append" # "create" == CREATE OR REPLACE semantics
128
+ with self._lock:
129
+ try:
130
+ with self._conn.cursor() as cur:
131
+ loaded = cur.adbc_ingest(table.name, reader, mode=ingest_mode, db_schema_name=table.schema)
132
+ self._conn.commit()
133
+ except Exception:
134
+ self._conn.rollback()
135
+ raise
136
+ return int(loaded) if isinstance(loaded, int) and loaded > 0 else 0
137
+
138
+ def _table_exists_sync(self, table: TableRef) -> bool:
139
+ with self._lock, self._conn.cursor() as cur:
140
+ cur.execute(
141
+ "SELECT count(*) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2",
142
+ (table.schema, table.name),
143
+ )
144
+ row = cur.fetchone()
145
+ self._conn.commit()
146
+ return bool(row and row[0])
147
+
148
+ def _describe_sync(self, table: TableRef) -> dict[str, str]:
149
+ with self._lock, self._conn.cursor() as cur:
150
+ cur.execute(
151
+ "SELECT column_name, data_type FROM information_schema.columns "
152
+ "WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position",
153
+ (table.schema, table.name),
154
+ )
155
+ rows = cur.fetchall()
156
+ self._conn.commit()
157
+ return dict(rows)
@@ -0,0 +1,113 @@
1
+ """Quack engine adapter — a remote DuckDB warehouse over the quack protocol.
2
+
3
+ Connects to a warehouse served by ``interlace serve --quack`` (or any
4
+ ``CALL quack_serve(...)``). Every statement is shipped to the server via the
5
+ ``quack_query`` table function — full SQL pass-through with results streamed
6
+ back as Arrow — because quack's catalog ATTACH only resolves the server's main
7
+ schema while the protocol is in beta. Arrow loads go through the attached
8
+ remote catalog (DDL resolves schema-qualified names correctly over the wire).
9
+ Multi-statement plans are sent as one BEGIN/COMMIT payload so they stay atomic
10
+ server-side. Requires DuckDB >= 1.5.3 (quack is a core extension there).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from uuid import uuid4
16
+
17
+ import duckdb
18
+ import pyarrow as pa
19
+ from sqlglot import exp
20
+
21
+ from interlace.engines.base import LoadMode
22
+ from interlace.engines.duckdb import DuckDBAdapter
23
+ from interlace.ir.relation import TableRef
24
+
25
+ _REMOTE = "__interlace_remote"
26
+
27
+
28
+ def sql_literal(value: str) -> str:
29
+ return exp.Literal.string(value).sql(dialect="duckdb")
30
+
31
+
32
+ class QuackAdapter(DuckDBAdapter):
33
+ """Executes canonical ASTs against a quack-served warehouse."""
34
+
35
+ def __init__(self, connection: duckdb.DuckDBPyConnection, uri: str) -> None:
36
+ super().__init__(connection)
37
+ self._uri = uri
38
+
39
+ @classmethod
40
+ def connect(cls, path: str, token: str | None = None) -> QuackAdapter:
41
+ """Connect to a ``quack:<host>:<port>`` warehouse; ``token`` is the serve token."""
42
+ conn = duckdb.connect(":memory:")
43
+ if token:
44
+ conn.execute(f"CREATE SECRET {_REMOTE}_secret (TYPE quack, TOKEN {sql_literal(token)})")
45
+ conn.execute(f"ATTACH {sql_literal(path)} AS {_REMOTE}") # used for Arrow loads; also validates reachability
46
+ return cls(conn, path)
47
+
48
+ # --- sync workers: route SQL through quack_query -------------------------
49
+
50
+ def _remote_sync(self, cur: duckdb.DuckDBPyConnection, sql: str) -> duckdb.DuckDBPyConnection:
51
+ return cur.execute("FROM quack_query(?, ?)", [self._uri, sql])
52
+
53
+ def _execute_sync(self, sql: str) -> None:
54
+ cur = self._conn.cursor()
55
+ try:
56
+ self._remote_sync(cur, sql)
57
+ finally:
58
+ cur.close()
59
+
60
+ def _execute_all_sync(self, sqls: list[str]) -> list[int]:
61
+ # One payload, transactional server-side; quack executes it as a unit —
62
+ # per-statement affected counts are not observable over the wire.
63
+ self._execute_sync(";\n".join(["BEGIN", *sqls, "COMMIT"]))
64
+ return [0] * len(sqls)
65
+
66
+ def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
67
+ cur = self._conn.cursor()
68
+ return self._remote_sync(cur, sql).to_arrow_reader()
69
+
70
+ def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
71
+ # Register the reader locally; schema-qualified DDL against the attached
72
+ # remote catalog streams the batches over the wire. The attach caches the
73
+ # remote catalog, so refresh it to see schemas created since we connected.
74
+ cur = self._conn.cursor()
75
+ cur.execute("CALL quack_clear_cache()")
76
+ src = f"__interlace_src_{uuid4().hex}"
77
+ cur.register(src, reader)
78
+ try:
79
+ target = exp.table_(table.name, db=table.schema, catalog=_REMOTE).sql(dialect=self.dialect)
80
+ if mode == "create":
81
+ cur.execute(f"CREATE OR REPLACE TABLE {target} AS SELECT * FROM {src}")
82
+ else:
83
+ cur.execute(f"INSERT INTO {target} SELECT * FROM {src}")
84
+ rows = cur.fetchall()
85
+ return int(rows[0][0]) if rows and rows[0] and isinstance(rows[0][0], int) else 0
86
+ finally:
87
+ cur.unregister(src)
88
+ cur.close()
89
+
90
+ def _table_exists_sync(self, table: TableRef) -> bool:
91
+ sql = (
92
+ "SELECT count(*) FROM information_schema.tables "
93
+ f"WHERE table_schema = {sql_literal(table.schema)} AND table_name = {sql_literal(table.name)}"
94
+ )
95
+ cur = self._conn.cursor()
96
+ try:
97
+ row = self._remote_sync(cur, sql).fetchone()
98
+ finally:
99
+ cur.close()
100
+ return bool(row and row[0])
101
+
102
+ def _describe_sync(self, table: TableRef) -> dict[str, str]:
103
+ sql = (
104
+ "SELECT column_name, data_type FROM information_schema.columns "
105
+ f"WHERE table_schema = {sql_literal(table.schema)} AND table_name = {sql_literal(table.name)} "
106
+ "ORDER BY ordinal_position"
107
+ )
108
+ cur = self._conn.cursor()
109
+ try:
110
+ rows = self._remote_sync(cur, sql).fetchall()
111
+ finally:
112
+ cur.close()
113
+ return dict(rows)
@@ -0,0 +1,125 @@
1
+ """Named engine registry — multi-engine routing without a global singleton.
2
+
3
+ A project may declare several engines in ``interlace.yaml``. The registry opens
4
+ them lazily (so unused remote engines stay cold), resolves models by name, and
5
+ closes everything on teardown. Single-engine projects use one entry named
6
+ ``default`` synthesised from the top-level warehouse fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterable, Iterator, Mapping
12
+ from typing import TypeVar
13
+
14
+ from interlace.engines.base import EngineAdapter
15
+ from interlace.exceptions import ConfigurationError, PlanError
16
+
17
+ _T = TypeVar("_T", bound=EngineAdapter)
18
+
19
+ Opener = Callable[[str], EngineAdapter]
20
+
21
+
22
+ class EngineRegistry(Mapping[str, EngineAdapter]):
23
+ """Lazy map of engine name → open :class:`EngineAdapter`.
24
+
25
+ ``get(name)`` opens on first use. Unknown names raise
26
+ :class:`ConfigurationError`. ``default`` is the project's default engine name.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ names: Iterable[str],
32
+ opener: Opener,
33
+ *,
34
+ default: str = "default",
35
+ attach_uris: Mapping[str, str] | None = None,
36
+ ) -> None:
37
+ self._names = frozenset(names)
38
+ self._opener = opener
39
+ self.default = default
40
+ self._cache: dict[str, EngineAdapter] = {}
41
+ # engine name -> URI another DuckDB-family engine could ATTACH (transfer
42
+ # fast lane); absent/None = only reachable through its own adapter.
43
+ self.attach_uris: dict[str, str] = dict(attach_uris or {})
44
+ if default not in self._names:
45
+ raise ConfigurationError(
46
+ f"default_engine {default!r} is not a configured engine",
47
+ details={"engines": sorted(self._names)},
48
+ )
49
+
50
+ def __getitem__(self, name: str) -> EngineAdapter:
51
+ return self.get(name)
52
+
53
+ def __iter__(self) -> Iterator[str]:
54
+ return iter(sorted(self._names))
55
+
56
+ def __len__(self) -> int:
57
+ return len(self._names)
58
+
59
+ def __contains__(self, name: object) -> bool:
60
+ return isinstance(name, str) and name in self._names
61
+
62
+ def get(self, name: str | None = None) -> EngineAdapter: # type: ignore[override]
63
+ """Return the adapter for ``name`` (or the default engine), opening if needed."""
64
+ key = self.default if name is None else name
65
+ if key not in self._names:
66
+ raise ConfigurationError(
67
+ f"unknown engine {key!r}",
68
+ details={"engines": sorted(self._names)},
69
+ )
70
+ if key not in self._cache:
71
+ self._cache[key] = self._opener(key)
72
+ return self._cache[key]
73
+
74
+ def require(self, name: str, *, model: str | None = None) -> EngineAdapter:
75
+ """Like :meth:`get`, but raise :class:`PlanError` when the engine is missing."""
76
+ if name not in self._names:
77
+ where = f" for model {model!r}" if model else ""
78
+ raise PlanError(
79
+ f"engine {name!r}{where} is not configured",
80
+ details={"engines": sorted(self._names)},
81
+ )
82
+ return self.get(name)
83
+
84
+ def close(self) -> None:
85
+ """Close every opened adapter (idempotent for already-closed adapters)."""
86
+ for adapter in self._cache.values():
87
+ close = getattr(adapter, "close", None)
88
+ if callable(close):
89
+ close()
90
+ self._cache.clear()
91
+
92
+
93
+ def as_registry(
94
+ engine: EngineAdapter | None = None,
95
+ engines: Mapping[str, EngineAdapter] | EngineRegistry | None = None,
96
+ *,
97
+ default: str = "default",
98
+ ) -> EngineRegistry:
99
+ """Normalise the ``engine=`` / ``engines=`` apply kwargs into a registry.
100
+
101
+ Existing call sites that pass a single ``engine=`` keep working: that adapter
102
+ is registered under ``default``.
103
+ """
104
+ if isinstance(engines, EngineRegistry):
105
+ if engine is not None:
106
+ # Prefer the registry; a bare engine is only a fallback for default.
107
+ pass
108
+ return engines
109
+
110
+ cache: dict[str, EngineAdapter] = dict(engines or {})
111
+ if engine is not None:
112
+ cache.setdefault(default, engine)
113
+ if not cache:
114
+ raise PlanError("apply requires engine= or engines=")
115
+
116
+ def opener(name: str) -> EngineAdapter:
117
+ try:
118
+ return cache[name]
119
+ except KeyError as exc:
120
+ raise ConfigurationError(
121
+ f"unknown engine {name!r}",
122
+ details={"engines": sorted(cache)},
123
+ ) from exc
124
+
125
+ return EngineRegistry(cache.keys(), opener, default=default if default in cache else next(iter(cache)))