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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

* Add `Client.upload` with typed upload modes and support for file streams.

### Fixed

* Require `primary_key` for upserts and stop sending the unsupported `mode` query parameter.

## [0.3.1](https://github.com/altertable-ai/altertable-lakehouse-python/compare/altertable-lakehouse-v0.3.0...altertable-lakehouse-v0.3.1) (2026-06-30)


Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,29 @@ if res.task_id:
### Upsert

```python
from altertable_lakehouse.models import UpsertMode

with open("data.csv", "rb") as f:
client.upsert(
catalog="my_cat",
schema="my_schema",
table="my_table",
mode=UpsertMode.APPEND,
content=f.read()
catalog="my_cat",
schema="my_schema",
table="my_table",
primary_key="id",
content=f.read(),
)
```

### Upload

```python
from altertable_lakehouse.models import UploadMode

with open("data.csv", "rb") as f:
client.upload(
catalog="my_cat",
schema="my_schema",
table="my_table",
mode=UploadMode.CREATE,
content=f,
content_type="text/csv",
)
```

Expand Down
40 changes: 32 additions & 8 deletions src/altertable_lakehouse/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import base64
import ssl
import httpx
from typing import Any, Iterator, Optional, Union, Tuple, NoReturn
from typing import Any, BinaryIO, Iterable, Iterator, Optional, Union, Tuple, NoReturn
from .models import (
AppendRequestSingle,
AppendRequestBatch,
Expand All @@ -16,9 +16,9 @@
ValidateResponse,
AutocompleteRequest,
AutocompleteResponse,
UpsertMode,
QueryMetadata,
QueryResult,
UploadMode,
)
from .errors import (
AuthError,
Expand Down Expand Up @@ -124,18 +124,14 @@ def upsert(
schema: str,
table: str,
content: bytes,
mode: Optional[UpsertMode] = None,
primary_key: Optional[str] = None,
primary_key: str,
) -> None:
params = {
"catalog": catalog,
"schema": schema,
"table": table,
"primary_key": primary_key,
}
if mode is not None:
params["mode"] = mode.value
if primary_key:
params["primary_key"] = primary_key
try:
res = self._client.post(
"/upsert",
Expand All @@ -146,6 +142,34 @@ def upsert(
except httpx.RequestError as e:
self._handle_error(e)

def upload(
self,
catalog: str,
schema: str,
table: str,
mode: UploadMode,
content: Union[bytes, BinaryIO, Iterable[bytes]],
content_type: Optional[str] = None,
) -> None:
"""Upload CSV, JSON, or Parquet bytes using the requested table mode."""
params = {
"catalog": catalog,
"schema": schema,
"table": table,
"mode": mode.value,
}
headers = {"Content-Type": content_type} if content_type else None
try:
res = self._client.post(
"/upload",
params=params,
content=content,
headers=headers,
)
self._check_response(res)
except httpx.RequestError as e:
self._handle_error(e)

def get_query(self, query_id: str) -> QueryLogResponse:
try:
res = self._client.get(f"/query/{query_id}")
Expand Down
3 changes: 1 addition & 2 deletions src/altertable_lakehouse/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ class ComputeSize(str, Enum):
XL = "XL"


class UpsertMode(str, Enum):
class UploadMode(str, Enum):
CREATE = "create"
APPEND = "append"
UPSERT = "upsert"
OVERWRITE = "overwrite"


Expand Down
96 changes: 91 additions & 5 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# type: ignore
import os
import ssl
from io import BytesIO
import pytest
import httpx
from testcontainers.core.container import DockerContainer
Expand Down Expand Up @@ -45,11 +46,96 @@ def test_query_all(client):
assert isinstance(res.columns, list)
assert isinstance(res.rows, list)

def test_upsert(client):
try:
client.upsert(catalog="cat", schema="sch", table="tbl", mode=models.UpsertMode.APPEND, content=b'{"a":1}')
except errors.BadRequestError:
pass
def test_upsert_sends_primary_key_without_unsupported_mode(client):
captured = {}

def handler(request):
captured["params"] = dict(request.url.params)
return httpx.Response(204, request=request)

client._client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)

client.upsert(
catalog="cat",
schema="sch",
table="tbl",
primary_key="id",
content=b'{"id":1}',
)

assert captured["params"] == {
"catalog": "cat",
"schema": "sch",
"table": "tbl",
"primary_key": "id",
}
assert "mode" not in captured["params"]


def test_upsert_requires_primary_key(client):
with pytest.raises(TypeError, match="primary_key"):
client.upsert(catalog="cat", schema="sch", table="tbl", content=b'{"id":1}')


def test_upload_sends_required_parameters_and_content_type(client):
captured = {}

def handler(request):
captured["params"] = dict(request.url.params)
captured["content_type"] = request.headers.get("content-type")
captured["content"] = request.content
return httpx.Response(200, request=request)

client._client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)

client.upload(
catalog="cat",
schema="sch",
table="tbl",
mode=models.UploadMode.CREATE,
content=BytesIO(b"id,name\n1,Alice\n"),
content_type="text/csv",
)

assert captured["params"] == {
"catalog": "cat",
"schema": "sch",
"table": "tbl",
"mode": "create",
}
assert captured["content_type"] == "text/csv"
assert captured["content"] == b"id,name\n1,Alice\n"


def test_upload_omits_content_type_and_surfaces_api_errors(client):
captured = {}

def handler(request):
captured["content_type"] = request.headers.get("content-type")
return httpx.Response(400, text="invalid upload", request=request)

client._client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(handler),
)

with pytest.raises(errors.BadRequestError, match="invalid upload"):
client.upload(
catalog="cat",
schema="sch",
table="tbl",
mode=models.UploadMode.APPEND,
content=b'{"id":1}',
)

assert captured["content_type"] is None


def test_append(client):
try:
Expand Down
Loading