feat(wren): add standalone wren Python SDK package (#1471)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jax Liu
2026-03-23 17:40:06 +08:00
committed by GitHub
parent ddc0ded39b
commit 5bc98938b4
35 changed files with 5604 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md — wren package
Standalone Python SDK and CLI for Wren Engine. Wraps `wren-core-py` (PyO3 bindings) + Ibis connectors into a single installable package.
## Package Structure
```
wren/
src/wren/
engine.py — WrenEngine facade (transpile / query / dry_run / dry_plan)
cli.py — Typer CLI: wren query|dry-run|transpile|validate
mdl/ — wren-core-py session context + manifest extraction helpers
connector/ — Per-datasource Ibis connectors (factory.py + one file per source)
model/
data_source.py — DataSource enum + per-source ConnectionInfo factories
error.py — WrenError, ErrorCode, ErrorPhase
tests/
```
## Build & Development
```bash
cd wren
just install # build wren-core-py wheel + uv sync
just install-all # with all optional extras
just install-extra <extra> # e.g. just install-extra postgres
just test # pytest tests/
just lint # ruff format --check + ruff check
just format # ruff auto-fix
just build # uv build (produces wheel)
```
Uses `uv` (not Poetry). `pyproject.toml` uses `hatchling` as build backend.
## Key Design Points
- **WrenEngine** is the main entry point. It accepts a base64-encoded MDL JSON string, a `DataSource`, and a connection dict.
- **Query flow**: `_plan()` → wren-core `SessionContext.transform_sql()``_transpile()` via sqlglot → connector `.query()`.
- **Manifest extraction**: `_plan()` tries to extract a minimal sub-manifest scoped to the query's referenced tables before calling wren-core — this reduces planning overhead. Falls back to the full manifest on error.
- **`get_session_context` is `@cache`-decorated** — same `(manifest_str, function_path, properties, data_source)` tuple reuses the same SessionContext. Avoid mutating session state.
- **Write dialect mapping**: `canner``trino`; file sources (`local_file`, `s3_file`, `minio_file`, `gcs_file`) → `duckdb`. All others use `data_source.name` directly.
- **WrenEngine is a context manager** (`__enter__` / `__exit__` call `close()`).
## Connectors
`connector/factory.py` dispatches on `DataSource` to return the right connector. Each connector wraps an Ibis backend and exposes `.query(sql, limit)` and `.dry_run(sql)`. Base class in `connector/base.py`; Ibis-backed connectors share `connector/ibis.py`.
## Optional Extras
Install per data-source extras: `postgres`, `mysql`, `bigquery`, `snowflake`, `clickhouse`, `trino`, `mssql`, `databricks`, `redshift`, `spark`, `athena`, `oracle`, `all`, `dev`.
On macOS, `mysql` extra needs:
```bash
PKG_CONFIG_PATH="$(brew --prefix mysql-client)/lib/pkgconfig" just install-extra mysql
```
## Dependency on wren-core-py
`wren-core-py` wheel is built locally from `../wren-core-py/` and installed via `--find-links`. Run `just build-core` (or `just install`) to rebuild after Rust changes.
+42
View File
@@ -0,0 +1,42 @@
# wren
Wren Engine CLI and Python SDK — semantic SQL layer for 20+ data sources.
## Installation
```bash
pip install wren
```
## Usage
```python
from wren import WrenEngine, DataSource
```
See the [Wren Engine documentation](https://getwren.ai) for details.
## Running tests
Install dev dependencies first:
```bash
just install-dev
```
| Command | What it runs | Docker needed |
|---------|-------------|---------------|
| `just test-unit` | Unit tests (transpile, dry-plan, context manager) | No |
| `just test-duckdb` | DuckDB connector tests — generates TPCH data via `dbgen` | No |
| `just test-postgres` | PostgreSQL connector tests — spins up a container | Yes |
| `just test` | All tests | Yes |
Run a specific connector via marker:
```bash
just test-connector postgres
```
To add tests for a new connector, subclass `WrenQueryTestSuite` in
`tests/connectors/test_<name>.py` and provide a class-scoped `engine` fixture.
All base tests are inherited automatically.
+70
View File
@@ -0,0 +1,70 @@
default:
@just --list
build-core:
cd ../wren-core-py && just install && just build
core-wheel-dir := "../wren-core-py/target/wheels/"
install-core:
just build-core
install:
just build-core
uv sync --find-links {{ core-wheel-dir }}
install-dev:
just build-core
uv sync --extra dev --find-links {{ core-wheel-dir }}
install-all:
just build-core
uv sync --all-extras --find-links {{ core-wheel-dir }}
install-extra extra *flags:
#!/usr/bin/env bash
set -euo pipefail
if [ "{{ extra }}" = "mysql" ]; then
mysql_prefix=$(brew --prefix mysql-client 2>/dev/null || true)
if [ -n "$mysql_prefix" ]; then
export PKG_CONFIG_PATH="$mysql_prefix/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
fi
fi
dev_flag=""
for flag in {{ flags }}; do
if [ "$flag" = "--dev" ]; then
dev_flag="--extra dev"
fi
done
uv sync --extra {{ extra }} $dev_flag --find-links {{ core-wheel-dir }}
dev:
uv run wren
test:
uv run pytest tests/ -v
test-unit:
uv run pytest tests/unit/ -v -m unit
test-duckdb:
uv run pytest tests/connectors/test_duckdb.py -v -m duckdb
test-postgres:
uv run pytest tests/connectors/test_postgres.py -v -m postgres
test-connector marker:
uv run pytest tests/connectors/ -v -m {{ marker }}
lint:
uv run ruff format --check src/
uv run ruff check src/
format:
uv run ruff format src/
uv run ruff check --fix src/
build:
uv build
alias fmt := format
+74
View File
@@ -0,0 +1,74 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "wren"
dynamic = ["version"]
description = "Wren Engine CLI and Python SDK — semantic SQL layer for 20+ data sources"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
keywords = ["wren", "sql", "semantic", "mdl", "datafusion"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"wren-core-py>=0.1",
"ibis-framework>=10",
"duckdb>=1.0",
"sqlglot>=27",
"typer>=0.12",
"pydantic>=2",
"pyarrow>=14",
"pyarrow-hotfix>=0.6",
"loguru>=0.7",
"opendal>=0.45",
"pandas>=2",
"boto3>=1.26",
]
[project.optional-dependencies]
postgres = ["psycopg>=3", "ibis-framework[postgres]"]
mysql = ["mysqlclient>=2.2", "ibis-framework[mysql]"]
bigquery = ["ibis-framework[bigquery]", "google-auth"]
snowflake = ["ibis-framework[snowflake]"]
clickhouse = ["ibis-framework[clickhouse]"]
trino = ["ibis-framework[trino]", "trino>=0.321"]
mssql = ["ibis-framework[mssql]"]
databricks = ["databricks-sql-connector", "databricks-sdk"]
redshift = ["redshift_connector"]
spark = ["pyspark>=3.5"]
athena = ["ibis-framework[athena]"]
oracle = ["ibis-framework[oracle]", "oracledb"]
all = ["wren[postgres,mysql,bigquery,snowflake,clickhouse,trino,mssql,databricks,redshift,athena,oracle]"]
dev = ["pytest>=8", "ruff>=0.4", "orjson>=3", "testcontainers[postgres]>=4"]
[project.scripts]
wren = "wren.cli:app"
[project.urls]
Homepage = "https://getwren.ai"
Repository = "https://github.com/Canner/wren-engine"
Issues = "https://github.com/Canner/wren-engine/issues"
[tool.hatch.version]
path = "src/wren/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/wren"]
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "PLC"]
ignore = [
"E501", # line-too-long, enforced by ruff format
]
+9
View File
@@ -0,0 +1,9 @@
"""Wren — semantic SQL layer for 20+ data sources."""
__version__ = "0.0.1"
from wren.engine import WrenEngine
from wren.model.data_source import DataSource
from wren.model.error import WrenError
__all__ = ["WrenEngine", "DataSource", "WrenError", "__version__"]
+225
View File
@@ -0,0 +1,225 @@
"""Wren CLI — SQL transform and execution via the Wren semantic layer."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Optional
import typer
app = typer.Typer(name="wren", help="Wren Engine CLI", no_args_is_help=True)
def _load_connection_info(
connection_info: str | None,
connection_file_path: str | None,
) -> dict:
if connection_info:
try:
return json.loads(connection_info)
except json.JSONDecodeError as e:
typer.echo(f"Error: invalid JSON in --connection-info: {e}", err=True)
raise typer.Exit(1)
if connection_file_path:
path = Path(connection_file_path)
if not path.exists():
typer.echo(
f"Error: connection file not found: {connection_file_path}", err=True
)
raise typer.Exit(1)
try:
return json.loads(path.read_text())
except json.JSONDecodeError as e:
typer.echo(f"Error: invalid JSON in {connection_file_path}: {e}", err=True)
raise typer.Exit(1)
typer.echo(
"Error: either --connection-info or --connection-file must be provided",
err=True,
)
raise typer.Exit(1)
def _load_manifest(mdl: str) -> str:
"""Load MDL from a file path or treat as base64 string directly."""
path = Path(mdl)
if path.exists():
import base64 # noqa: PLC0415
content = path.read_bytes()
# If it's a JSON file, base64-encode it
if mdl.endswith(".json"):
return base64.b64encode(content).decode()
# Otherwise assume it's already base64
return content.decode().strip()
# Treat as inline base64 string
return mdl
def _make_engine(
sql: str,
datasource: str,
mdl: str,
connection_info: str | None,
connection_file: str | None,
):
from wren.engine import WrenEngine # noqa: PLC0415
from wren.model.data_source import DataSource # noqa: PLC0415
manifest_str = _load_manifest(mdl)
conn_dict = _load_connection_info(connection_info, connection_file)
try:
ds = DataSource(datasource.lower())
except ValueError:
typer.echo(f"Error: unknown datasource '{datasource}'", err=True)
raise typer.Exit(1)
return WrenEngine(
manifest_str=manifest_str, data_source=ds, connection_info=conn_dict
)
# ── Common options ─────────────────────────────────────────────────────────
SqlArg = Annotated[str, typer.Option("--sql", "-s", help="SQL query to execute")]
DatasourceOpt = Annotated[
str,
typer.Option("--datasource", "-d", help="Data source name (e.g. postgres, duckdb)"),
]
MdlOpt = Annotated[
str, typer.Option("--mdl", "-m", help="Path to MDL JSON file or base64 MDL string")
]
ConnInfoOpt = Annotated[
Optional[str], typer.Option("--connection-info", help="JSON connection info string")
]
ConnFileOpt = Annotated[
Optional[str],
typer.Option("--connection-file", help="Path to JSON connection info file"),
]
LimitOpt = Annotated[
Optional[int], typer.Option("--limit", "-l", help="Max rows to return")
]
@app.command()
def query(
sql: SqlArg,
datasource: DatasourceOpt,
mdl: MdlOpt,
connection_info: ConnInfoOpt = None,
connection_file: ConnFileOpt = None,
limit: LimitOpt = None,
output: Annotated[
str, typer.Option("--output", "-o", help="Output format: json|csv|table")
] = "table",
):
"""Execute a SQL query through the Wren semantic layer."""
engine = _make_engine(sql, datasource, mdl, connection_info, connection_file)
try:
result = engine.query(sql, limit=limit)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
engine.close()
_print_result(result, output)
@app.command(name="dry-run")
def dry_run(
sql: SqlArg,
datasource: DatasourceOpt,
mdl: MdlOpt,
connection_info: ConnInfoOpt = None,
connection_file: ConnFileOpt = None,
):
"""Dry-run a SQL query (parse + validate, no results returned)."""
engine = _make_engine(sql, datasource, mdl, connection_info, connection_file)
try:
engine.dry_run(sql)
typer.echo("OK")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
engine.close()
@app.command()
def transpile(
sql: SqlArg,
datasource: DatasourceOpt,
mdl: MdlOpt,
):
"""Transform SQL through MDL and emit the target dialect SQL (no DB required)."""
from wren.engine import WrenEngine # noqa: PLC0415
from wren.model.data_source import DataSource # noqa: PLC0415
manifest_str = _load_manifest(mdl)
try:
ds = DataSource(datasource.lower())
except ValueError:
typer.echo(f"Error: unknown datasource '{datasource}'", err=True)
raise typer.Exit(1)
# For transpile we don't need real connection_info — pass a dummy dict
engine = WrenEngine(manifest_str=manifest_str, data_source=ds, connection_info={})
try:
result = engine.transpile(sql)
typer.echo(result)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
engine.close()
@app.command()
def validate(
sql: SqlArg,
datasource: DatasourceOpt,
mdl: MdlOpt,
connection_info: ConnInfoOpt = None,
connection_file: ConnFileOpt = None,
):
"""Validate SQL can be planned and dry-run against the data source."""
engine = _make_engine(sql, datasource, mdl, connection_info, connection_file)
try:
engine.dry_run(sql)
typer.echo("Valid")
except Exception as e:
typer.echo(f"Invalid: {e}", err=True)
raise typer.Exit(1)
finally:
engine.close()
# ── Output formatting ──────────────────────────────────────────────────────
def _print_result(table, output: str) -> None:
if output == "json":
try:
df = table.to_pandas()
typer.echo(df.to_json(orient="records", lines=True))
except Exception:
typer.echo(json.dumps(table.to_pydict()))
elif output == "csv":
try:
df = table.to_pandas()
typer.echo(df.to_csv(index=False))
except Exception:
typer.echo(str(table))
else:
# Default: table format via pandas
try:
df = table.to_pandas()
typer.echo(df.to_string(index=False))
except Exception:
typer.echo(str(table))
if __name__ == "__main__":
app()
+4
View File
@@ -0,0 +1,4 @@
from wren.connector.base import ConnectorABC, IbisConnector
from wren.connector.factory import get_connector
__all__ = ["ConnectorABC", "IbisConnector", "get_connector"]
+87
View File
@@ -0,0 +1,87 @@
from abc import ABC, abstractmethod
import pyarrow as pa
from ibis.expr.datatypes import Decimal
from ibis.expr.datatypes.core import UUID
from ibis.expr.types import Table
from loguru import logger
from wren.model.data_source import DataSource
class ConnectorABC(ABC):
@abstractmethod
def query(self, sql: str, limit: int | None = None) -> pa.Table:
pass
@abstractmethod
def dry_run(self, sql: str) -> None:
pass
@abstractmethod
def close(self) -> None:
pass
class IbisConnector(ConnectorABC):
def __init__(self, data_source: DataSource, connection_info):
self.data_source = data_source
self.connection = self.data_source.get_connection(connection_info)
self._closed = False
def query(self, sql: str, limit: int | None = None) -> pa.Table:
ibis_table = self.connection.sql(sql)
if limit is not None:
ibis_table = ibis_table.limit(limit)
ibis_table = self._handle_pyarrow_unsupported_type(ibis_table)
return ibis_table.to_pyarrow()
def _handle_pyarrow_unsupported_type(self, ibis_table: Table, **kwargs) -> Table:
result_table = ibis_table
for name, dtype in ibis_table.schema().items():
if isinstance(dtype, Decimal):
result_table = self._round_decimal_columns(
result_table=result_table, col_name=name, **kwargs
)
elif isinstance(dtype, UUID):
result_table = self._cast_uuid_columns(
result_table=result_table, col_name=name
)
return result_table
def _cast_uuid_columns(self, result_table: Table, col_name: str) -> Table:
return result_table.mutate(**{col_name: result_table[col_name].cast("string")})
def _round_decimal_columns(
self, result_table: Table, col_name: str, scale: int = 9
) -> Table:
col = result_table[col_name]
decimal_type = Decimal(precision=38, scale=scale)
rounded_col = col.cast(decimal_type).round(scale)
return result_table.mutate(**{col_name: rounded_col})
def dry_run(self, sql: str) -> None:
self.connection.sql(sql)
def close(self) -> None:
if self._closed or not hasattr(self, "connection") or self.connection is None:
return
try:
if hasattr(self.connection, "con"):
if hasattr(self.connection.con, "close"):
self.connection.con.close()
elif hasattr(self.connection, "close"):
self.connection.close()
elif hasattr(self.connection, "disconnect"):
self.connection.disconnect()
else:
logger.warning(
f"Closing connection for {self.data_source.value} is not implemented."
)
except Exception as e:
logger.warning(
f"Error closing connection for {self.data_source.value}: {e}"
)
finally:
self._closed = True
self.connection = None
+57
View File
@@ -0,0 +1,57 @@
import base64
from json import loads
import pyarrow as pa
from loguru import logger
from wren.connector.base import ConnectorABC
class BigQueryConnector(ConnectorABC):
def __init__(self, connection_info):
from google.cloud import bigquery # noqa: PLC0415
from google.oauth2 import service_account # noqa: PLC0415
self.connection_info = connection_info
credits_json = loads(
base64.b64decode(connection_info.credentials.get_secret_value()).decode(
"utf-8"
)
)
credentials = service_account.Credentials.from_service_account_info(
credits_json
)
credentials = credentials.with_scopes(
[
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/cloud-platform",
]
)
client = bigquery.Client(
credentials=credentials,
project=connection_info.get_billing_project_id(),
)
job_config = bigquery.QueryJobConfig()
job_config.job_timeout_ms = connection_info.job_timeout_ms
client.default_query_job_config = job_config
self.connection = client
def query(self, sql: str, limit: int | None = None) -> pa.Table:
return self.connection.query(sql).result(max_results=limit).to_arrow()
def dry_run(self, sql: str) -> None:
from google.cloud import bigquery # noqa: PLC0415
self.connection.query(
sql, job_config=bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
)
def close(self) -> None:
try:
self.connection.close()
except Exception as e:
logger.warning(f"Error closing BigQuery connection: {e}")
def create_connector(connection_info) -> BigQueryConnector:
return BigQueryConnector(connection_info)
+79
View File
@@ -0,0 +1,79 @@
from contextlib import closing
from functools import cache
from typing import Any
import ibis
import ibis.expr.schema as sch
import pyarrow as pa
from ibis import BaseBackend
from ibis.backends.sql.compilers.postgres import compiler as postgres_compiler
from ibis.expr.datatypes import Decimal
from ibis.expr.datatypes.core import UUID
from ibis.expr.types import Table
from loguru import logger
from wren.connector.base import ConnectorABC
from wren.model.data_source import DataSource
@cache
def _get_pg_type_names(connection: BaseBackend) -> dict[int, str]:
with closing(connection.raw_sql("SELECT oid, typname FROM pg_type")) as cur:
return dict(cur.fetchall())
class CannerConnector(ConnectorABC):
def __init__(self, connection_info):
self.connection = DataSource.canner.get_connection(connection_info)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
schema = self._get_schema(sql)
ibis_table = self.connection.sql(sql, schema=schema)
if limit is not None:
ibis_table = ibis_table.limit(limit)
ibis_table = self._handle_pyarrow_unsupported_type(ibis_table)
return ibis_table.to_pyarrow()
def _handle_pyarrow_unsupported_type(self, ibis_table: Table, **kwargs) -> Table:
result_table = ibis_table
for name, dtype in ibis_table.schema().items():
if isinstance(dtype, Decimal):
col = result_table[name]
decimal_type = Decimal(precision=38, scale=9)
rounded_col = col.cast(decimal_type).round(9)
result_table = result_table.mutate(**{name: rounded_col})
elif isinstance(dtype, UUID):
result_table = result_table.mutate(
**{name: result_table[name].cast("string")}
)
return result_table
def dry_run(self, sql: str) -> Any:
return self.connection.raw_sql(f"SELECT * FROM ({sql}) LIMIT 0")
def close(self) -> None:
try:
if hasattr(self.connection, "con") and hasattr(
self.connection.con, "close"
):
self.connection.con.close()
elif hasattr(self.connection, "close"):
self.connection.close()
except Exception as e:
logger.warning(f"Error closing Canner connection: {e}")
def _get_schema(self, sql: str) -> sch.Schema:
cur = self.dry_run(sql)
type_names = _get_pg_type_names(self.connection)
return ibis.schema(
{
desc.name: postgres_compiler.type_mapper.from_string(
type_names[desc.type_code]
)
for desc in cur.description
}
)
def create_connector(connection_info) -> CannerConnector:
return CannerConnector(connection_info)
@@ -0,0 +1,65 @@
from contextlib import closing
import pyarrow as pa
from loguru import logger
from wren.connector.base import ConnectorABC
from wren.model import (
DatabricksConnectionUnion,
DatabricksServicePrincipalConnectionInfo,
DatabricksTokenConnectionInfo,
)
class DatabricksConnector(ConnectorABC):
def __init__(self, connection_info: DatabricksConnectionUnion):
from databricks import sql as dbsql # noqa: PLC0415
from databricks.sdk.core import Config as DbConfig # noqa: PLC0415
from databricks.sdk.core import oauth_service_principal # noqa: PLC0415
if isinstance(connection_info, DatabricksTokenConnectionInfo):
self.connection = dbsql.connect(
server_hostname=connection_info.server_hostname.get_secret_value(),
http_path=connection_info.http_path.get_secret_value(),
access_token=connection_info.access_token.get_secret_value(),
)
elif isinstance(connection_info, DatabricksServicePrincipalConnectionInfo):
kwargs = {
"host": connection_info.server_hostname.get_secret_value(),
"client_id": connection_info.client_id.get_secret_value(),
"client_secret": connection_info.client_secret.get_secret_value(),
}
if connection_info.azure_tenant_id is not None:
kwargs["azure_tenant_id"] = (
connection_info.azure_tenant_id.get_secret_value()
)
def credential_provider():
return oauth_service_principal(DbConfig(**kwargs))
self.connection = dbsql.connect(
server_hostname=connection_info.server_hostname.get_secret_value(),
http_path=connection_info.http_path.get_secret_value(),
credentials_provider=credential_provider,
)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
with closing(self.connection.cursor()) as cursor:
cursor.execute(sql)
if limit is not None:
return cursor.fetchmany_arrow(limit)
return cursor.fetchall_arrow()
def dry_run(self, sql: str) -> None:
with closing(self.connection.cursor()) as cursor:
cursor.execute(f"SELECT * FROM ({sql}) AS sub LIMIT 0")
def close(self) -> None:
try:
self.connection.close()
except Exception as e:
logger.warning(f"Error closing Databricks connection: {e}")
def create_connector(connection_info) -> DatabricksConnector:
return DatabricksConnector(connection_info)
+125
View File
@@ -0,0 +1,125 @@
import os
import opendal
import pyarrow as pa
from loguru import logger
from wren.connector.base import ConnectorABC
from wren.model import (
GcsFileConnectionInfo,
MinioFileConnectionInfo,
S3FileConnectionInfo,
)
from wren.model.error import ErrorCode, WrenError
def _escape_sql(value: str) -> str:
return value.replace("'", "''")
def _init_duckdb_s3(connection, info: S3FileConnectionInfo):
connection.execute(f"""
CREATE SECRET wren_s3 (
TYPE S3,
KEY_ID '{_escape_sql(info.access_key.get_secret_value())}',
SECRET '{_escape_sql(info.secret_key.get_secret_value())}',
REGION '{_escape_sql(info.region.get_secret_value())}'
)""")
def _init_duckdb_minio(connection, info: MinioFileConnectionInfo):
connection.execute(f"""
CREATE SECRET wren_minio (
TYPE S3,
KEY_ID '{_escape_sql(info.access_key.get_secret_value())}',
SECRET '{_escape_sql(info.secret_key.get_secret_value())}',
REGION 'ap-northeast-1'
)""")
connection.execute("SET s3_endpoint=?", [info.endpoint.get_secret_value()])
connection.execute("SET s3_url_style='path'")
connection.execute("SET s3_use_ssl=?", [info.ssl_enabled])
def _init_duckdb_gcs(connection, info: GcsFileConnectionInfo):
connection.execute(f"""
CREATE SECRET wren_gcs (
TYPE GCS,
KEY_ID '{_escape_sql(info.key_id.get_secret_value())}',
SECRET '{_escape_sql(info.secret_key.get_secret_value())}'
)""")
class DuckDBConnector(ConnectorABC):
def __init__(self, connection_info):
import duckdb # noqa: PLC0415
from duckdb import HTTPException, IOException # noqa: PLC0415
self._HTTPException = HTTPException
self._IOException = IOException
self.connection = duckdb.connect()
try:
if isinstance(connection_info, S3FileConnectionInfo):
_init_duckdb_s3(self.connection, connection_info)
if isinstance(connection_info, MinioFileConnectionInfo):
_init_duckdb_minio(self.connection, connection_info)
if isinstance(connection_info, GcsFileConnectionInfo):
_init_duckdb_gcs(self.connection, connection_info)
if connection_info.format == "duckdb":
self._attach_database(connection_info)
except Exception:
self.connection.close()
raise
def query(self, sql: str, limit: int | None = None) -> pa.Table:
if limit is not None:
sql = f"SELECT * FROM ({sql}) AS _q LIMIT {int(limit)}"
return self.connection.execute(sql).fetch_arrow_table()
def dry_run(self, sql: str) -> None:
self.connection.execute(f"EXPLAIN {sql}")
def _attach_database(self, connection_info) -> None:
db_files = self._list_duckdb_files(connection_info)
if not db_files:
raise WrenError(ErrorCode.DUCKDB_FILE_NOT_FOUND, "No DuckDB files found.")
for file in db_files:
try:
escaped_file = file.replace("'", "''")
alias = os.path.splitext(os.path.basename(file))[0].replace('"', '""')
self.connection.execute(
f"ATTACH DATABASE '{escaped_file}' AS \"{alias}\" (READ_ONLY);"
)
except (self._IOException, self._HTTPException) as e:
raise WrenError(
ErrorCode.ATTACH_DUCKDB_ERROR, f"Failed to attach: {e!s}"
)
def _list_duckdb_files(self, connection_info) -> list[str]:
op = opendal.Operator("fs", root=connection_info.url.get_secret_value())
files = []
try:
for file in op.list("/"):
if file.path != "/":
stat = op.stat(file.path)
if not stat.mode.is_dir() and file.path.endswith(".duckdb"):
files.append(
f"{connection_info.url.get_secret_value()}/{file.path}"
)
except Exception as e:
raise WrenError(
ErrorCode.GENERIC_USER_ERROR, f"Failed to list files: {e!s}"
)
return files
def close(self) -> None:
try:
self.connection.close()
except Exception as e:
logger.warning(f"Error closing DuckDB connection: {e}")
def create_connector(connection_info) -> DuckDBConnector:
return DuckDBConnector(connection_info)
+69
View File
@@ -0,0 +1,69 @@
import importlib
from wren.model.data_source import DataSource
from wren.model.error import ErrorCode, WrenError
_REGISTRY: dict[DataSource, str] = {
DataSource.postgres: "wren.connector.postgres",
DataSource.mysql: "wren.connector.mysql",
DataSource.doris: "wren.connector.mysql",
DataSource.mssql: "wren.connector.mssql",
DataSource.canner: "wren.connector.canner",
DataSource.bigquery: "wren.connector.bigquery",
DataSource.local_file: "wren.connector.duckdb",
DataSource.s3_file: "wren.connector.duckdb",
DataSource.minio_file: "wren.connector.duckdb",
DataSource.gcs_file: "wren.connector.duckdb",
DataSource.duckdb: "wren.connector.duckdb",
DataSource.redshift: "wren.connector.redshift",
DataSource.spark: "wren.connector.spark",
DataSource.databricks: "wren.connector.databricks",
DataSource.trino: "wren.connector.ibis",
DataSource.clickhouse: "wren.connector.ibis",
DataSource.oracle: "wren.connector.ibis",
DataSource.snowflake: "wren.connector.ibis",
DataSource.athena: "wren.connector.ibis",
}
# Map data sources to the correct pip extra when they share a connector module
_INSTALL_EXTRA: dict[DataSource, str] = {
DataSource.doris: "mysql",
DataSource.canner: "postgres",
DataSource.local_file: "duckdb",
DataSource.s3_file: "duckdb",
DataSource.minio_file: "duckdb",
DataSource.gcs_file: "duckdb",
}
_NEEDS_DATA_SOURCE = {
DataSource.mysql,
DataSource.doris,
DataSource.trino,
DataSource.clickhouse,
DataSource.oracle,
DataSource.snowflake,
DataSource.athena,
}
def get_connector(data_source: DataSource, connection_info):
module_path = _REGISTRY.get(data_source)
if module_path is None:
raise WrenError(
ErrorCode.NOT_IMPLEMENTED,
f"Unsupported data source: {data_source}",
)
try:
module = importlib.import_module(module_path)
except ImportError as e:
extra = _INSTALL_EXTRA.get(data_source, data_source.value)
raise WrenError(
ErrorCode.NOT_IMPLEMENTED,
f"Connector '{data_source.value}' requires additional dependencies: {e}. "
f"Install with: pip install wren[{extra}]",
) from e
if data_source in _NEEDS_DATA_SOURCE:
return module.create_connector(data_source, connection_info)
return module.create_connector(connection_info)
+103
View File
@@ -0,0 +1,103 @@
"""Generic ibis-backed connectors with data-source-specific error handling."""
import pyarrow as pa
from wren.connector.base import IbisConnector
from wren.model.data_source import DataSource
from wren.model.error import DIALECT_SQL, ErrorCode, ErrorPhase, WrenError
try:
import clickhouse_connect
_ClickHouseDbError = clickhouse_connect.driver.exceptions.DatabaseError
except ImportError:
class _ClickHouseDbError(Exception):
pass
class TrinoConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.trino, connection_info)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
import trino # noqa: PLC0415
try:
return super().query(sql, limit)
except trino.exceptions.TrinoQueryError as e:
if not e.error_name == "EXCEEDED_TIME_LIMIT":
raise WrenError(
ErrorCode.INVALID_SQL,
str(e),
phase=ErrorPhase.SQL_EXECUTION,
metadata={DIALECT_SQL: sql},
) from e
raise
except (WrenError, TimeoutError):
raise
def dry_run(self, sql: str) -> None:
import trino # noqa: PLC0415
try:
super().dry_run(sql)
except trino.exceptions.TrinoQueryError as e:
if not e.error_name == "EXCEEDED_TIME_LIMIT":
raise WrenError(
ErrorCode.INVALID_SQL,
str(e),
phase=ErrorPhase.SQL_DRY_RUN,
metadata={DIALECT_SQL: sql},
) from e
raise
except (WrenError, TimeoutError):
raise
class ClickHouseConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.clickhouse, connection_info)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
try:
return super().query(sql, limit)
except _ClickHouseDbError as e:
if "TIMEOUT_EXCEEDED" not in str(e):
raise WrenError(
ErrorCode.INVALID_SQL,
str(e),
phase=ErrorPhase.SQL_EXECUTION,
metadata={DIALECT_SQL: sql},
) from e
raise
except (WrenError, TimeoutError):
raise
def dry_run(self, sql: str) -> None:
try:
super().dry_run(sql)
except _ClickHouseDbError as e:
if "TIMEOUT_EXCEEDED" not in str(e):
raise WrenError(
ErrorCode.INVALID_SQL,
str(e),
phase=ErrorPhase.SQL_DRY_RUN,
metadata={DIALECT_SQL: sql},
) from e
raise
except (WrenError, TimeoutError):
raise
_DATA_SOURCE_TO_CLASS = {
DataSource.trino: TrinoConnector,
DataSource.clickhouse: ClickHouseConnector,
}
def create_connector(data_source: DataSource, connection_info) -> IbisConnector:
cls = _DATA_SOURCE_TO_CLASS.get(data_source, IbisConnector)
if cls is IbisConnector:
return IbisConnector(data_source, connection_info)
return cls(connection_info)
+113
View File
@@ -0,0 +1,113 @@
from contextlib import closing
from decimal import Decimal as PyDecimal
import pyarrow as pa
import sqlglot.expressions as sge
from ibis.expr.datatypes import Decimal
from ibis.expr.types import Table
from sqlglot import exp, parse_one
from wren.connector.base import IbisConnector
from wren.model.data_source import DataSource
from wren.model.error import DIALECT_SQL, ErrorCode, ErrorPhase, WrenError
class MSSqlConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.mssql, connection_info)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
sql = self._flatten_pagination_limit(sql)
ibis_table = self.connection.sql(sql)
if limit is not None:
ibis_table = ibis_table.limit(limit)
ibis_table = self._handle_pyarrow_unsupported_type(ibis_table)
return self._round_decimal_columns(ibis_table)
def _round_decimal_columns(self, ibis_table: Table, scale: int = 9) -> pa.Table:
def round_decimal(val):
if val is None:
return None
d = PyDecimal(str(val))
return d.quantize(PyDecimal("1." + "0" * scale))
decimal_columns = [
name
for name, dtype in ibis_table.schema().items()
if isinstance(dtype, Decimal)
]
if not decimal_columns:
return ibis_table.to_pyarrow()
pandas_df = ibis_table.to_pandas()
for col_name in decimal_columns:
pandas_df[col_name] = pandas_df[col_name].apply(round_decimal)
return pa.Table.from_pandas(pandas_df)
def _flatten_pagination_limit(
self, sql_query: str, input_dialect: str = "tsql"
) -> str:
try:
parsed = parse_one(sql_query, dialect=input_dialect)
if not isinstance(parsed, exp.Select) or not parsed.args.get("limit"):
return sql_query
from_clause = parsed.find(exp.From)
if not from_clause:
return sql_query
subqueries = []
if isinstance(from_clause.this, exp.Subquery):
subqueries.append(from_clause.this)
for join in parsed.args.get("joins") or []:
if isinstance(join, exp.Join):
if isinstance(join.this, exp.Subquery):
subqueries.append(join.this)
if join.expression and isinstance(join.expression, exp.Subquery):
subqueries.append(join.expression)
if len(subqueries) != 1:
return sql_query
inner = subqueries[0].this
if not isinstance(inner, exp.Select):
return sql_query
inner.set("limit", exp.Limit(expression=parsed.args["limit"].expression))
return inner.sql(dialect="tsql")
except Exception:
return sql_query
def dry_run(self, sql: str) -> None:
try:
super().dry_run(sql)
except AttributeError as e:
if "NoneType" in str(e) and "lower" in str(e):
error_message = self._describe_sql_for_error_message(sql)
raise WrenError(
error_code=ErrorCode.INVALID_SQL,
message=f"The sql dry run failed. {error_message}.",
phase=ErrorPhase.SQL_DRY_RUN,
metadata={DIALECT_SQL: sql},
) from e
raise WrenError(
error_code=ErrorCode.IBIS_PROJECT_ERROR,
message=str(e),
phase=ErrorPhase.SQL_DRY_RUN,
) from e
def _describe_sql_for_error_message(self, sql: str) -> str:
try:
tsql = sge.convert(sql).sql("mssql")
describe_sql = f"SELECT error_message FROM sys.dm_exec_describe_first_result_set({tsql}, NULL, 0)"
with closing(self.connection.raw_sql(describe_sql)) as cur:
rows = cur.fetchall()
if not rows:
return "Unknown reason"
return rows[0][0]
except Exception:
return "Unknown reason"
def create_connector(connection_info) -> MSSqlConnector:
return MSSqlConnector(connection_info)
+57
View File
@@ -0,0 +1,57 @@
import ibis.expr.datatypes as dt
from ibis.expr.datatypes import Decimal
from ibis.expr.datatypes.core import UUID
from ibis.expr.types import Table
from wren.connector.base import IbisConnector
from wren.model.data_source import DataSource
class MySqlConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.mysql, connection_info)
def _handle_pyarrow_unsupported_type(self, ibis_table: Table, **kwargs) -> Table:
result_table = ibis_table
for name, dtype in ibis_table.schema().items():
if isinstance(dtype, Decimal):
result_table = self._round_decimal_columns(
result_table=result_table, col_name=name, **kwargs
)
elif isinstance(dtype, UUID):
result_table = self._cast_uuid_columns(
result_table=result_table, col_name=name
)
elif isinstance(dtype, dt.JSON):
result_table = result_table.mutate(
**{name: result_table[name].cast("string")}
)
return result_table
class DorisConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.doris, connection_info)
def _handle_pyarrow_unsupported_type(self, ibis_table: Table, **kwargs) -> Table:
result_table = ibis_table
for name, dtype in ibis_table.schema().items():
if isinstance(dtype, Decimal):
result_table = self._round_decimal_columns(
result_table=result_table, col_name=name, **kwargs
)
elif isinstance(dtype, UUID):
result_table = self._cast_uuid_columns(
result_table=result_table, col_name=name
)
elif isinstance(dtype, dt.JSON):
result_table = result_table.mutate(
**{name: result_table[name].cast("string")}
)
return result_table
def create_connector(data_source: DataSource, connection_info):
if data_source == DataSource.doris:
return DorisConnector(connection_info)
return MySqlConnector(connection_info)
+76
View File
@@ -0,0 +1,76 @@
from contextlib import suppress
import pyarrow as pa
from loguru import logger
from wren.connector.base import IbisConnector
from wren.model.data_source import DataSource
from wren.model.error import DIALECT_SQL, ErrorCode, ErrorPhase, WrenError
class PostgresConnector(IbisConnector):
def __init__(self, connection_info):
super().__init__(DataSource.postgres, connection_info)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
import psycopg # noqa: PLC0415
try:
return super().query(sql, limit)
except psycopg.errors.QueryCanceled:
raise
except (WrenError, TimeoutError):
raise
except Exception as e:
raise WrenError(
ErrorCode.GENERIC_USER_ERROR,
str(e),
phase=ErrorPhase.SQL_EXECUTION,
metadata={DIALECT_SQL: sql},
) from e
def dry_run(self, sql: str) -> None:
import psycopg # noqa: PLC0415
try:
super().dry_run(sql)
except psycopg.errors.QueryCanceled:
raise
except (WrenError, TimeoutError):
raise
except Exception as e:
raise WrenError(
ErrorCode.GENERIC_USER_ERROR,
str(e),
phase=ErrorPhase.SQL_DRY_RUN,
metadata={DIALECT_SQL: sql},
) from e
def close(self) -> None:
if self._closed or not hasattr(self, "connection") or self.connection is None:
return
try:
if hasattr(self.connection, "con") and self.connection.con is not None:
if (
hasattr(self.connection.con, "closed")
and not self.connection.con.closed
):
with suppress(Exception):
self.connection.con.cancel()
import time # noqa: PLC0415
time.sleep(0.1)
self.connection.con.close()
elif hasattr(self.connection, "close"):
self.connection.close()
except Exception as e:
logger.warning(f"Error closing postgres connection: {e}")
if hasattr(self.connection, "con"):
self.connection.con = None
finally:
self._closed = True
self.connection = None
def create_connector(connection_info) -> PostgresConnector:
return PostgresConnector(connection_info)
+68
View File
@@ -0,0 +1,68 @@
from contextlib import closing
import pandas as pd
import pyarrow as pa
from loguru import logger
from wren.connector.base import ConnectorABC
from wren.model import (
RedshiftConnectionInfo,
RedshiftConnectionUnion,
RedshiftIAMConnectionInfo,
)
from wren.model.error import ErrorCode, WrenError
class RedshiftConnector(ConnectorABC):
def __init__(self, connection_info: RedshiftConnectionUnion):
import redshift_connector # noqa: PLC0415
if isinstance(connection_info, RedshiftIAMConnectionInfo):
self.connection = redshift_connector.connect(
iam=True,
cluster_identifier=connection_info.cluster_identifier.get_secret_value(),
database=connection_info.database.get_secret_value(),
db_user=connection_info.user.get_secret_value(),
access_key_id=connection_info.access_key_id.get_secret_value(),
secret_access_key=connection_info.access_key_secret.get_secret_value(),
region=connection_info.region.get_secret_value(),
)
elif isinstance(connection_info, RedshiftConnectionInfo):
self.connection = redshift_connector.connect(
host=connection_info.host.get_secret_value(),
port=int(connection_info.port.get_secret_value()),
database=connection_info.database.get_secret_value(),
user=connection_info.user.get_secret_value(),
password=connection_info.password.get_secret_value(),
)
else:
raise WrenError(
ErrorCode.GENERIC_INTERNAL_ERROR,
"Invalid Redshift connection_info type",
)
self.connection.autocommit = True
def query(self, sql: str, limit: int | None = None) -> pa.Table:
if limit is not None:
sql = f"SELECT * FROM ({sql}) AS _q LIMIT {int(limit)}"
with closing(self.connection.cursor()) as cursor:
cursor.execute(sql)
cols = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
df = pd.DataFrame(rows, columns=cols)
return pa.Table.from_pandas(df)
def dry_run(self, sql: str) -> None:
with closing(self.connection.cursor()) as cursor:
cursor.execute(f"SELECT * FROM ({sql}) AS sub LIMIT 0")
def close(self) -> None:
try:
self.connection.close()
except Exception as e:
logger.warning(f"Error closing Redshift connection: {e}")
def create_connector(connection_info) -> RedshiftConnector:
return RedshiftConnector(connection_info)
+52
View File
@@ -0,0 +1,52 @@
import pyarrow as pa
from wren.connector.base import ConnectorABC
from wren.model import SparkConnectionInfo
class SparkConnector(ConnectorABC):
def __init__(self, connection_info: SparkConnectionInfo):
self.connection_info = connection_info
self.connection = self._create_session()
self._closed = False
def _create_session(self):
from pyspark.sql import SparkSession # noqa: PLC0415
host = self.connection_info.host.get_secret_value()
port = self.connection_info.port.get_secret_value()
return (
SparkSession.builder.remote(f"sc://{host}:{port}")
.appName("wren")
.getOrCreate()
)
def query(self, sql: str, limit: int | None = None) -> pa.Table:
df = self.connection.sql(sql).toPandas()
if hasattr(df, "attrs") and df.attrs:
df.attrs = {
k: v
for k, v in df.attrs.items()
if k not in ("metrics", "observed_metrics")
}
arrow_table = pa.Table.from_pandas(df)
if limit is not None:
arrow_table = arrow_table.slice(0, limit)
return arrow_table
def dry_run(self, sql: str) -> None:
self.connection.sql(sql).limit(0).count()
def close(self) -> None:
if self._closed:
return
try:
self.connection.stop()
except Exception:
pass
finally:
self._closed = True
def create_connector(connection_info) -> SparkConnector:
return SparkConnector(connection_info)
+207
View File
@@ -0,0 +1,207 @@
"""WrenEngine — SQL transform + execute against a data source.
Example usage:
from wren.engine import WrenEngine
from wren.model.data_source import DataSource
engine = WrenEngine(
manifest_str="<base64-encoded MDL JSON>",
data_source=DataSource.postgres,
connection_info={"host": "localhost", "port": 5432, ...},
)
# Transform only (no DB required)
planned_sql = engine.transpile("SELECT * FROM orders")
# Execute against the data source
arrow_table = engine.query("SELECT * FROM orders", limit=100)
"""
from __future__ import annotations
from typing import Any
import pyarrow as pa
import sqlglot
from wren.connector.factory import get_connector
from wren.mdl import get_manifest_extractor, get_session_context, to_json_base64
from wren.model.data_source import DataSource
from wren.model.error import DIALECT_SQL, PLANNED_SQL, ErrorCode, ErrorPhase, WrenError
def _get_write_dialect(data_source: DataSource) -> str:
if data_source == DataSource.canner:
return "trino"
if data_source in {
DataSource.local_file,
DataSource.s3_file,
DataSource.minio_file,
DataSource.gcs_file,
}:
return "duckdb"
return data_source.name
class WrenEngine:
"""Thin facade over wren-core MDL processing and connector execution.
Parameters
----------
manifest_str:
Base64-encoded MDL JSON string (as produced by ``wren_core.to_json_base64``).
data_source:
Target data source enum value.
connection_info:
Dict of connection parameters OR a typed ConnectionInfo object.
function_path:
Optional path to a CSV file of custom function definitions.
Passed through to wren-core SessionContext.
"""
def __init__(
self,
manifest_str: str,
data_source: DataSource | str,
connection_info: dict[str, Any] | object,
function_path: str | None = None,
):
if isinstance(data_source, str):
data_source = DataSource(data_source)
self.manifest_str = manifest_str
self.data_source = data_source
self.function_path = function_path
# Build typed ConnectionInfo if a raw dict was given.
# An empty dict is allowed for transpile-only usage (no DB connection).
if isinstance(connection_info, dict) and connection_info:
self.connection_info = data_source.get_connection_info(connection_info)
else:
self.connection_info = connection_info
self._connector = None
# ------------------------------------------------------------------
# SQL transformation (no DB access)
# ------------------------------------------------------------------
def transpile(self, sql: str, properties: dict | None = None) -> str:
"""Transform SQL through MDL and transpile to the target dialect.
Returns the dialect SQL string without executing it.
"""
planned = self._plan(sql, properties)
return self._transpile(planned)
def dry_plan(self, sql: str, properties: dict | None = None) -> str:
"""Return the wren-core planned SQL (DataFusion dialect, before transpile)."""
return self._plan(sql, properties)
# ------------------------------------------------------------------
# SQL execution
# ------------------------------------------------------------------
def query(
self,
sql: str,
limit: int | None = None,
properties: dict | None = None,
) -> pa.Table:
"""Transpile and execute SQL, return results as an Arrow table."""
dialect_sql = self.transpile(sql, properties)
connector = self._get_connector()
try:
return connector.query(dialect_sql, limit)
except WrenError:
raise
except Exception as e:
raise WrenError(
ErrorCode.GENERIC_USER_ERROR,
str(e),
phase=ErrorPhase.SQL_EXECUTION,
metadata={DIALECT_SQL: dialect_sql},
) from e
def dry_run(self, sql: str, properties: dict | None = None) -> None:
"""Transpile and dry-run SQL without returning results."""
dialect_sql = self.transpile(sql, properties)
connector = self._get_connector()
try:
connector.dry_run(dialect_sql)
except WrenError:
raise
except Exception as e:
raise WrenError(
ErrorCode.GENERIC_USER_ERROR,
str(e),
phase=ErrorPhase.SQL_DRY_RUN,
metadata={DIALECT_SQL: dialect_sql},
) from e
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def close(self) -> None:
if self._connector is not None:
self._connector.close()
self._connector = None
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _plan(self, sql: str, properties: dict | None) -> str:
processed = None
if properties:
processed = frozenset(properties.items())
try:
# Extract minimal manifest for the query
extractor = get_manifest_extractor(self.manifest_str)
tables = extractor.resolve_used_table_names(sql)
manifest = extractor.extract_by(tables)
effective_manifest = to_json_base64(manifest)
except Exception:
effective_manifest = self.manifest_str
try:
session = get_session_context(
effective_manifest,
self.function_path,
processed,
self.data_source.name,
)
return session.transform_sql(sql)
except Exception as e:
raise WrenError(
ErrorCode.INVALID_SQL,
str(e),
phase=ErrorPhase.SQL_PLANNING,
metadata={DIALECT_SQL: sql},
) from e
def _transpile(self, planned_sql: str) -> str:
try:
write = _get_write_dialect(self.data_source)
return sqlglot.transpile(planned_sql, read="duckdb", write=write)[0]
except Exception as e:
raise WrenError(
ErrorCode.SQLGLOT_ERROR,
str(e),
phase=ErrorPhase.SQL_TRANSPILE,
metadata={PLANNED_SQL: planned_sql},
) from e
def _get_connector(self):
if self._connector is None:
self._connector = get_connector(self.data_source, self.connection_info)
return self._connector
+44
View File
@@ -0,0 +1,44 @@
"""MDL processing utilities backed by wren-core-py."""
from functools import cache
import wren_core
@cache
def get_session_context(
manifest_str: str | None,
function_path: str | None,
properties: frozenset | None = None,
data_source: str | None = None,
) -> wren_core.SessionContext:
return wren_core.SessionContext(
manifest_str, function_path, properties, data_source
)
def get_manifest_extractor(manifest_str: str) -> wren_core.ManifestExtractor:
return wren_core.ManifestExtractor(manifest_str)
def to_json_base64(manifest) -> str:
return wren_core.to_json_base64(manifest)
def transform_sql(
manifest_str: str,
sql: str,
data_source: str | None = None,
function_path: str | None = None,
properties: dict | None = None,
) -> str:
"""Transform SQL through wren-core MDL processing.
Returns the planned SQL string (dialect-neutral DataFusion SQL).
"""
processed = None
if properties:
processed = frozenset(properties.items())
session = get_session_context(manifest_str, function_path, processed, data_source)
return session.transform_sql(sql)
+296
View File
@@ -0,0 +1,296 @@
"""Connection info models and DTOs for the wren package."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, BeforeValidator, Field, SecretStr
from wren.model.error import ErrorCode, WrenError
SecretPort = Annotated[
SecretStr, BeforeValidator(lambda v: str(v) if isinstance(v, int) else v)
]
class BaseConnectionInfo(BaseModel):
model_config = {"populate_by_name": True}
def to_key_string(self) -> str:
key_parts = []
for _, field_value in self:
if isinstance(field_value, SecretStr):
key_parts.append(field_value.get_secret_value())
return "|".join(key_parts)
class BigQueryConnectionInfo(BaseConnectionInfo):
credentials: SecretStr = Field(
description="Base64 encode `credentials.json`", examples=["eyJ..."]
)
job_timeout_ms: int | None = Field(default=None)
def get_billing_project_id(self) -> str | None:
raise WrenError(
ErrorCode.NOT_IMPLEMENTED,
"get_billing_project_id not implemented by base class",
)
class BigQueryDatasetConnectionInfo(BigQueryConnectionInfo):
bigquery_type: Literal["dataset"] = "dataset"
project_id: SecretStr = Field(examples=["my-project"])
dataset_id: SecretStr = Field(examples=["my_dataset"])
def get_billing_project_id(self):
return self.project_id.get_secret_value()
def __hash__(self):
return hash((self.project_id, self.dataset_id, self.credentials))
class BigQueryProjectConnectionInfo(BigQueryConnectionInfo):
bigquery_type: Literal["project"] = "project"
region: SecretStr = Field(examples=["US"])
billing_project_id: SecretStr = Field(examples=["billing-project-1"])
def get_billing_project_id(self):
return self.billing_project_id.get_secret_value()
def __hash__(self):
return hash((self.region, self.billing_project_id, self.credentials))
BigQueryConnectionUnion = Annotated[
Union[BigQueryDatasetConnectionInfo, BigQueryProjectConnectionInfo],
Field(discriminator="bigquery_type", default="dataset"),
]
class AthenaConnectionInfo(BaseConnectionInfo):
s3_staging_dir: SecretStr = Field(examples=["s3://my-bucket/athena-staging/"])
aws_access_key_id: SecretStr | None = Field(default=None)
aws_secret_access_key: SecretStr | None = Field(default=None)
aws_session_token: SecretStr | None = Field(default=None)
web_identity_token: SecretStr | None = Field(default=None)
role_arn: SecretStr | None = Field(default=None)
role_session_name: SecretStr | None = Field(default=None)
region_name: SecretStr = Field(examples=["us-west-2"], default=None)
schema_name: SecretStr | None = Field(
alias="schema_name", default=SecretStr("default")
)
class CannerConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["8080"])
user: SecretStr = Field(examples=["admin"])
pat: SecretStr = Field(examples=["eyJ..."])
workspace: SecretStr = Field(examples=["default"])
enable_ssl: bool = Field(default=False, alias="enableSSL")
class ClickHouseConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["8123"])
database: SecretStr = Field(examples=["default"])
user: SecretStr = Field(examples=["default"])
password: SecretStr | None = Field(default=None)
secure: bool = Field(default=False)
settings: dict[str, str] | None = Field(default=None)
kwargs: dict[str, str] | None = Field(default=None)
class MSSqlConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["1433"])
database: SecretStr = Field(examples=["master"])
user: SecretStr = Field(examples=["sa"])
password: SecretStr | None = Field(default=None)
driver: str = Field(default="ODBC Driver 18 for SQL Server")
tds_version: str = Field(default="8.0", alias="TDS_Version")
kwargs: dict[str, str] | None = Field(default=None)
class MySqlConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["3306"])
database: SecretStr = Field(examples=["default"])
user: SecretStr = Field(examples=["root"])
password: SecretStr | None = Field(default=None)
ssl_mode: SecretStr | None = Field(alias="sslMode", default=SecretStr("ENABLED"))
ssl_ca: SecretStr | None = Field(alias="sslCA", default=None)
kwargs: dict[str, str] | None = Field(default=None)
class DorisConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["9030"])
database: SecretStr = Field(examples=["default"])
user: SecretStr = Field(examples=["root"])
password: SecretStr | None = Field(default=None)
kwargs: dict[str, str] | None = Field(default=None)
class PostgresConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["5432"])
database: SecretStr = Field(examples=["postgres"])
user: SecretStr = Field(examples=["postgres"])
password: SecretStr | None = Field(default=None)
kwargs: dict[str, str] | None = Field(default=None)
class OracleConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(default="localhost", examples=["localhost"])
port: SecretPort = Field(default="1521", examples=[1521])
database: SecretStr = Field(default="orcl", examples=["orcl"])
user: SecretStr = Field(examples=["admin"])
password: SecretStr | None = Field(default=None)
dsn: SecretStr | None = Field(default=None)
class RedshiftConnectionInfo(BaseConnectionInfo):
redshift_type: Literal["redshift"] = "redshift"
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["5439"])
database: SecretStr = Field(examples=["dev"])
user: SecretStr = Field(examples=["awsuser"])
password: SecretStr = Field(examples=["password"])
class RedshiftIAMConnectionInfo(BaseConnectionInfo):
redshift_type: Literal["redshift_iam"] = "redshift_iam"
cluster_identifier: SecretStr = Field(examples=["my-redshift-cluster"])
database: SecretStr = Field(examples=["dev"])
user: SecretStr = Field(examples=["awsuser"])
region: SecretStr = Field(examples=["us-west-2"])
access_key_id: SecretStr = Field(examples=["AKIA..."])
access_key_secret: SecretStr = Field(examples=["my-secret-key"])
RedshiftConnectionUnion = Annotated[
Union[RedshiftConnectionInfo, RedshiftIAMConnectionInfo],
Field(discriminator="redshift_type"),
]
class SnowflakeConnectionInfo(BaseConnectionInfo):
user: SecretStr = Field(examples=["admin"])
password: SecretStr | None = Field(default=None)
account: SecretStr = Field(examples=["myaccount"])
database: SecretStr = Field(examples=["mydb"])
sf_schema: SecretStr = Field(alias="schema", examples=["myschema"])
warehouse: SecretStr | None = Field(default=None)
private_key: SecretStr | None = Field(default=None)
kwargs: dict[str, str] | None = Field(default=None)
class SparkConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(examples=["15002"])
class DatabricksTokenConnectionInfo(BaseConnectionInfo):
databricks_type: Literal["token"] = "token"
server_hostname: SecretStr = Field(
alias="serverHostname", examples=["dbc-xxx.cloud.databricks.com"]
)
http_path: SecretStr = Field(alias="httpPath", examples=["/sql/1.0/warehouses/xxx"])
access_token: SecretStr = Field(alias="accessToken", examples=["dapi..."])
class DatabricksServicePrincipalConnectionInfo(BaseConnectionInfo):
databricks_type: Literal["service_principal"] = "service_principal"
server_hostname: SecretStr = Field(alias="serverHostname")
http_path: SecretStr = Field(alias="httpPath")
client_id: SecretStr = Field(alias="clientId")
client_secret: SecretStr = Field(alias="clientSecret")
azure_tenant_id: SecretStr | None = Field(alias="azureTenantId", default=None)
DatabricksConnectionUnion = Annotated[
Union[DatabricksTokenConnectionInfo, DatabricksServicePrincipalConnectionInfo],
Field(discriminator="databricks_type"),
]
class TrinoConnectionInfo(BaseConnectionInfo):
host: SecretStr = Field(examples=["localhost"])
port: SecretPort = Field(default="8080")
catalog: SecretStr = Field(examples=["hive"])
trino_schema: SecretStr = Field(alias="schema", examples=["default"])
user: SecretStr | None = Field(default=None)
password: SecretStr | None = Field(default=None)
kwargs: dict[str, str] | None = Field(default=None)
class LocalFileConnectionInfo(BaseConnectionInfo):
url: SecretStr = Field(default="/", examples=["/data"])
format: str = Field(default="csv", examples=["csv", "parquet", "json", "duckdb"])
class S3FileConnectionInfo(BaseConnectionInfo):
url: SecretStr = Field(default="/", examples=["/data"])
format: str = Field(default="csv")
bucket: SecretStr = Field(examples=["my-bucket"])
region: SecretStr = Field(examples=["us-west-2"])
access_key: SecretStr = Field(examples=["my-access-key"])
secret_key: SecretStr = Field(examples=["my-secret-key"])
class MinioFileConnectionInfo(BaseConnectionInfo):
url: SecretStr = Field(default="/", examples=["/data"])
format: str = Field(default="csv")
ssl_enabled: bool = Field(default=False)
endpoint: SecretStr = Field(examples=["localhost:9000"])
bucket: SecretStr = Field(examples=["my-bucket"])
access_key: SecretStr = Field(examples=["my-account"])
secret_key: SecretStr = Field(examples=["my-password"])
class GcsFileConnectionInfo(BaseConnectionInfo):
url: SecretStr = Field(default="/", examples=["/data"])
format: str = Field(default="csv")
bucket: SecretStr = Field(examples=["my-bucket"])
key_id: SecretStr = Field(examples=["my-key-id"])
secret_key: SecretStr = Field(examples=["my-secret-key"])
credentials: SecretStr | None = Field(default=None, examples=["eyJ..."])
class ConnectionUrl(BaseConnectionInfo):
connection_url: SecretStr = Field(alias="connectionUrl")
kwargs: dict[str, str] | None = Field(default=None)
ConnectionInfo = (
AthenaConnectionInfo
| BigQueryDatasetConnectionInfo
| BigQueryProjectConnectionInfo
| CannerConnectionInfo
| ClickHouseConnectionInfo
| ConnectionUrl
| MSSqlConnectionInfo
| MySqlConnectionInfo
| DorisConnectionInfo
| OracleConnectionInfo
| PostgresConnectionInfo
| RedshiftConnectionInfo
| RedshiftIAMConnectionInfo
| SnowflakeConnectionInfo
| SparkConnectionInfo
| DatabricksTokenConnectionInfo
| DatabricksServicePrincipalConnectionInfo
| TrinoConnectionInfo
| LocalFileConnectionInfo
| S3FileConnectionInfo
| MinioFileConnectionInfo
| GcsFileConnectionInfo
)
class SSLMode(str, Enum):
DISABLED = "disabled"
ENABLED = "enabled"
VERIFY_CA = "verify_ca"
+477
View File
@@ -0,0 +1,477 @@
from __future__ import annotations
import base64
import ssl
import urllib
from enum import Enum, StrEnum, auto
from json import loads
from typing import Any
from urllib.parse import unquote_plus
import boto3
import ibis
from ibis import BaseBackend
from wren.model import (
AthenaConnectionInfo,
BaseConnectionInfo,
BigQueryDatasetConnectionInfo,
BigQueryProjectConnectionInfo,
CannerConnectionInfo,
ClickHouseConnectionInfo,
ConnectionInfo,
ConnectionUrl,
DatabricksServicePrincipalConnectionInfo,
DatabricksTokenConnectionInfo,
DorisConnectionInfo,
GcsFileConnectionInfo,
LocalFileConnectionInfo,
MinioFileConnectionInfo,
MSSqlConnectionInfo,
MySqlConnectionInfo,
OracleConnectionInfo,
PostgresConnectionInfo,
RedshiftConnectionInfo,
RedshiftIAMConnectionInfo,
S3FileConnectionInfo,
SnowflakeConnectionInfo,
SparkConnectionInfo,
SSLMode,
TrinoConnectionInfo,
)
from wren.model.error import ErrorCode, WrenError
X_WREN_DB_STATEMENT_TIMEOUT = "x-wren-db-statement_timeout"
class DataSource(StrEnum):
athena = auto()
bigquery = auto()
canner = auto()
clickhouse = auto()
mssql = auto()
mysql = auto()
doris = auto()
oracle = auto()
postgres = auto()
redshift = auto()
snowflake = auto()
trino = auto()
local_file = auto()
s3_file = auto()
minio_file = auto()
gcs_file = auto()
duckdb = auto()
spark = auto()
databricks = auto()
def get_connection(self, info: ConnectionInfo) -> BaseBackend:
try:
return DataSourceExtension[self].get_connection(info)
except KeyError:
raise NotImplementedError(f"Unsupported data source: {self}")
def get_connection_info(
self,
data: dict[str, Any] | ConnectionInfo,
headers: dict[str, str] | None = None,
) -> ConnectionInfo:
headers = headers or {}
if isinstance(data, BaseConnectionInfo):
info = data
else:
info = self._build_connection_info(data)
match self:
case DataSource.postgres:
kwargs = info.kwargs if info.kwargs else {}
if "connect_timeout" not in kwargs:
kwargs["connect_timeout"] = 120
options = kwargs.get("options", "")
if "statement_timeout" not in options:
if options:
options += " "
options += f"-c statement_timeout={headers.get(X_WREN_DB_STATEMENT_TIMEOUT, 180)}s"
kwargs["options"] = options
info.kwargs = kwargs
case DataSource.clickhouse:
session_timeout = headers.get(X_WREN_DB_STATEMENT_TIMEOUT, 180)
if info.settings is None:
info.settings = {}
if "max_execution_time" not in info.settings:
info.settings["max_execution_time"] = int(session_timeout)
case DataSource.trino:
session_timeout = headers.get(X_WREN_DB_STATEMENT_TIMEOUT, 180)
if info.kwargs is None:
info.kwargs = {}
session_properties = info.kwargs.get("session_properties", {})
if "query_max_execution_time" not in session_properties:
session_properties["query_max_execution_time"] = (
f"{session_timeout}s"
)
info.kwargs["session_properties"] = session_properties
case DataSource.bigquery:
session_timeout = headers.get(X_WREN_DB_STATEMENT_TIMEOUT, 180)
if not hasattr(info, "job_timeout_ms") or info.job_timeout_ms is None:
info.job_timeout_ms = int(session_timeout) * 1000
return info
def _build_connection_info(self, data: dict) -> ConnectionInfo:
if "connectionUrl" in data or "connection_url" in data:
if self == DataSource.clickhouse:
return self._handle_clickhouse_url(
urllib.parse.urlparse(
data.get("connectionUrl", data.get("connection_url"))
)
)
return ConnectionUrl.model_validate(data)
match self:
case DataSource.athena:
return AthenaConnectionInfo.model_validate(data)
case DataSource.bigquery:
if "bigquery_type" in data and data["bigquery_type"] == "project":
return BigQueryProjectConnectionInfo.model_validate(data)
return BigQueryDatasetConnectionInfo.model_validate(data)
case DataSource.canner:
return CannerConnectionInfo.model_validate(data)
case DataSource.clickhouse:
return ClickHouseConnectionInfo.model_validate(data)
case DataSource.mssql:
return MSSqlConnectionInfo.model_validate(data)
case DataSource.mysql:
return MySqlConnectionInfo.model_validate(data)
case DataSource.doris:
return DorisConnectionInfo.model_validate(data)
case DataSource.oracle:
return OracleConnectionInfo.model_validate(data)
case DataSource.postgres:
return PostgresConnectionInfo.model_validate(data)
case DataSource.redshift:
if "redshift_type" in data and data["redshift_type"] == "redshift_iam":
return RedshiftIAMConnectionInfo.model_validate(data)
return RedshiftConnectionInfo.model_validate(data)
case DataSource.snowflake:
return SnowflakeConnectionInfo.model_validate(data)
case DataSource.trino:
return TrinoConnectionInfo.model_validate(data)
case DataSource.duckdb | DataSource.local_file:
return LocalFileConnectionInfo.model_validate(data)
case DataSource.s3_file:
return S3FileConnectionInfo.model_validate(data)
case DataSource.minio_file:
return MinioFileConnectionInfo.model_validate(data)
case DataSource.gcs_file:
return GcsFileConnectionInfo.model_validate(data)
case DataSource.spark:
return SparkConnectionInfo.model_validate(data)
case DataSource.databricks:
if (
"databricks_type" in data
and data["databricks_type"] == "service_principal"
):
return DatabricksServicePrincipalConnectionInfo.model_validate(data)
return DatabricksTokenConnectionInfo.model_validate(data)
case _:
raise NotImplementedError(f"Unsupported data source: {self}")
def _handle_clickhouse_url(
self, parsed: urllib.parse.ParseResult
) -> ClickHouseConnectionInfo:
if not parsed.scheme or parsed.scheme != "clickhouse":
raise WrenError(
ErrorCode.INVALID_CONNECTION_INFO,
"Invalid connection URL for ClickHouse",
)
kwargs = {}
if parsed.username:
kwargs["user"] = parsed.username
if parsed.password:
kwargs["password"] = unquote_plus(parsed.password)
if parsed.hostname:
kwargs["host"] = parsed.hostname
if parsed.port:
kwargs["port"] = str(parsed.port)
if database := parsed.path[1:]:
kwargs["database"] = database
parsed_kwargs = dict(urllib.parse.parse_qsl(parsed.query))
if "secure" in parsed_kwargs:
kwargs["secure"] = self._safe_strtobool(parsed_kwargs["secure"])
parsed_kwargs.pop("secure")
kwargs["kwargs"] = parsed_kwargs
return ClickHouseConnectionInfo(**kwargs)
def _safe_strtobool(self, val: str) -> bool:
return val.lower() in {"1", "true", "yes", "y"}
class DataSourceExtension(Enum):
athena = "athena"
bigquery = "bigquery"
canner = "canner"
clickhouse = "clickhouse"
mssql = "mssql"
mysql = "mysql"
doris = "doris"
oracle = "oracle"
postgres = "postgres"
redshift = "redshift"
snowflake = "snowflake"
trino = "trino"
local_file = "local_file"
duckdb = "duckdb"
s3_file = "s3_file"
minio_file = "minio_file"
gcs_file = "gcs_file"
databricks = "databricks"
spark = "spark"
def get_connection(self, info: ConnectionInfo) -> BaseBackend:
try:
if hasattr(info, "connection_url"):
kwargs = info.kwargs if info.kwargs else {}
return ibis.connect(info.connection_url.get_secret_value(), **kwargs)
if self.name in {"local_file", "redshift", "spark", "duckdb"}:
raise NotImplementedError(
f"{self.name} connection is not implemented to get ibis backend"
)
return getattr(self, f"get_{self.name}_connection")(info)
except KeyError:
raise NotImplementedError(f"Unsupported data source: {self}")
except WrenError:
raise
except Exception as e:
raise WrenError(ErrorCode.GET_CONNECTION_ERROR, f"{e!s}") from e
@staticmethod
def get_athena_connection(info: AthenaConnectionInfo) -> BaseBackend:
kwargs: dict[str, Any] = {
"s3_staging_dir": info.s3_staging_dir.get_secret_value(),
"schema_name": info.schema_name.get_secret_value(),
}
if info.region_name:
kwargs["region_name"] = info.region_name.get_secret_value()
if info.web_identity_token and info.role_arn:
oidc_token = info.web_identity_token.get_secret_value()
role_arn = info.role_arn.get_secret_value()
session_name = (
info.role_session_name.get_secret_value()
if info.role_session_name
else "wren-oidc-session"
)
region = info.region_name.get_secret_value() if info.region_name else None
sts = boto3.client("sts", region_name=region)
resp = sts.assume_role_with_web_identity(
RoleArn=role_arn,
RoleSessionName=session_name,
WebIdentityToken=oidc_token,
)
creds = resp["Credentials"]
kwargs["aws_access_key_id"] = creds["AccessKeyId"]
kwargs["aws_secret_access_key"] = creds["SecretAccessKey"]
kwargs["aws_session_token"] = creds["SessionToken"]
elif info.aws_access_key_id and info.aws_secret_access_key:
kwargs["aws_access_key_id"] = info.aws_access_key_id.get_secret_value()
kwargs["aws_secret_access_key"] = (
info.aws_secret_access_key.get_secret_value()
)
if info.aws_session_token:
kwargs["aws_session_token"] = info.aws_session_token.get_secret_value()
return ibis.athena.connect(**kwargs)
@staticmethod
def get_bigquery_connection(info: BigQueryDatasetConnectionInfo) -> BaseBackend:
from google.cloud import bigquery # noqa: PLC0415
from google.oauth2 import service_account # noqa: PLC0415
credits_json = loads(
base64.b64decode(info.credentials.get_secret_value()).decode("utf-8")
)
credentials = service_account.Credentials.from_service_account_info(
credits_json
)
credentials = credentials.with_scopes(
[
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/cloud-platform",
]
)
bq_client = bigquery.Client(
project=info.project_id.get_secret_value(), credentials=credentials
)
job_config = bigquery.QueryJobConfig()
job_config.job_timeout_ms = info.job_timeout_ms
bq_client.default_query_job_config = job_config
return ibis.bigquery.connect(client=bq_client, credentials=credentials)
@staticmethod
def get_canner_connection(info: CannerConnectionInfo) -> BaseBackend:
return ibis.postgres.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.workspace.get_secret_value(),
user=info.user.get_secret_value(),
password=info.pat.get_secret_value(),
)
@staticmethod
def get_clickhouse_connection(info: ClickHouseConnectionInfo) -> BaseBackend:
return ibis.clickhouse.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=(info.password and info.password.get_secret_value()),
settings=info.settings if info.settings else {},
**info.kwargs if info.kwargs else {},
)
@classmethod
def get_mssql_connection(cls, info: MSSqlConnectionInfo) -> BaseBackend:
return ibis.mssql.connect(
host=info.host.get_secret_value(),
port=info.port.get_secret_value(),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=info.password.get_secret_value(),
driver=info.driver,
TDS_Version=info.tds_version,
**info.kwargs if info.kwargs else {},
)
@classmethod
def get_mysql_connection(cls, info: MySqlConnectionInfo) -> BaseBackend:
ssl_context = cls._create_ssl_context(info)
kwargs = {"ssl": ssl_context} if ssl_context else {}
kwargs.setdefault("charset", "utf8mb4")
if info.kwargs:
kwargs.update(info.kwargs)
return ibis.mysql.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=info.password.get_secret_value() if info.password else "",
**kwargs,
)
@classmethod
def get_doris_connection(cls, info: DorisConnectionInfo) -> BaseBackend:
kwargs: dict = {}
kwargs.setdefault("charset", "utf8mb4")
if info.kwargs:
kwargs.update(info.kwargs)
connection = ibis.mysql.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=info.password.get_secret_value() if info.password else "",
**kwargs,
)
connection.con.get_autocommit = lambda: True
return connection
@staticmethod
def get_postgres_connection(info: PostgresConnectionInfo) -> BaseBackend:
return ibis.postgres.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=(info.password and info.password.get_secret_value()),
**info.kwargs if info.kwargs else {},
)
@staticmethod
def get_oracle_connection(info: OracleConnectionInfo) -> BaseBackend:
if hasattr(info, "dsn") and info.dsn:
return ibis.oracle.connect(
dsn=info.dsn.get_secret_value(),
user=info.user.get_secret_value(),
password=(info.password and info.password.get_secret_value()),
)
return ibis.oracle.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.database.get_secret_value(),
user=info.user.get_secret_value(),
password=(info.password and info.password.get_secret_value()),
)
@staticmethod
def get_snowflake_connection(info: SnowflakeConnectionInfo) -> BaseBackend:
if hasattr(info, "private_key") and info.private_key:
params = {
"user": info.user.get_secret_value(),
"private_key": info.private_key.get_secret_value(),
"account": info.account.get_secret_value(),
"database": info.database.get_secret_value(),
"schema": info.sf_schema.get_secret_value(),
}
else:
params = {
"user": info.user.get_secret_value(),
"password": info.password.get_secret_value(),
"account": info.account.get_secret_value(),
"database": info.database.get_secret_value(),
"schema": info.sf_schema.get_secret_value(),
}
if hasattr(info, "warehouse") and info.warehouse:
params["warehouse"] = info.warehouse.get_secret_value()
if info.kwargs:
params.update(info.kwargs)
return ibis.snowflake.connect(**params)
@staticmethod
def get_trino_connection(info: TrinoConnectionInfo) -> BaseBackend:
return ibis.trino.connect(
host=info.host.get_secret_value(),
port=int(info.port.get_secret_value()),
database=info.catalog.get_secret_value(),
schema=info.trino_schema.get_secret_value(),
user=(info.user and info.user.get_secret_value()),
password=(info.password and info.password.get_secret_value()),
**info.kwargs if info.kwargs else {},
)
@staticmethod
def get_databricks_connection(info: DatabricksTokenConnectionInfo) -> BaseBackend:
return ibis.databricks.connect(
server_hostname=info.server_hostname.get_secret_value(),
http_path=info.http_path.get_secret_value(),
access_token=info.access_token.get_secret_value(),
)
@staticmethod
def _create_ssl_context(info: ConnectionInfo) -> ssl.SSLContext | None:
ssl_mode = (
info.ssl_mode.get_secret_value()
if hasattr(info, "ssl_mode") and info.ssl_mode
else None
)
if ssl_mode == SSLMode.VERIFY_CA and not info.ssl_ca:
raise WrenError(
ErrorCode.INVALID_CONNECTION_INFO,
"SSL CA must be provided when SSL mode is VERIFY CA",
)
if not ssl_mode or ssl_mode == SSLMode.DISABLED:
return None
ctx = ssl.create_default_context()
ctx.check_hostname = False
if ssl_mode == SSLMode.ENABLED:
ctx.verify_mode = ssl.CERT_NONE
elif ssl_mode == SSLMode.VERIFY_CA:
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(
cadata=base64.b64decode(info.ssl_ca.get_secret_value()).decode("utf-8")
if info.ssl_ca
else None
)
return ctx
+86
View File
@@ -0,0 +1,86 @@
from datetime import datetime
from enum import Enum
from typing import Any
DIALECT_SQL = "dialectSql"
PLANNED_SQL = "plannedSql"
class ErrorCode(int, Enum):
GENERIC_USER_ERROR = 1
NOT_FOUND = 2
MDL_NOT_FOUND = 3
INVALID_SQL = 4
INVALID_MDL = 5
DUCKDB_FILE_NOT_FOUND = 6
ATTACH_DUCKDB_ERROR = 7
VALIDATION_RULE_NOT_FOUND = 8
VALIDATION_ERROR = 9
VALIDATION_PARAMETER_ERROR = 10
GET_CONNECTION_ERROR = 11
INVALID_CONNECTION_INFO = 12
GENERIC_INTERNAL_ERROR = 100
LEGACY_ENGINE_ERROR = 101
NOT_IMPLEMENTED = 102
IBIS_PROJECT_ERROR = 103
SQLGLOT_ERROR = 104
GENERIC_EXTERNAL_ERROR = 200
DATABASE_TIMEOUT = 201
class ErrorPhase(int, Enum):
REQUEST_RECEIVED = 1
MDL_EXTRACTION = 2
SQL_PARSING = 3
SQL_PLANNING = 4
SQL_TRANSPILE = 5
SQL_EXECUTION = 6
SQL_DRY_RUN = 7
RESPONSE_GENERATION = 8
METADATA_FETCHING = 9
VALIDATION = 10
SQL_SUBSTITUTE = 11
class WrenError(Exception):
error_code: ErrorCode
message: str
phase: ErrorPhase | None = None
metadata: dict[str, Any] | None = None
timestamp: str | None = None
def __init__(
self,
error_code: ErrorCode,
message: str,
phase: ErrorPhase | None = None,
metadata: dict[str, Any] | None = None,
cause: Exception | None = None,
):
self.error_code = error_code
self.message = message
self.phase = phase
self.metadata = metadata
self.timestamp = datetime.now().isoformat()
super().__init__(message)
if cause is not None:
self.__cause__ = cause
def __str__(self) -> str:
parts = [f"[{self.error_code.name}] {self.message}"]
if self.phase:
parts.append(f"phase={self.phase.name}")
return " ".join(parts)
class DatabaseTimeoutError(WrenError):
def __init__(self, message: str):
enhanced_message = (
f"{message!s}.\n"
"It seems your database is not responding or the query is taking too long. "
"Please check your database status and query performance."
)
super().__init__(
error_code=ErrorCode.DATABASE_TIMEOUT,
message=enhanced_message,
)
View File
+9
View File
@@ -0,0 +1,9 @@
"""Root pytest configuration for the wren package test suite."""
import pytest
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "unit: unit tests — no database required")
config.addinivalue_line("markers", "duckdb: DuckDB connector tests — no Docker required")
config.addinivalue_line("markers", "postgres: PostgreSQL connector tests — requires Docker")
+48
View File
@@ -0,0 +1,48 @@
"""DuckDB connector tests.
Uses DuckDB's built-in TPCH extension to generate test data — no Docker needed.
The data is written to a temp file so ``DuckDBConnector`` can attach it.
"""
from __future__ import annotations
import base64
import duckdb
import orjson
import pytest
from wren import WrenEngine
from wren.model.data_source import DataSource
from tests.suite.manifests import make_tpch_manifest
from tests.suite.query import WrenQueryTestSuite
pytestmark = pytest.mark.duckdb
# DuckDB TPCH tables live in the "main" schema of the attached file.
# The DuckDBConnector attaches the file as catalog = stem of the filename,
# so "tpch.duckdb" → catalog "tpch".
_CATALOG = "tpch"
_SCHEMA = "main"
class TestDuckDB(WrenQueryTestSuite):
manifest = make_tpch_manifest(table_catalog=_CATALOG, table_schema=_SCHEMA)
# DuckDB TPCH dbgen produces INTEGER as int64 in Arrow
order_id_dtype = "int64"
@pytest.fixture(scope="class")
def engine(self, tmp_path_factory) -> WrenEngine: # type: ignore[override]
db_dir = tmp_path_factory.mktemp("duckdb")
db_path = db_dir / "tpch.duckdb"
# Generate TPCH sf=0.01 (1500 orders, 150 customers) into the file.
con = duckdb.connect(str(db_path))
con.execute("INSTALL tpch; LOAD tpch; CALL dbgen(sf=0.01)")
con.close()
manifest_str = base64.b64encode(orjson.dumps(self.manifest)).decode()
conn_info = {"url": str(db_dir), "format": "duckdb"}
with WrenEngine(manifest_str, DataSource.duckdb, conn_info) as e:
yield e
@@ -0,0 +1,90 @@
"""PostgreSQL connector tests.
Uses testcontainers to spin up a real Postgres instance.
TPCH data is generated via DuckDB's built-in extension and loaded via psycopg.
"""
from __future__ import annotations
import base64
from urllib.parse import urlparse
import duckdb
import orjson
import psycopg
import pytest
from testcontainers.postgres import PostgresContainer
from wren import WrenEngine
from wren.model.data_source import DataSource
from tests.suite.manifests import make_tpch_manifest
from tests.suite.query import WrenQueryTestSuite
pytestmark = pytest.mark.postgres
_SCHEMA = "public"
def _load_tpch(conn_str: str) -> None:
"""Generate TPCH sf=0.01 via DuckDB and bulk-load into Postgres."""
duck = duckdb.connect()
duck.execute("INSTALL tpch; LOAD tpch; CALL dbgen(sf=0.01)")
orders_rows = duck.execute(
"SELECT o_orderkey, o_custkey, o_orderstatus, "
"cast(o_totalprice as double), o_orderdate FROM orders"
).fetchall()
customer_rows = duck.execute(
"SELECT c_custkey, c_name FROM customer"
).fetchall()
duck.close()
with psycopg.connect(conn_str) as pg:
with pg.cursor() as cur:
cur.execute("""
CREATE TABLE orders (
o_orderkey INTEGER PRIMARY KEY,
o_custkey INTEGER NOT NULL,
o_orderstatus CHAR(1) NOT NULL,
o_totalprice DOUBLE PRECISION NOT NULL,
o_orderdate DATE NOT NULL
)
""")
cur.executemany(
"INSERT INTO orders VALUES (%s, %s, %s, %s, %s)", orders_rows
)
cur.execute("""
CREATE TABLE customer (
c_custkey INTEGER PRIMARY KEY,
c_name VARCHAR(25) NOT NULL
)
""")
cur.executemany(
"INSERT INTO customer VALUES (%s, %s)", customer_rows
)
class TestPostgres(WrenQueryTestSuite):
manifest = make_tpch_manifest(table_catalog=None, table_schema=_SCHEMA)
@pytest.fixture(scope="class")
def engine(self) -> WrenEngine: # type: ignore[override]
with PostgresContainer("postgres:16") as pg:
# testcontainers returns a SQLAlchemy-style URL; psycopg wants
# the plain postgresql:// form.
url = pg.get_connection_url().replace("+psycopg2", "")
_load_tpch(url)
parsed = urlparse(url)
conn_info = {
"host": parsed.hostname,
"port": parsed.port,
"database": parsed.path.lstrip("/"),
"user": parsed.username,
"password": parsed.password,
}
manifest_str = base64.b64encode(orjson.dumps(self.manifest)).decode()
with WrenEngine(manifest_str, DataSource.postgres, conn_info) as e:
yield e
View File
+70
View File
@@ -0,0 +1,70 @@
"""Shared MDL manifest factories for connector tests."""
from __future__ import annotations
def make_tpch_manifest(table_catalog: str | None, table_schema: str) -> dict:
"""Return a minimal TPCH MDL manifest for orders + customer.
The manifest includes:
- orders model with basic TPCH columns + a calculated field
- customer model with basic TPCH columns
- a MANY_TO_ONE relationship from orders to customer
- a relationship column on orders (customer → c_name)
Args:
table_catalog: Database catalog for tableReference (None to omit).
table_schema: Database schema for tableReference (e.g. "main", "public").
"""
def table_ref(table: str) -> dict:
ref: dict = {"schema": table_schema, "table": table}
if table_catalog is not None:
ref["catalog"] = table_catalog
return ref
return {
"catalog": "wren",
"schema": "public",
"models": [
{
"name": "orders",
"tableReference": table_ref("orders"),
"columns": [
{"name": "o_orderkey", "type": "integer"},
{"name": "o_custkey", "type": "integer"},
{"name": "o_orderstatus", "type": "varchar"},
{"name": "o_totalprice", "type": "double"},
{"name": "o_orderdate", "type": "date"},
{
"name": "order_cust_key",
"type": "varchar",
"expression": "concat(cast(o_orderkey as varchar), '_', cast(o_custkey as varchar))",
},
{
"name": "customer",
"type": "customer",
"relationship": "orders_customer",
},
],
"primaryKey": "o_orderkey",
},
{
"name": "customer",
"tableReference": table_ref("customer"),
"columns": [
{"name": "c_custkey", "type": "integer"},
{"name": "c_name", "type": "varchar"},
],
"primaryKey": "c_custkey",
},
],
"relationships": [
{
"name": "orders_customer",
"models": ["orders", "customer"],
"joinType": "many_to_one",
"condition": '"orders".o_custkey = "customer".c_custkey',
}
],
}
+217
View File
@@ -0,0 +1,217 @@
"""Shared query test suite for WrenEngine connector tests.
How to add tests for a new data source
=======================================
Step 1 — Create the test file
------------------------------
Add ``tests/connectors/test_<name>.py``. Copy the skeleton below and fill in
the three required parts: pytest mark, manifest, and engine fixture.
# tests/connectors/test_clickhouse.py
import base64
import orjson
import pytest
from testcontainers.clickhouse import ClickHouseContainer
from wren import WrenEngine
from wren.model.data_source import DataSource
from tests.suite.manifests import make_tpch_manifest
from tests.suite.query import WrenQueryTestSuite
pytestmark = pytest.mark.clickhouse # (1) marker for `just test-connector clickhouse`
class TestClickHouse(WrenQueryTestSuite):
manifest = make_tpch_manifest( # (2) manifest — adjust catalog/schema
table_catalog=None, # to match where TPCH data lands
table_schema="default",
)
order_id_dtype = "int64" # (3) override any differing expectations
@pytest.fixture(scope="class")
def engine(self): # (4) engine fixture — class-scoped
with ClickHouseContainer() as ch:
_load_tpch(ch.get_connection_url())
conn_info = { ... }
manifest_str = base64.b64encode(orjson.dumps(self.manifest)).decode()
with WrenEngine(manifest_str, DataSource.clickhouse, conn_info) as e:
yield e
Step 2 — Register the pytest marker
-------------------------------------
Add one line to ``tests/conftest.py``::
config.addinivalue_line("markers", "clickhouse: ClickHouse connector tests — requires Docker")
Step 3 — Run the new tests
----------------------------
Install the connector extra if needed::
just install-extra clickhouse
Then run::
just test-connector clickhouse
All ``WrenQueryTestSuite`` tests run automatically. Any ``test_*`` methods you
add directly to ``TestClickHouse`` run alongside them.
Overridable class variables
----------------------------
Override these in the subclass to match what the connector actually returns:
order_count int Total rows in TPCH orders table (default 15000, sf=0.01)
customer_count int Total rows in TPCH customer table (default 1500, sf=0.01)
order_id_dtype str Arrow dtype string for o_orderkey (default "int32")
Example::
class TestClickHouse(WrenQueryTestSuite):
order_id_dtype = "int64" # ClickHouse INT32 → Arrow int64
Adding connector-specific tests
---------------------------------
Add ``test_*`` methods directly to the subclass — pytest discovers them
alongside all inherited tests::
class TestClickHouse(WrenQueryTestSuite):
...
def test_array_column(self, engine):
result = engine.query('SELECT array_col FROM "orders" LIMIT 1')
assert result.num_rows == 1
Sharing tests across a subset of connectors (mix-ins)
-------------------------------------------------------
For capabilities shared by *some* connectors (e.g. timezone, window functions),
define a separate mix-in class and include it only where relevant::
# tests/suite/timezone.py
class TimezoneTestSuite:
def test_timestamptz(self, engine): ...
# tests/connectors/test_postgres.py
class TestPostgres(WrenQueryTestSuite, TimezoneTestSuite):
... # gets core tests + timezone tests
# tests/connectors/test_duckdb.py
class TestDuckDB(WrenQueryTestSuite):
... # gets only core tests
"""
from __future__ import annotations
import base64
from typing import ClassVar
import orjson
import pyarrow as pa
import pytest
from wren import WrenEngine
from wren.model.error import WrenError
class WrenQueryTestSuite:
"""Abstract base class providing shared query tests for all connectors.
Each subclass must:
- Set ``manifest`` to a connector-appropriate MDL dict.
- Provide a class-scoped ``engine`` fixture returning a ``WrenEngine``.
"""
# Subclass must set this
manifest: ClassVar[dict]
# Overridable expectations — connectors may differ on counts or dtypes
order_count: ClassVar[int] = 15000 # TPCH sf=0.01
customer_count: ClassVar[int] = 1500 # TPCH sf=0.01
order_id_dtype: ClassVar[str] = "int32" # Postgres INTEGER → Arrow int32
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@pytest.fixture(scope="class")
def manifest_str(self) -> str:
return base64.b64encode(orjson.dumps(self.manifest)).decode()
# ------------------------------------------------------------------
# Query execution tests
# ------------------------------------------------------------------
def test_basic_select(self, engine: WrenEngine) -> None:
result = engine.query(
'SELECT o_orderkey, o_custkey, o_orderstatus FROM "orders" ORDER BY o_orderkey LIMIT 1'
)
assert isinstance(result, pa.Table)
assert result.num_rows == 1
assert result.column_names == ["o_orderkey", "o_custkey", "o_orderstatus"]
# TPCH sf=0.01: first order row (orderkey=1)
assert result["o_orderkey"][0].as_py() == 1
def test_count(self, engine: WrenEngine) -> None:
result = engine.query('SELECT COUNT(*) AS cnt FROM "orders"')
assert result["cnt"][0].as_py() == self.order_count
def test_query_with_limit(self, engine: WrenEngine) -> None:
# engine.query limit= parameter truncates the result
result = engine.query('SELECT o_orderkey FROM "orders" ORDER BY o_orderkey', limit=3)
assert result.num_rows == 3
def test_calculated_field(self, engine: WrenEngine) -> None:
result = engine.query(
'SELECT o_orderkey, o_custkey, order_cust_key FROM "orders" ORDER BY o_orderkey LIMIT 1'
)
assert result.num_rows == 1
orderkey = result["o_orderkey"][0].as_py()
custkey = result["o_custkey"][0].as_py()
calc = result["order_cust_key"][0].as_py()
assert calc == f"{orderkey}_{custkey}"
def test_explicit_join(self, engine: WrenEngine) -> None:
result = engine.query(
"""
SELECT o.o_orderkey, c.c_name
FROM "orders" o
JOIN "customer" c ON o.o_custkey = c.c_custkey
ORDER BY o.o_orderkey
LIMIT 5
"""
)
assert result.num_rows == 5
assert "o_orderkey" in result.column_names
assert "c_name" in result.column_names
def test_order_id_dtype(self, engine: WrenEngine) -> None:
result = engine.query('SELECT o_orderkey FROM "orders" LIMIT 1')
field = result.schema.field("o_orderkey")
assert str(field.type) == self.order_id_dtype
# ------------------------------------------------------------------
# Dry run tests
# ------------------------------------------------------------------
def test_dry_run_valid(self, engine: WrenEngine) -> None:
# Should not raise
engine.dry_run('SELECT * FROM "orders" LIMIT 1')
def test_dry_run_invalid_table(self, engine: WrenEngine) -> None:
with pytest.raises(WrenError):
engine.dry_run('SELECT * FROM "NotFound"')
# ------------------------------------------------------------------
# Transpile / dry-plan tests (no DB access)
# ------------------------------------------------------------------
def test_transpile_returns_sql(self, engine: WrenEngine) -> None:
sql = engine.transpile('SELECT o_orderkey FROM "orders" LIMIT 1')
assert isinstance(sql, str)
assert len(sql) > 0
def test_dry_plan_returns_datafusion_sql(self, engine: WrenEngine) -> None:
planned = engine.dry_plan('SELECT o_orderkey FROM "orders" LIMIT 1')
assert isinstance(planned, str)
assert len(planned) > 0
# dry_plan returns wren-core / DataFusion SQL, not dialect SQL
assert "orders" in planned.lower()
View File
+127
View File
@@ -0,0 +1,127 @@
"""Unit tests for WrenEngine — no database required.
transpile() and dry_plan() exercise the wren-core MDL planning + sqlglot
transpile path without connecting to any data source.
"""
from __future__ import annotations
import base64
import math
import orjson
import pytest
from wren import WrenEngine
from wren.model.data_source import DataSource
from wren.model.error import WrenError
pytestmark = pytest.mark.unit
# Minimal manifest with a single model. No real DB needed for planning.
_MANIFEST = {
"catalog": "wren",
"schema": "public",
"models": [
{
"name": "orders",
"tableReference": {"schema": "main", "table": "orders"},
"columns": [
{"name": "o_orderkey", "type": "integer"},
{"name": "o_custkey", "type": "integer"},
{"name": "o_orderstatus", "type": "varchar"},
{
"name": "order_cust_key",
"type": "varchar",
"expression": "concat(cast(o_orderkey as varchar), '_', cast(o_custkey as varchar))",
},
],
"primaryKey": "o_orderkey",
}
],
}
_MANIFEST_STR = base64.b64encode(orjson.dumps(_MANIFEST)).decode()
@pytest.fixture(scope="module")
def duckdb_engine(tmp_path_factory):
"""A WrenEngine pointed at a temporary DuckDB file (not queried by unit tests)."""
db_dir = tmp_path_factory.mktemp("unit_duckdb")
conn_info = {"url": str(db_dir), "format": "duckdb"}
with WrenEngine(_MANIFEST_STR, DataSource.duckdb, conn_info) as e:
yield e
@pytest.fixture(scope="module")
def pg_engine():
"""A WrenEngine configured for Postgres (no real connection opened for planning)."""
conn_info = {
"host": "localhost",
"port": 5432,
"database": "test",
"user": "test",
"password": "test",
}
with WrenEngine(_MANIFEST_STR, DataSource.postgres, conn_info) as e:
yield e
# ------------------------------------------------------------------
# transpile
# ------------------------------------------------------------------
def test_transpile_returns_string(duckdb_engine: WrenEngine) -> None:
sql = duckdb_engine.transpile('SELECT o_orderkey FROM "orders" LIMIT 1')
assert isinstance(sql, str)
assert len(sql) > 0
def test_transpile_postgres_dialect(pg_engine: WrenEngine) -> None:
"""Transpile should produce Postgres-flavoured SQL (no backtick quoting, etc.)."""
sql = pg_engine.transpile('SELECT o_orderkey FROM "orders" LIMIT 1')
assert isinstance(sql, str)
# sqlglot Postgres output uses double-quote identifiers, not backticks
assert "`" not in sql
def test_transpile_calculated_field(duckdb_engine: WrenEngine) -> None:
sql = duckdb_engine.transpile('SELECT order_cust_key FROM "orders" LIMIT 1')
assert isinstance(sql, str)
# The calculated column expression should be expanded in the SQL
assert "concat" in sql.lower() or "||" in sql.lower()
def test_transpile_invalid_sql_raises(duckdb_engine: WrenEngine) -> None:
with pytest.raises(WrenError):
duckdb_engine.transpile("SELECT * FROM not_a_model_in_manifest")
# ------------------------------------------------------------------
# dry_plan
# ------------------------------------------------------------------
def test_dry_plan_returns_datafusion_sql(duckdb_engine: WrenEngine) -> None:
planned = duckdb_engine.dry_plan('SELECT o_orderkey FROM "orders" LIMIT 1')
assert isinstance(planned, str)
assert len(planned) > 0
def test_dry_plan_invalid_sql_raises(duckdb_engine: WrenEngine) -> None:
with pytest.raises(WrenError):
duckdb_engine.dry_plan("SELECT * FROM nonexistent_model")
# ------------------------------------------------------------------
# Context manager
# ------------------------------------------------------------------
def test_context_manager_closes_connector() -> None:
conn_info = {"url": "/tmp", "format": "duckdb"}
with WrenEngine(_MANIFEST_STR, DataSource.duckdb, conn_info) as e:
assert e._connector is None # connector is lazily initialized
# After __exit__, internal state is cleaned up
assert e._connector is None
+2499
View File
File diff suppressed because it is too large Load Diff