Skip to content

Commit f630bd7

Browse files
authored
feat(uploads): transparent presigned direct-to-storage upload (#130)
* feat(uploads): add transparent presigned direct-to-storage upload * fix(uploads): harden failure path, error model, and concurrency tests * feat(uploads): accept bytes/file objects and add upload_stream * fix(uploads): file-object cursor, finalize no-retry, session scope, short-read guard * fix(uploads): guard single-PUT byte count against size mismatch * fix(uploads): fire multipart progress callback under the lock * docs(uploads): make changelog upload notes user-facing
1 parent d073681 commit f630bd7

7 files changed

Lines changed: 2429 additions & 12 deletions

File tree

‎.openapi-generator-ignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ setup.py
88
# never emits or overwrites them, but they are listed here as the source of
99
# truth for "hand-maintained, don't touch": _auth.py (JWT exchange), arrow.py
1010
# (Arrow IPC result fetch), query.py (429 retry + truncation auto-follow, #688),
11-
# _retry.py (pre-response connection-reset retry on all methods, #118).
11+
# _retry.py (pre-response connection-reset retry on all methods, #118),
12+
# uploads.py (transparent presigned direct-to-storage upload flow).
1213
hotdata/_auth.py
1314
hotdata/arrow.py
1415
hotdata/query.py
1516
hotdata/_retry.py
17+
hotdata/uploads.py
1618

1719
# Hand-written test for the patched ApiClient.close()/context-manager behavior
1820
# (re-applied by scripts/patch_api_client_close.py). It lives in the generated

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `hotdata.UploadsApi` gains `upload_file(source, ...)`, a transparent
13+
direct-to-storage upload. Give it a file path, raw `bytes`, or a seekable
14+
binary file object and it opens an upload session, sends the data **straight
15+
to object storage** (a single request for a small file, concurrent multipart
16+
for a large one), and finalizes — returning the `FinalizeUploadResponse`. Your
17+
bytes never round-trip through the API. Supports a progress callback, an
18+
auto-scaled (or caller-set) part size, bounded concurrency with a peak-memory
19+
budget, and idempotent per-part retry (tunable via `part_retry`). Failures
20+
raise a typed hierarchy under `UploadError`: `StorageError`,
21+
`StorageTransportError`, `MissingETagError`, `MalformedSessionError`, and
22+
`SizeLimitError`.
23+
- `hotdata.UploadsApi.upload_stream` uploads `bytes` or a binary stream
24+
(streamed without buffering) in a single request — the fallback for when
25+
direct-to-storage uploads aren't available or the source isn't seekable.
26+
1027
### Changed
1128

1229
- feat(uploads): add file upload endpoints

‎README.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,61 @@ with ApiClient(Configuration(api_key="...", workspace_id="...")) as client:
8989

9090
Both methods accept `offset` and `limit` for pagination. They raise `hotdata.arrow.ResultNotReadyError` if the result is still pending or processing — poll `results.get_result(result_id)` until `status == "ready"` first.
9191

92+
## File uploads
93+
94+
`hotdata.uploads.UploadsApi` (also the default `hotdata.UploadsApi`) adds
95+
`upload_file`, which uploads a local file **directly to object storage** and
96+
finalizes it in one call. It opens an upload session, `PUT`s the bytes straight
97+
to storage — a single `PUT` for a small file, concurrent part `PUT`s for a large
98+
one — then finalizes. The bytes never round-trip through the API.
99+
100+
```python
101+
from hotdata import ApiClient, Configuration, UploadsApi
102+
103+
with ApiClient(Configuration(api_key="...", workspace_id="...")) as client:
104+
uploads = UploadsApi(client)
105+
106+
finalized = uploads.upload_file(
107+
"data.parquet",
108+
content_type="application/parquet",
109+
progress=lambda done, total: print(f"{done}/{total} bytes"),
110+
)
111+
112+
# Pass finalized.upload_id to the managed-table load endpoint.
113+
print(finalized.upload_id)
114+
```
115+
116+
`upload_file` accepts a path, raw `bytes`, or a seekable binary file object
117+
(`size` is inferred for all three; a file object is read from its current
118+
position to the end). The SDK picks single vs. multipart from the size,
119+
auto-scales the part size, and bounds part concurrency to a peak-memory budget
120+
(override with `part_size` / `max_concurrency` / `part_retry`). Storage `PUT`s go
121+
through a dedicated, header-isolated connection pool, so the SDK's auth and
122+
workspace headers never reach object storage (which would otherwise reject the
123+
upload). Finalize is sent with retries disabled so the exactly-once call is never
124+
accidentally replayed.
125+
126+
Failures surface as a typed hierarchy under `hotdata.uploads.UploadError`:
127+
`StorageError` (storage returned a non-2xx), `StorageTransportError` (the PUT
128+
failed before any response), `MissingETagError`, `MalformedSessionError`, and
129+
`SizeLimitError`. Opening the session or finalizing raises the usual
130+
`hotdata.exceptions.ApiException` — for example a `501` `PRESIGN_UNSUPPORTED`,
131+
meaning the backend cannot issue upload URLs.
132+
133+
For that fallback (or to upload from a non-seekable stream), use `upload_stream`,
134+
which sends the bytes to the legacy `POST /v1/files` endpoint in one request,
135+
streaming a file object without buffering it in memory:
136+
137+
```python
138+
with open("data.parquet", "rb") as f:
139+
resp = uploads.upload_stream(f, content_type="application/parquet")
140+
print(resp.id)
141+
```
142+
143+
Note `upload_file` shadows the generated raw-body `upload_file(body=...)`; that
144+
raw operation is still reachable at
145+
`hotdata.api.uploads_api.UploadsApi.upload_file`.
146+
92147
## API reference
93148

94149
Generated Markdown for every operation and model is in [`docs/`](https://github.com/hotdata-dev/sdk-python/tree/main/docs):

‎hotdata/__init__.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,9 @@
322322
# --- hand-applied: prefer the enhanced clients over the generated ones
323323
# (re-applied by scripts/patch_query_exports.py after regeneration).
324324
# hotdata.query.QueryApi adds 429 retry + truncation auto-follow;
325-
# hotdata.arrow.ResultsApi adds Arrow IPC result fetch. The raw generated
326-
# classes remain importable from hotdata.api.query_api / hotdata.api.results_api.
325+
# hotdata.arrow.ResultsApi adds Arrow IPC result fetch;
326+
# hotdata.uploads.UploadsApi adds transparent presigned direct-to-storage
327+
# uploads. The raw generated classes remain importable from hotdata.api.*.
327328
from hotdata.query import QueryApi as QueryApi # noqa: E402,F811
328329
from hotdata.arrow import ResultsApi as ResultsApi # noqa: E402,F811
330+
from hotdata.uploads import UploadsApi as UploadsApi # noqa: E402,F811

0 commit comments

Comments
 (0)