From 6656e2d15436e39e9438987938cbee0613028ff3 Mon Sep 17 00:00:00 2001 From: Albert Date: Fri, 24 Jul 2026 00:09:32 +0000 Subject: [PATCH 1/2] fix(api): align upsert query parameters with lakehouse spec --- CHANGELOG.md | 6 +++++ README.md | 12 ++++------ src/altertable_lakehouse/client.py | 9 ++------ src/altertable_lakehouse/models.py | 7 ------ tests/test_client.py | 37 ++++++++++++++++++++++++++---- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8994a2..3533fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### 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) diff --git a/README.md b/README.md index bd085a9..be81cca 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,13 @@ 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(), ) ``` diff --git a/src/altertable_lakehouse/client.py b/src/altertable_lakehouse/client.py index 8f17153..3c633b8 100644 --- a/src/altertable_lakehouse/client.py +++ b/src/altertable_lakehouse/client.py @@ -16,7 +16,6 @@ ValidateResponse, AutocompleteRequest, AutocompleteResponse, - UpsertMode, QueryMetadata, QueryResult, ) @@ -124,18 +123,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", diff --git a/src/altertable_lakehouse/models.py b/src/altertable_lakehouse/models.py index 2c4c950..f23b4ed 100644 --- a/src/altertable_lakehouse/models.py +++ b/src/altertable_lakehouse/models.py @@ -11,13 +11,6 @@ class ComputeSize(str, Enum): XL = "XL" -class UpsertMode(str, Enum): - CREATE = "create" - APPEND = "append" - UPSERT = "upsert" - OVERWRITE = "overwrite" - - class TaskStatus(str, Enum): PENDING = "pending" COMPLETED = "completed" diff --git a/tests/test_client.py b/tests/test_client.py index 6c38d74..f4a74d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -45,11 +45,38 @@ 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_append(client): try: From 50e63ae07210fe0b3cf787455ad37a6109d4e1d9 Mon Sep 17 00:00:00 2001 From: Albert Date: Fri, 24 Jul 2026 06:41:58 +0000 Subject: [PATCH 2/2] feat(api): add upload endpoint --- CHANGELOG.md | 4 ++ README.md | 16 ++++++++ src/altertable_lakehouse/client.py | 31 +++++++++++++++- src/altertable_lakehouse/models.py | 6 +++ tests/test_client.py | 59 ++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3533fe1..a7ea033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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. diff --git a/README.md b/README.md index be81cca..cfffbd1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,22 @@ with open("data.csv", "rb") as f: ) ``` +### 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", + ) +``` + ### Validate Query ```python diff --git a/src/altertable_lakehouse/client.py b/src/altertable_lakehouse/client.py index 3c633b8..d0b5d08 100644 --- a/src/altertable_lakehouse/client.py +++ b/src/altertable_lakehouse/client.py @@ -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, @@ -18,6 +18,7 @@ AutocompleteResponse, QueryMetadata, QueryResult, + UploadMode, ) from .errors import ( AuthError, @@ -141,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}") diff --git a/src/altertable_lakehouse/models.py b/src/altertable_lakehouse/models.py index f23b4ed..65c8deb 100644 --- a/src/altertable_lakehouse/models.py +++ b/src/altertable_lakehouse/models.py @@ -11,6 +11,12 @@ class ComputeSize(str, Enum): XL = "XL" +class UploadMode(str, Enum): + CREATE = "create" + APPEND = "append" + OVERWRITE = "overwrite" + + class TaskStatus(str, Enum): PENDING = "pending" COMPLETED = "completed" diff --git a/tests/test_client.py b/tests/test_client.py index f4a74d3..a7de524 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ # type: ignore import os import ssl +from io import BytesIO import pytest import httpx from testcontainers.core.container import DockerContainer @@ -78,6 +79,64 @@ 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: client.append(catalog="cat", schema="sch", table="tbl", data={"a": 1}, sync=False)