Skip to content

Commit e1ca0bc

Browse files
authored
fix(ingestr): pin the DuckDB ADBC driver to the installed duckdb version (#1203)
DuckDB 2.0.0 was released and ingestr installs the newest DuckDB ADBC driver when none is installed, so files it wrote moved to a storage format the locked duckdb package cannot read. SQLBuild now installs the driver matching its duckdb package before running ingestr, unless ADBC_DRIVER_PATH is set, and the ingestr extra includes dbc.
1 parent 1111ca0 commit e1ca0bc

9 files changed

Lines changed: 228 additions & 16 deletions

File tree

‎crates/sqlbuild-core/src/lifecycle_events/constants.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ pub const OPERATION_KINDS: [&str; 10] = [
183183
"subprocess",
184184
"warehouse",
185185
];
186-
pub const OPERATION_NAMES: [&str; 55] = [
186+
pub const OPERATION_NAMES: [&str; 56] = [
187+
"adbc_driver_install",
187188
"audit_evaluation",
188189
"clone_execution",
189190
"clone_finalization",

‎crates/sqlbuild-core/src/progress/constants.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Lifecycle event types, operation labels and layout of CLI progress lines.
22
33
/// Operations with visible progress lines, and the label each renders with.
4-
pub(crate) const VISIBLE_OPERATION_LABELS: [(&str, &str); 31] = [
4+
pub(crate) const VISIBLE_OPERATION_LABELS: [(&str, &str); 32] = [
55
("clone_finalization", "Clone finalization"),
66
("discovery_declaration_parse", "Declaration parsing"),
77
("discovery_filesystem_walk", "Filesystem discovery"),
@@ -14,6 +14,7 @@ pub(crate) const VISIBLE_OPERATION_LABELS: [(&str, &str); 31] = [
1414
("dbt_command", "dbt command"),
1515
("external_source_load", "loader callable"),
1616
("ingestr_command", "ingestr subprocess"),
17+
("adbc_driver_install", "DuckDB driver install"),
1718
("managed_source_load", "loader callable"),
1819
("python_asset", "Python asset attempt"),
1920
("python_check", "Python check attempt"),
@@ -38,7 +39,8 @@ pub(crate) const VISIBLE_OPERATION_LABELS: [(&str, &str); 31] = [
3839
("relation_promotion", "Promote relation"),
3940
];
4041
/// Operations a live transient owner shows on a terminal when no resource attempt owns them.
41-
pub(crate) const TTY_TRANSIENT_OWNER_OPERATIONS: [&str; 18] = [
42+
pub(crate) const TTY_TRANSIENT_OWNER_OPERATIONS: [&str; 19] = [
43+
"adbc_driver_install",
4244
"dbt_command",
4345
"discovery_declaration_parse",
4446
"discovery_filesystem_walk",

‎pyproject.toml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ clickhouse = [
4949
"clickhouse-connect>=0.8",
5050
]
5151
ingestr = [
52-
"ingestr>=1.0.0",
52+
"dbc>=0.3.1",
53+
"ingestr>=1.1.73",
5354
]
5455
dlt = [
5556
"dlt[duckdb,filesystem,sql_database]>=1.28.0,<2",
@@ -72,7 +73,8 @@ dev = [
7273
"dbt-snowflake==1.12.0b1",
7374
"dbt-sqlserver>=1.9.0,<1.11",
7475
"dlt[duckdb,filesystem,sql_database]>=1.28.0,<2",
75-
"ingestr>=1.0.0",
76+
"dbc>=0.3.1",
77+
"ingestr>=1.1.73",
7678
"jsonschema>=4.23.0",
7779
"maturin>=1.9,<2",
7880
"pre-commit>=4.3.0",

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/integrations/ingestr.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ uv pip install 'sqlbuild[ingestr]'
3131

3232
This installs `ingestr` alongside SQLBuild. The `ingestr` CLI must be available on `PATH`.
3333

34+
ingestr reads and writes DuckDB through an ADBC driver. Before the first ingestr run, SQLBuild
35+
installs the DuckDB driver that matches its own `duckdb` package (with `dbc`, included in the extra)
36+
into `<environment>/etc/adbc/drivers`, so the files ingestr writes stay readable by SQLBuild. Set
37+
`ADBC_DRIVER_PATH` to manage the driver yourself.
38+
3439
## How it works
3540

3641
1. Declare an `ingestr` block on a source in `sources/*.yml`

‎src/sqlbuild/integrations/ingestr/_helpers/runner.py‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import shutil
66
import subprocess
77
import sys
8+
import tomllib
9+
from importlib import metadata
810
from os import environ
911
from pathlib import Path
1012

@@ -14,6 +16,7 @@
1416
from sqlbuild.runtime.observability.classes.operation_lifecycle import OperationLifecycle
1517

1618
_DUCKDB_DRIVER_BOOTSTRAP_ERROR: str = "ADBC driver still not available after installation"
19+
_DUCKDB_MANIFEST: str = "duckdb.toml"
1720

1821

1922
def run_ingestr_command(
@@ -26,6 +29,7 @@ def run_ingestr_command(
2629
"This source uses ingestr, but the ingestr CLI is not available. "
2730
"Install it with: pip install 'sqlbuild[ingestr]'"
2831
)
32+
ensure_duckdb_driver()
2933
command_display: str = format_ingestr_command(command)
3034
try:
3135
completed: subprocess.CompletedProcess[str] = _run_observed_ingestr_subprocess(
@@ -81,7 +85,74 @@ def _ingestr_subprocess_env() -> dict[str, str]:
8185
env: dict[str, str] = dict(environ)
8286
if env.get("ADBC_DRIVER_PATH"):
8387
return env
84-
driver_path: Path = Path(sys.prefix) / "etc" / "adbc" / "drivers"
88+
driver_path: Path = _managed_driver_path()
8589
if driver_path.exists():
8690
env["ADBC_DRIVER_PATH"] = str(driver_path)
8791
return env
92+
93+
94+
def _managed_driver_path() -> Path:
95+
return Path(sys.prefix) / "etc" / "adbc" / "drivers"
96+
97+
98+
def ensure_duckdb_driver() -> None:
99+
"""Install the DuckDB ADBC driver matching the duckdb package unless the user set one."""
100+
101+
if environ.get("ADBC_DRIVER_PATH"):
102+
return
103+
try:
104+
duckdb_version: str = metadata.version("duckdb")
105+
except metadata.PackageNotFoundError:
106+
return
107+
driver_path: Path = _managed_driver_path()
108+
if _installed_driver_version(driver_path / _DUCKDB_MANIFEST) == duckdb_version:
109+
return
110+
dbc: str | None = _dbc_executable()
111+
if dbc is None:
112+
raise IngestrIntegrationError(
113+
"ingestr needs the dbc driver installer to pin its DuckDB driver to duckdb "
114+
f"{duckdb_version}. Install it with: pip install 'sqlbuild[ingestr]'"
115+
)
116+
driver_path.mkdir(parents=True, exist_ok=True)
117+
with OperationLifecycle(
118+
operation_kind="subprocess",
119+
operation_name="adbc_driver_install",
120+
auto_fail_base_exceptions=False,
121+
) as lifecycle:
122+
completed: subprocess.CompletedProcess[str] = subprocess.run(
123+
(dbc, "install", "--quiet", f"duckdb={duckdb_version}"),
124+
capture_output=True,
125+
check=False,
126+
env={**environ, "ADBC_DRIVER_PATH": str(driver_path)},
127+
text=True,
128+
)
129+
if completed.returncode == 0:
130+
lifecycle.completed(exit_code=0)
131+
else:
132+
lifecycle.failed(
133+
error_code=f"exit_{completed.returncode}",
134+
exit_code=completed.returncode,
135+
signal_number=-completed.returncode if completed.returncode < 0 else None,
136+
)
137+
if completed.returncode != 0:
138+
raise IngestrIntegrationError(
139+
f"could not install the DuckDB ADBC driver for duckdb {duckdb_version}: "
140+
f"{(completed.stderr or completed.stdout).strip()}"
141+
)
142+
143+
144+
def _installed_driver_version(manifest: Path) -> str | None:
145+
try:
146+
with manifest.open("rb") as handle:
147+
version: object = tomllib.load(handle).get("version")
148+
except (OSError, tomllib.TOMLDecodeError):
149+
return None
150+
return version if isinstance(version, str) else None
151+
152+
153+
def _dbc_executable() -> str | None:
154+
for name in ("dbc", "dbc.exe"):
155+
beside_python: Path = Path(sys.executable).parent / name
156+
if beside_python.exists():
157+
return str(beside_python)
158+
return shutil.which("dbc")

‎tests/unit/src/sqlbuild/integrations/ingestr/_test_types.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,18 @@ class IngestrRunnerTestCase:
3838
returncode: int
3939
expected_signal_number: int
4040
expected_event_types: tuple[str, ...] = ()
41+
42+
43+
@dataclass(frozen=True)
44+
class IngestrDriverTestCase:
45+
description: str
46+
driver_files: tuple[tuple[str, str], ...]
47+
environment: dict[str, str]
48+
expected_installs: tuple[tuple[str, ...], ...]
49+
50+
51+
@dataclass(frozen=True)
52+
class IngestrDriverFailureTestCase:
53+
description: str
54+
stderr: str
55+
expected_message: str

‎tests/unit/src/sqlbuild/integrations/ingestr/test_runner.py‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,26 @@
33
from __future__ import annotations
44

55
import subprocess
6+
from pathlib import Path
67

78
import pytest
89

9-
from sqlbuild.integrations.ingestr._helpers.runner import run_ingestr_command
10+
from sqlbuild.integrations.ingestr._helpers.runner import ensure_duckdb_driver, run_ingestr_command
1011
from sqlbuild.integrations.ingestr.exceptions import IngestrIntegrationError
1112
from sqlbuild.observability import (
1213
EventDispatcher,
1314
LifecycleEvent,
1415
dispatcher_scope,
1516
invocation_scope,
1617
)
17-
from tests.unit.src.sqlbuild.integrations.ingestr._test_types import IngestrRunnerTestCase
18+
from tests.unit.src.sqlbuild.integrations.ingestr._test_types import (
19+
IngestrDriverFailureTestCase,
20+
IngestrDriverTestCase,
21+
IngestrRunnerTestCase,
22+
)
23+
24+
_RUNNER: str = "sqlbuild.integrations.ingestr._helpers.runner"
25+
_INSTALL: tuple[str, ...] = ("dbc", "install", "--quiet", "duckdb=1.5.2")
1826

1927

2028
@pytest.mark.parametrize(
@@ -39,6 +47,9 @@ def test_given_signalled_ingestr_when_run_then_safe_terminal_excludes_child_stre
3947
"sqlbuild.integrations.ingestr._helpers.runner.shutil.which",
4048
lambda executable: executable,
4149
)
50+
monkeypatch.setattr(
51+
"sqlbuild.integrations.ingestr._helpers.runner.ensure_duckdb_driver", lambda: None
52+
)
4253
monkeypatch.setattr(
4354
"sqlbuild.integrations.ingestr._helpers.runner._run_ingestr_subprocess",
4455
lambda **kwargs: subprocess.CompletedProcess(
@@ -92,6 +103,9 @@ def test_given_unobserved_ingestr_interruption_when_run_then_only_start_is_publi
92103
"sqlbuild.integrations.ingestr._helpers.runner.shutil.which",
93104
lambda executable: executable,
94105
)
106+
monkeypatch.setattr(
107+
"sqlbuild.integrations.ingestr._helpers.runner.ensure_duckdb_driver", lambda: None
108+
)
95109
monkeypatch.setattr(
96110
"sqlbuild.integrations.ingestr._helpers.runner._run_ingestr_subprocess",
97111
lambda **kwargs: (_ for _ in ()).throw(KeyboardInterrupt),
@@ -105,3 +119,88 @@ def test_given_unobserved_ingestr_interruption_when_run_then_only_start_is_publi
105119
_ = run_ingestr_command(("ingestr", "run"))
106120

107121
assert tuple(event.event_type for event in events) == test_case.expected_event_types
122+
123+
124+
@pytest.mark.parametrize(
125+
"test_case",
126+
(
127+
IngestrDriverTestCase(
128+
description="missing driver installs the duckdb package version",
129+
driver_files=(),
130+
environment={},
131+
expected_installs=(_INSTALL,),
132+
),
133+
IngestrDriverTestCase(
134+
description="driver of another version is replaced",
135+
driver_files=(("duckdb.toml", "version = '2.0.0'\n"),),
136+
environment={},
137+
expected_installs=(_INSTALL,),
138+
),
139+
IngestrDriverTestCase(
140+
description="matching driver is kept",
141+
driver_files=(("duckdb.toml", "version = '1.5.2'\n"),),
142+
environment={},
143+
expected_installs=(),
144+
),
145+
IngestrDriverTestCase(
146+
description="user driver path is left alone",
147+
driver_files=(),
148+
environment={"ADBC_DRIVER_PATH": "/custom/adbc"},
149+
expected_installs=(),
150+
),
151+
),
152+
ids=lambda case: case.description,
153+
)
154+
def test_given_duckdb_driver_state_when_ensuring_then_installs_only_a_mismatch(
155+
test_case: IngestrDriverTestCase,
156+
monkeypatch: pytest.MonkeyPatch,
157+
tmp_path: Path,
158+
) -> None:
159+
driver_path: Path = tmp_path / "etc" / "adbc" / "drivers"
160+
driver_path.mkdir(parents=True)
161+
_ = [(driver_path / name).write_text(text) for name, text in test_case.driver_files]
162+
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
163+
monkeypatch.setattr(f"{_RUNNER}.sys.prefix", str(tmp_path))
164+
monkeypatch.setattr(f"{_RUNNER}.environ", dict(test_case.environment))
165+
monkeypatch.setattr(f"{_RUNNER}.metadata.version", lambda name: "1.5.2")
166+
monkeypatch.setattr(f"{_RUNNER}._dbc_executable", lambda: "dbc")
167+
monkeypatch.setattr(
168+
f"{_RUNNER}.subprocess.run",
169+
lambda args, **kwargs: (
170+
calls.append((args, kwargs["env"])) or subprocess.CompletedProcess(args, 0, "", "")
171+
),
172+
)
173+
174+
ensure_duckdb_driver()
175+
176+
assert tuple(args for args, _ in calls) == test_case.expected_installs
177+
assert all(env["ADBC_DRIVER_PATH"] == str(driver_path) for _, env in calls)
178+
179+
180+
@pytest.mark.parametrize(
181+
"test_case",
182+
(
183+
IngestrDriverFailureTestCase(
184+
description="failed install names the duckdb version",
185+
stderr="registry unreachable",
186+
expected_message="duckdb 1.5.2: registry unreachable",
187+
),
188+
),
189+
ids=lambda case: case.description,
190+
)
191+
def test_given_failing_driver_install_when_ensuring_then_names_the_duckdb_version(
192+
test_case: IngestrDriverFailureTestCase,
193+
monkeypatch: pytest.MonkeyPatch,
194+
tmp_path: Path,
195+
) -> None:
196+
monkeypatch.setattr(f"{_RUNNER}.sys.prefix", str(tmp_path))
197+
monkeypatch.setattr(f"{_RUNNER}.environ", {})
198+
monkeypatch.setattr(f"{_RUNNER}.metadata.version", lambda name: "1.5.2")
199+
monkeypatch.setattr(f"{_RUNNER}._dbc_executable", lambda: "dbc")
200+
monkeypatch.setattr(
201+
f"{_RUNNER}.subprocess.run",
202+
lambda args, **kwargs: subprocess.CompletedProcess(args, 1, "", test_case.stderr),
203+
)
204+
205+
with pytest.raises(IngestrIntegrationError, match=test_case.expected_message):
206+
ensure_duckdb_driver()

0 commit comments

Comments
 (0)