diff --git a/CHANGELOG.md b/CHANGELOG.md index e8994a2..a7ea033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index bd085a9..cfffbd1 100644 --- a/README.md +++ b/README.md @@ -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", ) ``` diff --git a/src/altertable_lakehouse/client.py b/src/altertable_lakehouse/client.py index 8f17153..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, @@ -16,9 +16,9 @@ ValidateResponse, AutocompleteRequest, AutocompleteResponse, - UpsertMode, QueryMetadata, QueryResult, + UploadMode, ) from .errors import ( AuthError, @@ -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", @@ -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}") diff --git a/src/altertable_lakehouse/models.py b/src/altertable_lakehouse/models.py index 2c4c950..65c8deb 100644 --- a/src/altertable_lakehouse/models.py +++ b/src/altertable_lakehouse/models.py @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py index 6c38d74..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 @@ -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: