Skip to content

Commit be466dc

Browse files
feat: implement altertable-lakehouse SDK based on v0.9.0 specs
1 parent e6de08a commit be466dc

11 files changed

Lines changed: 1592 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ jobs:
3636
altertable:
3737
image: ghcr.io/altertable-ai/altertable-mock:latest
3838
ports:
39-
- 15001:15001
39+
- 15000:15000
4040
env:
41-
ALTERTABLE_MOCK_API_KEYS: test_pk_abc123
41+
ALTERTABLE_MOCK_USERS: testuser:testpass
4242
options: >-
4343
--health-cmd "exit 0"
4444
--health-interval 5s

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [0.1.0] - 2026-03-09
6+
### Added
7+
- Initial release of the Python SDK for the Altertable Lakehouse API.
8+
- Implemented `append`, `upload`, `query`, `query_all`, `validate`, `get_query`, and `cancel_query` methods.
9+
- Full typing support with `pydantic` models.

README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Altertable Lakehouse Python SDK
2+
3+
Official Python SDK for the Altertable Lakehouse API.
4+
5+
## Installation
6+
7+
```bash
8+
pip install altertable-lakehouse
9+
```
10+
11+
## Usage
12+
13+
### Initialization
14+
15+
```python
16+
from altertable_lakehouse import Client
17+
18+
client = Client(username="your_username", password="your_password")
19+
```
20+
21+
### Querying
22+
23+
```python
24+
from altertable_lakehouse.models import QueryRequest
25+
26+
# Stream rows (good for large datasets)
27+
req = QueryRequest(statement="SELECT * FROM my_table")
28+
metadata, row_iterator = client.query(req)
29+
for row in row_iterator:
30+
print(row)
31+
32+
# Accumulate all rows in memory
33+
result = client.query_all(req)
34+
print(result.rows)
35+
```
36+
37+
### Append
38+
39+
```python
40+
from altertable_lakehouse.models import AppendRequestSingle, AppendPayload
41+
42+
req = AppendRequestSingle(Single=AppendPayload(data={"col1": "val1"}))
43+
res = client.append(catalog="my_cat", schema="my_schema", table="my_table", data=req)
44+
print(res.ok)
45+
```
46+
47+
### Upload
48+
49+
```python
50+
from altertable_lakehouse.models import UploadFormat, UploadMode
51+
52+
with open("data.csv", "rb") as f:
53+
client.upload(
54+
catalog="my_cat",
55+
schema="my_schema",
56+
table="my_table",
57+
format=UploadFormat.CSV,
58+
mode=UploadMode.APPEND,
59+
content=f.read()
60+
)
61+
```
62+
63+
### Validate Query
64+
65+
```python
66+
res = client.validate("SELECT * FROM non_existent")
67+
print(res.valid)
68+
print(res.connections_errors)
69+
```
70+
71+
### Query Log & Cancellation
72+
73+
```python
74+
# Get query status
75+
log_res = client.get_query("query_uuid_here")
76+
print(log_res.progress)
77+
78+
# Cancel a query
79+
cancel_res = client.cancel_query("query_uuid_here", "session_id_here")
80+
print(cancel_res.cancelled)
81+
```

poetry.lock

Lines changed: 1117 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ packages = [{include = "altertable_lakehouse", from = "src"}]
88

99
[tool.poetry.dependencies]
1010
python = "^3.9"
11+
httpx = "^0.28.1"
12+
dataclasses-json = "^0.6.7"
13+
pydantic = "^2.12.5"
1114

1215
[tool.poetry.group.dev.dependencies]
1316
pytest = "^8.0.0"
1417
ruff = "^0.3.0"
1518
mypy = "^1.8.0"
19+
testcontainers = "<4.0"
1620

1721
[build-system]
1822
requires = ["poetry-core"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .client import Client
2+
from . import models
3+
from . import errors
4+
5+
__all__ = ["Client", "models", "errors"]

src/altertable_lakehouse/client.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import os
2+
import json
3+
import base64
4+
import httpx
5+
from typing import Any, Dict, Iterator, Optional, Union, Tuple, NoReturn
6+
from .models import (
7+
AppendRequestSingle, AppendRequestBatch, AppendResponse,
8+
QueryRequest, QueryLogResponse, CancelQueryResponse,
9+
ValidateResponse, UploadFormat, UploadMode,
10+
QueryMetadata, QueryResult
11+
)
12+
from .errors import (
13+
AuthError, BadRequestError, NetworkError, TimeoutError,
14+
ParseError, ApiError, ConfigurationError,
15+
AltertableLakehouseError
16+
)
17+
18+
class Client:
19+
def __init__(
20+
self,
21+
base_url: str = "https://api.altertable.ai",
22+
username: Optional[str] = None,
23+
password: Optional[str] = None,
24+
token: Optional[str] = None,
25+
timeout: float = 30.0,
26+
user_agent_suffix: Optional[str] = None
27+
):
28+
self.base_url = base_url.rstrip("/")
29+
self.timeout = timeout
30+
31+
auth_token = None
32+
if token:
33+
auth_token = token
34+
elif username and password:
35+
auth_token = base64.b64encode(f"{username}:{password}".encode()).decode()
36+
elif "ALTERTABLE_BASIC_AUTH_TOKEN" in os.environ:
37+
auth_token = os.environ["ALTERTABLE_BASIC_AUTH_TOKEN"]
38+
elif "ALTERTABLE_USERNAME" in os.environ and "ALTERTABLE_PASSWORD" in os.environ:
39+
u = os.environ["ALTERTABLE_USERNAME"]
40+
p = os.environ["ALTERTABLE_PASSWORD"]
41+
auth_token = base64.b64encode(f"{u}:{p}".encode()).decode()
42+
43+
if not auth_token:
44+
raise ConfigurationError("No credentials provided.")
45+
46+
ua = "altertable-lakehouse-python/0.1.0"
47+
if user_agent_suffix:
48+
ua += f" {user_agent_suffix}"
49+
50+
self._client = httpx.Client(
51+
base_url=self.base_url,
52+
timeout=self.timeout,
53+
headers={
54+
"Authorization": f"Basic {auth_token}",
55+
"User-Agent": ua
56+
}
57+
)
58+
59+
def _handle_error(self, e: Exception) -> NoReturn:
60+
if isinstance(e, httpx.TimeoutException):
61+
raise TimeoutError("Request timed out", e)
62+
if isinstance(e, httpx.RequestError):
63+
raise NetworkError(f"Network error: {str(e)}", e)
64+
raise AltertableLakehouseError(f"Unexpected error: {str(e)}", e)
65+
66+
def _check_response(self, response: httpx.Response) -> None:
67+
if response.is_success:
68+
return
69+
if response.status_code == 401:
70+
raise AuthError("Unauthorized", response.status_code)
71+
if response.status_code == 400:
72+
raise BadRequestError(response.text, response.status_code)
73+
raise ApiError(response.text, response.status_code)
74+
75+
def append(self, catalog: str, schema: str, table: str, data: Union[AppendRequestSingle, AppendRequestBatch, Dict[str, Any]]) -> AppendResponse:
76+
try:
77+
payload = data.model_dump() if hasattr(data, "model_dump") else data
78+
res = self._client.post(
79+
"/append",
80+
params={"catalog": catalog, "schema": schema, "table": table},
81+
json=payload
82+
)
83+
self._check_response(res)
84+
return AppendResponse(**res.json())
85+
except httpx.RequestError as e:
86+
self._handle_error(e)
87+
88+
def upload(self, catalog: str, schema: str, table: str, format: UploadFormat, mode: UploadMode, content: bytes, primary_key: Optional[str] = None) -> None:
89+
params = {
90+
"catalog": catalog,
91+
"schema": schema,
92+
"table": table,
93+
"format": format.value,
94+
"mode": mode.value
95+
}
96+
if primary_key:
97+
params["primary_key"] = primary_key
98+
try:
99+
res = self._client.post(
100+
"/upload",
101+
params=params,
102+
content=content,
103+
headers={"Content-Type": "application/octet-stream"}
104+
)
105+
self._check_response(res)
106+
except httpx.RequestError as e:
107+
self._handle_error(e)
108+
109+
def get_query(self, query_id: str) -> QueryLogResponse:
110+
try:
111+
res = self._client.get(f"/query/{query_id}")
112+
self._check_response(res)
113+
return QueryLogResponse(**res.json())
114+
except httpx.RequestError as e:
115+
self._handle_error(e)
116+
117+
def cancel_query(self, query_id: str, session_id: str) -> CancelQueryResponse:
118+
try:
119+
res = self._client.delete(f"/query/{query_id}", params={"session_id": session_id})
120+
self._check_response(res)
121+
return CancelQueryResponse(**res.json())
122+
except httpx.RequestError as e:
123+
self._handle_error(e)
124+
125+
def validate(self, statement: str) -> ValidateResponse:
126+
try:
127+
res = self._client.post("/validate", json={"statement": statement})
128+
self._check_response(res)
129+
return ValidateResponse(**res.json())
130+
except httpx.RequestError as e:
131+
self._handle_error(e)
132+
133+
def query(self, request: QueryRequest) -> Tuple[QueryMetadata, Iterator[Dict[str, Any]]]:
134+
payload = request.model_dump(exclude_none=True)
135+
try:
136+
req = self._client.build_request(
137+
"POST",
138+
"/query",
139+
json=payload,
140+
headers={"Accept": "application/x-ndjson"}
141+
)
142+
res = self._client.send(req, stream=True)
143+
self._check_response(res)
144+
145+
def parse_stream() -> Iterator[Dict[str, Any]]:
146+
line_index = 0
147+
for line in res.iter_lines():
148+
if not line.strip():
149+
continue
150+
try:
151+
yield json.loads(line)
152+
except json.JSONDecodeError as exc:
153+
raise ParseError("Failed to parse NDJSON line", line_index, line) from exc
154+
line_index += 1
155+
156+
iterator = parse_stream()
157+
metadata = QueryMetadata()
158+
159+
first = next(iterator, None)
160+
if first and "metadata" in first:
161+
metadata.stats = first.get("metadata", {})
162+
second = next(iterator, None)
163+
if second and "columns" in second:
164+
metadata.columns = second.get("columns", [])
165+
else:
166+
def row_generator1(first_row: Any, iter_obj: Iterator[Any]) -> Iterator[Any]:
167+
if first_row:
168+
yield first_row
169+
yield from iter_obj
170+
return metadata, row_generator1(second, iterator)
171+
else:
172+
def row_generator2(first_row: Any, iter_obj: Iterator[Any]) -> Iterator[Any]:
173+
if first_row:
174+
yield first_row
175+
yield from iter_obj
176+
return metadata, row_generator2(first, iterator)
177+
178+
return metadata, iterator
179+
180+
except httpx.RequestError as e:
181+
self._handle_error(e)
182+
183+
def query_all(self, request: QueryRequest) -> QueryResult:
184+
metadata, iterator = self.query(request)
185+
rows = list(iterator)
186+
return QueryResult(metadata=metadata, rows=rows)

src/altertable_lakehouse/errors.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Optional
2+
3+
class AltertableLakehouseError(Exception):
4+
def __init__(self, message: str, cause: Optional[Exception] = None):
5+
super().__init__(message)
6+
self.cause = cause
7+
8+
class NetworkError(AltertableLakehouseError):
9+
pass
10+
11+
class TimeoutError(AltertableLakehouseError):
12+
pass
13+
14+
class SerializationError(AltertableLakehouseError):
15+
pass
16+
17+
class ParseError(AltertableLakehouseError):
18+
def __init__(self, message: str, line_index: int, raw_content: str):
19+
super().__init__(f"{message} at line {line_index}: {raw_content}")
20+
self.line_index = line_index
21+
self.raw_content = raw_content
22+
23+
class ApiError(AltertableLakehouseError):
24+
def __init__(self, message: str, status_code: int):
25+
super().__init__(f"{status_code}: {message}")
26+
self.status_code = status_code
27+
28+
class AuthError(ApiError):
29+
pass
30+
31+
class BadRequestError(ApiError):
32+
pass
33+
34+
class ConfigurationError(AltertableLakehouseError):
35+
pass

0 commit comments

Comments
 (0)