Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ jobs:
python-version: "3.14"
cache: "poetry"
- name: Install dependencies
run: poetry install
run: poetry install --extras ibis
- name: Run Ruff
run: poetry run ruff check src tests
run: |
poetry run ruff check src tests examples
poetry run ruff check --extend-select C901 src/altertable_lakehouse/ibis.py
- name: Run Mypy
run: poetry run mypy src tests

Expand Down Expand Up @@ -59,7 +61,12 @@ jobs:
cache: "poetry"

- name: Install dependencies
run: poetry install
run: |
if [ "${{ matrix.python-version }}" = "3.9" ]; then
poetry install
else
poetry install --extras ibis
fi

- name: Run tests
run: poetry run pytest
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file.

### Added

* Add an optional Ibis backend with native Parquet results through `Client.query_parquet`.
* Surface errors received during NDJSON query streaming as `QueryError`.
* Add `Client.upload` with typed upload modes and support for file streams.

### Fixed
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ Official Python SDK for the Altertable Lakehouse API.
pip install altertable-lakehouse
```

### Ibis

```bash
pip install 'altertable-lakehouse[ibis]'
```

```python
import ibis

con = ibis.altertable.connect(
username="your_username", password="your_password",
catalog="my_catalog", database="my_schema",
)
orders = con.table("orders")
totals = orders.group_by("customer_id").aggregate(total=orders.amount.sum())
print(totals.execute())
con.disconnect()
```

`database` means schema. Queries run remotely; results are buffered in memory.
Writes use the SDK directly. See the [runnable example](examples/ibis_queries.py).

## Usage

### Initialization
Expand Down
26 changes: 26 additions & 0 deletions examples/ibis_queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os

import ibis


def main() -> None:
connection = ibis.altertable.connect(
base_url=os.environ.get("ALTERTABLE_BASE_URL", "https://api.altertable.ai")
)
try:
orders = connection.sql(
"SELECT * FROM (VALUES ('alice', 10), ('alice', 20), ('bob', 5)) "
"AS orders(customer, amount)"
)
totals = orders.group_by("customer").aggregate(total=orders.amount.sum())
minimum = ibis.param("int64")
result = totals.filter(totals.total >= minimum).order_by("customer")
print(connection.compile(result, params={minimum: 10}))
print(result.execute(params={minimum: 10}))
print(result.to_pyarrow(params={minimum: 10}))
finally:
connection.disconnect()


if __name__ == "__main__":
main()
666 changes: 664 additions & 2 deletions poetry.lock

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,30 @@ python = "^3.9"
httpx = "^0.28.1"
dataclasses-json = "^0.6.7"
pydantic = "^2.12.5"
ibis-framework = {version = ">=12,<13", python = ">=3.10", optional = true}
pandas = {version = ">=2.2,<4", python = ">=3.10", optional = true}
pyarrow = {version = ">=18", python = ">=3.10", optional = true}
pyarrow-hotfix = {version = ">=0.4", python = ">=3.10", optional = true}

[tool.poetry.extras]
ibis = ["ibis-framework", "pandas", "pyarrow", "pyarrow-hotfix"]

[tool.poetry.plugins."ibis.backends"]
altertable = "altertable_lakehouse.ibis"

[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
ruff = "^0.3.0"
mypy = "^1.8.0"
testcontainers = "<4.0"

[[tool.mypy.overrides]]
module = ["ibis.*", "pandas.*", "pyarrow.*"]
ignore_missing_imports = true

[tool.ruff.lint.mccabe]
max-complexity = 10

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
26 changes: 25 additions & 1 deletion src/altertable_lakehouse/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
NetworkError,
TimeoutError,
ParseError,
QueryError,
ApiError,
ConfigurationError,
AltertableLakehouseError,
Expand Down Expand Up @@ -227,9 +228,12 @@ def next_item() -> Optional[Any]:
continue
line_index += 1
try:
return json.loads(line)
item = json.loads(line)
except json.JSONDecodeError as exc:
raise ParseError("Failed to parse NDJSON line", line_index, line) from exc
if line_index > 1 and isinstance(item, dict) and isinstance(item.get("error"), str):
raise QueryError(item["error"], line_index)
return item
return None

first_item = next_item()
Expand Down Expand Up @@ -261,6 +265,8 @@ def row_iter() -> Iterator[Any]:
if item is None:
break
yield item
except httpx.RequestError as e:
self._handle_error(e)
finally:
res.close()

Expand All @@ -279,3 +285,21 @@ def query_all(self, request: QueryRequest) -> QueryResult:
metadata, columns, iterator = self.query(request)
rows = list(iterator)
return QueryResult(metadata=metadata, columns=columns, rows=rows)

def query_parquet(self, request: QueryRequest) -> bytes:
payload = request.model_dump(exclude_none=True, by_alias=True)
payload["format"] = "parquet"
try:
response = self._client.post(
"/query", json=payload, headers={"Accept": "application/parquet"}
)
self._check_response(response)
content_type = response.headers.get("content-type", "").split(";")[0].strip()
if content_type != "application/parquet":
raise ApiError(
f"Expected Parquet query results, received {content_type!r}",
response.status_code,
)
return response.content
except httpx.RequestError as exc:
self._handle_error(exc)
8 changes: 8 additions & 0 deletions src/altertable_lakehouse/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ class BadRequestError(ApiError):

class ConfigurationError(AltertableLakehouseError):
pass


class QueryError(AltertableLakehouseError):
"""A query failed after the server started its NDJSON response."""

def __init__(self, message: str, line_index: int):
super().__init__(f"{message} at line {line_index}")
self.line_index = line_index
Loading
Loading