riffsdk 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/release.yml +16 -0
  2. {riffsdk-0.4.2 → riffsdk-0.5.0}/CHANGELOG.md +42 -0
  3. riffsdk-0.5.0/PKG-INFO +127 -0
  4. {riffsdk-0.4.2 → riffsdk-0.5.0}/pyproject.toml +8 -1
  5. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_cli.py +124 -15
  6. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/__init__.py +2 -0
  7. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_async_client.py +37 -12
  8. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_client.py +37 -12
  9. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_models.py +16 -0
  10. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_upload.py +78 -3
  11. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_async_client.py +78 -1
  12. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_cli.py +89 -1
  13. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_client.py +66 -0
  14. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_integration.py +122 -0
  15. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_upload.py +202 -1
  16. riffsdk-0.4.2/PKG-INFO +0 -9
  17. {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/ci.yml +0 -0
  18. {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/pr-comment.yml +0 -0
  19. {riffsdk-0.4.2 → riffsdk-0.5.0}/.gitignore +0 -0
  20. {riffsdk-0.4.2 → riffsdk-0.5.0}/.python-version +0 -0
  21. {riffsdk-0.4.2 → riffsdk-0.5.0}/AGENTS.md +0 -0
  22. {riffsdk-0.4.2 → riffsdk-0.5.0}/CLAUDE.md +0 -0
  23. {riffsdk-0.4.2 → riffsdk-0.5.0}/LICENCE +0 -0
  24. {riffsdk-0.4.2 → riffsdk-0.5.0}/README.md +0 -0
  25. {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/async_client.py +0 -0
  26. {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/basic_crud.py +0 -0
  27. {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/file_upload_download.py +0 -0
  28. {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/optimistic_concurrency.py +0 -0
  29. {riffsdk-0.4.2 → riffsdk-0.5.0}/mise.toml +0 -0
  30. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/__init__.py +0 -0
  31. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/__main__.py +0 -0
  32. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/__init__.py +0 -0
  33. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/auth.py +0 -0
  34. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/config.py +0 -0
  35. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_http.py +0 -0
  36. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_session.py +0 -0
  37. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_streaming.py +0 -0
  38. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/exceptions.py +0 -0
  39. {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/py.typed +0 -0
  40. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/__init__.py +0 -0
  41. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_exceptions.py +0 -0
  42. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_http.py +0 -0
  43. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_imports.py +0 -0
  44. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_models.py +0 -0
  45. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_session.py +0 -0
  46. {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_streaming.py +0 -0
  47. {riffsdk-0.4.2 → riffsdk-0.5.0}/uv.lock +0 -0
@@ -86,3 +86,19 @@ jobs:
86
86
  path: dist/
87
87
  - name: Publish to PyPI
88
88
  uses: pypa/gh-action-pypi-publish@release/v1
89
+ - name: Verify release landed on PyPI
90
+ env:
91
+ TAG: ${{ needs.release.outputs.tag }}
92
+ run: |
93
+ VER="${TAG#v}"
94
+ echo "Verifying riffsdk ${VER} is available on PyPI..."
95
+ for i in $(seq 1 12); do
96
+ if curl -sf "https://pypi.org/pypi/riffsdk/${VER}/json" >/dev/null; then
97
+ echo "riffsdk ${VER} is live on PyPI"
98
+ exit 0
99
+ fi
100
+ echo "not yet on PyPI, retry ${i}/12..."
101
+ sleep 10
102
+ done
103
+ echo "::error::riffsdk ${VER} never appeared on PyPI despite a successful upload step (likely a silent edge 200 — see https://upload.pypi.org/legacy/ response in the publish step)"
104
+ exit 1
@@ -2,6 +2,48 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.5.0 (2026-06-11)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bugfixes for large file streaming ([#13](https://github.com/databutton/riff-sdk-python/pull/13),
10
+ [`619fd57`](https://github.com/databutton/riff-sdk-python/commit/619fd575ca353d955de23bb5e455ff6c6dc005d0))
11
+
12
+ ### Features
13
+
14
+ - Rebuild staged-part state on streaming resume
15
+ ([#13](https://github.com/databutton/riff-sdk-python/pull/13),
16
+ [`619fd57`](https://github.com/databutton/riff-sdk-python/commit/619fd575ca353d955de23bb5e455ff6c6dc005d0))
17
+
18
+ - Support uploading streams of unknown size
19
+ ([#13](https://github.com/databutton/riff-sdk-python/pull/13),
20
+ [`619fd57`](https://github.com/databutton/riff-sdk-python/commit/619fd575ca353d955de23bb5e455ff6c6dc005d0))
21
+
22
+
23
+ ## v0.4.3 (2026-06-11)
24
+
25
+ ### Bug Fixes
26
+
27
+ - Add project homepage URL ([#12](https://github.com/databutton/riff-sdk-python/pull/12),
28
+ [`7333ba9`](https://github.com/databutton/riff-sdk-python/commit/7333ba9badb64eef3b111b74ee060fb5a6538c5b))
29
+
30
+ - Declare MIT license and package author
31
+ ([#12](https://github.com/databutton/riff-sdk-python/pull/12),
32
+ [`7333ba9`](https://github.com/databutton/riff-sdk-python/commit/7333ba9badb64eef3b111b74ee060fb5a6538c5b))
33
+
34
+ - Include README as package long description
35
+ ([#12](https://github.com/databutton/riff-sdk-python/pull/12),
36
+ [`7333ba9`](https://github.com/databutton/riff-sdk-python/commit/7333ba9badb64eef3b111b74ee060fb5a6538c5b))
37
+
38
+ - Populate pypi release details and check publishing in CI job
39
+ ([#12](https://github.com/databutton/riff-sdk-python/pull/12),
40
+ [`7333ba9`](https://github.com/databutton/riff-sdk-python/commit/7333ba9badb64eef3b111b74ee060fb5a6538c5b))
41
+
42
+ - Verify PyPI publish actually landed before passing release
43
+ ([#12](https://github.com/databutton/riff-sdk-python/pull/12),
44
+ [`7333ba9`](https://github.com/databutton/riff-sdk-python/commit/7333ba9badb64eef3b111b74ee060fb5a6538c5b))
45
+
46
+
5
47
  ## v0.4.2 (2026-06-11)
6
48
 
7
49
  ### Bug Fixes
riffsdk-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: riffsdk
3
+ Version: 0.5.0
4
+ Summary: Python SDK for the Riff Storage API
5
+ Project-URL: Homepage, https://riff.ai
6
+ Author-email: Martin Sandve Alnæs <msa@databutton.io>
7
+ License-Expression: MIT
8
+ License-File: LICENCE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pydantic>=2.6
12
+ Requires-Dist: tenacity>=8.3
13
+ Description-Content-Type: text/markdown
14
+
15
+ # riffsdk
16
+
17
+ Python SDK for the Riff Storage API. Provides sync and async clients for storing, retrieving, and managing objects.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ uv add riffsdk
23
+ # or
24
+ pip install riffsdk
25
+ ```
26
+
27
+ Or install from a branch (for pre-release testing):
28
+
29
+ ```bash
30
+ uv add git+https://github.com/databutton/riff-sdk-python.git@main
31
+ # or
32
+ pip install git+https://github.com/databutton/riff-sdk-python.git@main
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ from riffsdk.storage import StorageClient
39
+
40
+ client = StorageClient()
41
+
42
+ # Upload
43
+ meta = client.put("hello.txt", "Hello, world!")
44
+
45
+ # Download
46
+ data = client.get("hello.txt")
47
+
48
+ # List
49
+ for obj in client.list("hello"):
50
+ print(f"{obj.key} ({obj.size} bytes)")
51
+
52
+ # Delete
53
+ client.delete("hello.txt")
54
+
55
+ client.close()
56
+ ```
57
+
58
+ ### Async
59
+
60
+ ```python
61
+ from riffsdk.storage import AsyncStorageClient
62
+
63
+ async with AsyncStorageClient() as client:
64
+ await client.put("key", b"data", content_type="application/octet-stream")
65
+ data = await client.get("key")
66
+ ```
67
+
68
+ ## Authentication
69
+
70
+ Set the `RIFF_TOKEN` environment variable. The SDK picks it up automatically.
71
+
72
+ ## API
73
+
74
+ ### Clients
75
+
76
+ - `StorageClient` -- sync client
77
+ - `AsyncStorageClient` -- async client
78
+
79
+ Both support: `put`, `get`, `stat`, `exists`, `list`, `delete`, `close`, and context manager usage.
80
+
81
+ ### Models
82
+
83
+ - `ObjectMeta` -- metadata for a stored object (key, version, size, content_type, timestamps)
84
+ - `UploadResult`, `DownloadResult` -- operation results
85
+ - `ListPage` -- paginated listing
86
+ - `Scope` -- access scope (use `account_scope()`, `project_scope()`, `session_scope()`)
87
+
88
+ ### Uploads
89
+
90
+ - `ResumableUpload` / `AsyncResumableUpload` -- multipart resumable uploads for large files
91
+ - `StorageReader` / `StorageWriter` -- streaming read/write
92
+
93
+ ### Exceptions
94
+
95
+ All exceptions inherit from `StorageError`:
96
+
97
+ - `AuthorisationError`
98
+ - `ObjectNotFoundError`
99
+ - `VersionConflictError`
100
+ - `AlreadyExistsError`
101
+ - `LeaseConflictError`
102
+ - `UploadNotFoundError`
103
+ - `QuotaExceededError`
104
+ - `PartMismatchError`
105
+ - `StorageTransportError`
106
+
107
+ ## Examples
108
+
109
+ See the `examples/` directory for complete working examples:
110
+
111
+ - `basic_crud.py` -- put, get, list, delete
112
+ - `async_client.py` -- async usage with asyncio
113
+ - `file_upload_download.py` -- file uploads with progress
114
+ - `optimistic_concurrency.py` -- version-based conflict handling
115
+
116
+ ## Development
117
+
118
+ Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
119
+
120
+ ```bash
121
+ uv sync --dev # Install dependencies
122
+ mise run test # Run tests
123
+ mise run lint # Lint
124
+ mise run format # Format code
125
+ ```
126
+
127
+ See `AGENTS.md` for full development workflow details.
@@ -4,11 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "riffsdk"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Python SDK for the Riff Storage API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENCE"]
12
+ authors = [{ name = "Martin Sandve Alnæs", email = "msa@databutton.io" }]
9
13
  requires-python = ">=3.11"
10
14
  dependencies = ["httpx>=0.27", "pydantic>=2.6", "tenacity>=8.3"]
11
15
 
16
+ [project.urls]
17
+ Homepage = "https://riff.ai"
18
+
12
19
  [project.scripts]
13
20
  riffsdk = "riffsdk._cli:main"
14
21
 
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import mimetypes
5
5
  import os
6
+ import stat
6
7
  import sys
7
- import tempfile
8
+ from typing import BinaryIO
8
9
 
9
10
  from riffsdk.storage._client import StorageClient
10
11
  from riffsdk.storage._models import Scope
@@ -39,6 +40,84 @@ def _format_size(size: int) -> str:
39
40
  return f"{size:.1f} PB"
40
41
 
41
42
 
43
+ _SIZE_UNITS = {
44
+ "B": 1,
45
+ "K": 1024,
46
+ "KB": 1024,
47
+ "M": 1024**2,
48
+ "MB": 1024**2,
49
+ "G": 1024**3,
50
+ "GB": 1024**3,
51
+ "T": 1024**4,
52
+ "TB": 1024**4,
53
+ "P": 1024**5,
54
+ "PB": 1024**5,
55
+ }
56
+
57
+
58
+ def _parse_size(text: str) -> int:
59
+ """Parse a human-readable size like '10G', '500MB', '1024' (bytes)."""
60
+ s = text.strip().upper()
61
+ unit = "B"
62
+ num = s
63
+ for u in ("PB", "TB", "GB", "MB", "KB", "P", "T", "G", "M", "K", "B"):
64
+ if s.endswith(u) and s[: -len(u)].strip():
65
+ num, unit = s[: -len(u)].strip(), u
66
+ break
67
+ try:
68
+ value = float(num)
69
+ except ValueError:
70
+ _die(f"invalid size: {text!r}")
71
+ if value < 0:
72
+ _die(f"invalid size: {text!r} (must not be negative)")
73
+ return int(value * _SIZE_UNITS[unit])
74
+
75
+
76
+ class _PrefixedStream:
77
+ """Read-only stream that returns a pre-read prefix, then the rest.
78
+
79
+ Lets the CLI peek the first block of stdin (to detect an empty pipe)
80
+ without buffering or losing those bytes for the actual upload.
81
+ """
82
+
83
+ def __init__(self, prefix: bytes, rest: BinaryIO) -> None:
84
+ self._prefix = prefix
85
+ self._rest = rest
86
+
87
+ def read(self, size: int = -1) -> bytes:
88
+ if not self._prefix:
89
+ return self._rest.read(size)
90
+ if size is None or size < 0:
91
+ data = self._prefix + self._rest.read()
92
+ self._prefix = b""
93
+ return data
94
+ if size <= len(self._prefix):
95
+ data, self._prefix = self._prefix[:size], self._prefix[size:]
96
+ return data
97
+ data = self._prefix + self._rest.read(size - len(self._prefix))
98
+ self._prefix = b""
99
+ return data
100
+
101
+
102
+ def _resolve_stream_size(args: argparse.Namespace) -> int | None:
103
+ """Total size of a stdin upload, or None if it can't be known without reading.
104
+
105
+ Uses an explicit --size, else the size of stdin when it is a regular file
106
+ (e.g. `riffsdk upload key -s < big.iso`). Returns None for non-seekable
107
+ pipes, where the size is genuinely unknown until the stream is consumed.
108
+ """
109
+ if getattr(args, "size", None):
110
+ return _parse_size(args.size)
111
+ try:
112
+ st = os.fstat(sys.stdin.fileno())
113
+ except (OSError, ValueError, TypeError):
114
+ # No real fd (closed, replaced, or a non-file object): size is unknown.
115
+ return None
116
+ if stat.S_ISREG(st.st_mode) and st.st_size > 0:
117
+ return st.st_size
118
+ return None
119
+
120
+
42
121
  def _make_progress(label: str) -> callable:
43
122
  def callback(done: int, total: int) -> None:
44
123
  if not sys.stderr.isatty():
@@ -92,20 +171,33 @@ def cmd_upload(client: StorageClient, args: argparse.Namespace) -> None:
92
171
  if args.stream:
93
172
  if args.input:
94
173
  _die("--input and --stream are mutually exclusive")
95
- with tempfile.SpooledTemporaryFile(max_size=10 * 1024 * 1024) as tmp:
96
- while True:
97
- chunk = sys.stdin.buffer.read(65536)
98
- if not chunk:
99
- break
100
- tmp.write(chunk)
101
- total_size = tmp.tell()
102
- if total_size == 0:
174
+ content_type = ct or _guess_content_type(key)
175
+ chunk_size = _parse_size(args.chunk_size) if args.chunk_size else None
176
+ total_size = _resolve_stream_size(args)
177
+
178
+ if total_size is None:
179
+ # Unknown size: stream stdin straight through (the server signs
180
+ # part URLs on demand). Peek one block so an empty stdin is
181
+ # reported clearly without consuming the whole input. No progress
182
+ # bar — the total is unknown.
183
+ first = sys.stdin.buffer.read(65536)
184
+ if not first:
103
185
  _die("no data received on stdin")
104
- tmp.seek(0)
105
- content_type = ct or _guess_content_type(key)
106
- result = client.upload_stream(
107
- key, tmp, total_size=total_size, content_type=content_type, scope=scope
108
- )
186
+ source: object = _PrefixedStream(first, sys.stdin.buffer)
187
+ progress = None
188
+ else:
189
+ source = sys.stdin.buffer
190
+ progress = _make_progress(key)
191
+
192
+ result = client.upload_stream(
193
+ key,
194
+ source,
195
+ total_size=total_size,
196
+ content_type=content_type,
197
+ chunk_size=chunk_size,
198
+ on_progress=progress,
199
+ scope=scope,
200
+ )
109
201
  if args.verbose:
110
202
  sys.stderr.write(
111
203
  f"Uploaded {result.key} ({_format_size(result.size)}, "
@@ -116,7 +208,12 @@ def cmd_upload(client: StorageClient, args: argparse.Namespace) -> None:
116
208
  _die(f"file not found: {args.input}")
117
209
  progress = _make_progress(os.path.basename(args.input))
118
210
  result = client.upload_file(
119
- key, args.input, content_type=ct, on_progress=progress, scope=scope
211
+ key,
212
+ args.input,
213
+ content_type=ct,
214
+ chunk_size=_parse_size(args.chunk_size) if args.chunk_size else None,
215
+ on_progress=progress,
216
+ scope=scope,
120
217
  )
121
218
  if args.verbose:
122
219
  sys.stderr.write(
@@ -251,6 +348,18 @@ def _build_parser() -> argparse.ArgumentParser:
251
348
  p.add_argument(
252
349
  "-s", "--stream", action="store_true", help="read from stdin for piping"
253
350
  )
351
+ p.add_argument(
352
+ "--size",
353
+ default=None,
354
+ help="total size of the stdin stream (e.g. 10G); streams without "
355
+ "buffering. Auto-detected when stdin is a regular file.",
356
+ )
357
+ p.add_argument(
358
+ "--chunk-size",
359
+ default=None,
360
+ help="upload part size (e.g. 64M); larger chunks mean fewer parts "
361
+ "for very large uploads",
362
+ )
254
363
  p.add_argument("-t", "--content-type", default=None, help="MIME content type")
255
364
  p.set_defaults(func=cmd_upload)
256
365
 
@@ -6,6 +6,7 @@ from riffsdk.storage._models import (
6
6
  ListPage,
7
7
  ObjectMeta,
8
8
  Scope,
9
+ UploadedPart,
9
10
  UploadPart,
10
11
  UploadResult,
11
12
  account_scope,
@@ -39,6 +40,7 @@ __all__ = [
39
40
  "StorageClient",
40
41
  "ObjectMeta",
41
42
  "UploadPart",
43
+ "UploadedPart",
42
44
  "ListPage",
43
45
  "UploadResult",
44
46
  "StorageReader",
@@ -17,6 +17,7 @@ from riffsdk.storage._models import (
17
17
  ListPage,
18
18
  ObjectMeta,
19
19
  Scope,
20
+ UploadedPart,
20
21
  UploadPart,
21
22
  UploadResult,
22
23
  )
@@ -293,13 +294,19 @@ class AsyncStorageClient:
293
294
  stream: BinaryIO,
294
295
  *,
295
296
  content_type: str = "application/octet-stream",
296
- total_size: int,
297
+ total_size: int | None = None,
297
298
  chunk_size: int | None = None,
298
299
  if_version_matches: str | None = None,
299
300
  if_not_exists: bool = False,
300
301
  on_progress: Callable[[int, int], None] | None = None,
301
302
  scope: Scope = None,
302
303
  ) -> UploadResult:
304
+ """Upload a binary stream.
305
+
306
+ Pass ``total_size`` when the size is known to use pre-signed part URLs.
307
+ Leave it ``None`` to stream an input of unknown size: the source is read
308
+ in part-size chunks until EOF, signing part URLs on demand.
309
+ """
303
310
  cs = chunk_size or self._chunk_size
304
311
 
305
312
  upload = await self.begin_upload(
@@ -313,14 +320,25 @@ class AsyncStorageClient:
313
320
  )
314
321
 
315
322
  bytes_sent = 0
316
- for part_num in range(1, upload.part_count + 1):
317
- chunk = stream.read(upload.part_size)
318
- if not chunk:
319
- break
320
- await upload.send_chunk(chunk, part_number=part_num)
321
- bytes_sent += len(chunk)
322
- if on_progress:
323
- on_progress(bytes_sent, total_size)
323
+ if total_size is None:
324
+ # Streaming: read until EOF, letting send_chunk auto-number parts.
325
+ while True:
326
+ chunk = stream.read(upload.part_size)
327
+ if not chunk:
328
+ break
329
+ await upload.send_chunk(chunk)
330
+ bytes_sent += len(chunk)
331
+ if on_progress:
332
+ on_progress(bytes_sent, bytes_sent)
333
+ else:
334
+ for part_num in range(1, upload.part_count + 1):
335
+ chunk = stream.read(upload.part_size)
336
+ if not chunk:
337
+ break
338
+ await upload.send_chunk(chunk, part_number=part_num)
339
+ bytes_sent += len(chunk)
340
+ if on_progress:
341
+ on_progress(bytes_sent, total_size)
324
342
 
325
343
  return await upload.complete()
326
344
 
@@ -328,7 +346,7 @@ class AsyncStorageClient:
328
346
  self,
329
347
  key: str,
330
348
  *,
331
- total_size: int,
349
+ total_size: int | None = None,
332
350
  content_type: str = "application/octet-stream",
333
351
  chunk_size: int | None = None,
334
352
  if_version_matches: str | None = None,
@@ -337,9 +355,12 @@ class AsyncStorageClient:
337
355
  ) -> AsyncResumableUpload:
338
356
  body: dict = {
339
357
  "key": key,
340
- "size": total_size,
341
358
  "content_type": content_type,
342
359
  }
360
+ # Omit size to request a streaming upload (server returns part_count 0
361
+ # and signs part URLs on demand via refresh).
362
+ if total_size is not None:
363
+ body["size"] = total_size
343
364
  cs = chunk_size or self._chunk_size
344
365
  body["part_size"] = cs
345
366
  if if_version_matches is not None:
@@ -363,7 +384,7 @@ class AsyncStorageClient:
363
384
  sessions=self._sessions,
364
385
  upload_id=data["upload_id"],
365
386
  key=data["key"],
366
- total_size=total_size,
387
+ total_size=total_size if total_size is not None else 0,
367
388
  part_count=data["part_count"],
368
389
  part_size=data["part_size"],
369
390
  expires_at=datetime.fromisoformat(data["expires_at"]),
@@ -385,6 +406,9 @@ class AsyncStorageClient:
385
406
  )
386
407
  data = resp.json()
387
408
  parts = [UploadPart.model_validate(p) for p in data["parts"]]
409
+ uploaded_parts = [
410
+ UploadedPart.model_validate(p) for p in data.get("uploaded_parts", [])
411
+ ]
388
412
 
389
413
  return AsyncResumableUpload(
390
414
  transport=self._transport,
@@ -401,6 +425,7 @@ class AsyncStorageClient:
401
425
  session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
402
426
  parts=parts,
403
427
  content_type=data.get("content_type", "application/octet-stream"),
428
+ uploaded_parts=uploaded_parts,
404
429
  )
405
430
 
406
431
  # --- Download ---
@@ -17,6 +17,7 @@ from riffsdk.storage._models import (
17
17
  ListPage,
18
18
  ObjectMeta,
19
19
  Scope,
20
+ UploadedPart,
20
21
  UploadPart,
21
22
  UploadResult,
22
23
  )
@@ -293,13 +294,19 @@ class StorageClient:
293
294
  stream: BinaryIO,
294
295
  *,
295
296
  content_type: str = "application/octet-stream",
296
- total_size: int,
297
+ total_size: int | None = None,
297
298
  chunk_size: int | None = None,
298
299
  if_version_matches: str | None = None,
299
300
  if_not_exists: bool = False,
300
301
  on_progress: Callable[[int, int], None] | None = None,
301
302
  scope: Scope = None,
302
303
  ) -> UploadResult:
304
+ """Upload a binary stream.
305
+
306
+ Pass ``total_size`` when the size is known to use pre-signed part URLs.
307
+ Leave it ``None`` to stream an input of unknown size: the source is read
308
+ in part-size chunks until EOF, signing part URLs on demand.
309
+ """
303
310
  cs = chunk_size or self._chunk_size
304
311
 
305
312
  upload = self.begin_upload(
@@ -313,14 +320,25 @@ class StorageClient:
313
320
  )
314
321
 
315
322
  bytes_sent = 0
316
- for part_num in range(1, upload.part_count + 1):
317
- chunk = stream.read(upload.part_size)
318
- if not chunk:
319
- break
320
- upload.send_chunk(chunk, part_number=part_num)
321
- bytes_sent += len(chunk)
322
- if on_progress:
323
- on_progress(bytes_sent, total_size)
323
+ if total_size is None:
324
+ # Streaming: read until EOF, letting send_chunk auto-number parts.
325
+ while True:
326
+ chunk = stream.read(upload.part_size)
327
+ if not chunk:
328
+ break
329
+ upload.send_chunk(chunk)
330
+ bytes_sent += len(chunk)
331
+ if on_progress:
332
+ on_progress(bytes_sent, bytes_sent)
333
+ else:
334
+ for part_num in range(1, upload.part_count + 1):
335
+ chunk = stream.read(upload.part_size)
336
+ if not chunk:
337
+ break
338
+ upload.send_chunk(chunk, part_number=part_num)
339
+ bytes_sent += len(chunk)
340
+ if on_progress:
341
+ on_progress(bytes_sent, total_size)
324
342
 
325
343
  return upload.complete()
326
344
 
@@ -328,7 +346,7 @@ class StorageClient:
328
346
  self,
329
347
  key: str,
330
348
  *,
331
- total_size: int,
349
+ total_size: int | None = None,
332
350
  content_type: str = "application/octet-stream",
333
351
  chunk_size: int | None = None,
334
352
  if_version_matches: str | None = None,
@@ -337,9 +355,12 @@ class StorageClient:
337
355
  ) -> ResumableUpload:
338
356
  body: dict = {
339
357
  "key": key,
340
- "size": total_size,
341
358
  "content_type": content_type,
342
359
  }
360
+ # Omit size to request a streaming upload (server returns part_count 0
361
+ # and signs part URLs on demand via refresh).
362
+ if total_size is not None:
363
+ body["size"] = total_size
343
364
  cs = chunk_size or self._chunk_size
344
365
  body["part_size"] = cs
345
366
  if if_version_matches is not None:
@@ -363,7 +384,7 @@ class StorageClient:
363
384
  sessions=self._sessions,
364
385
  upload_id=data["upload_id"],
365
386
  key=data["key"],
366
- total_size=total_size,
387
+ total_size=total_size if total_size is not None else 0,
367
388
  part_count=data["part_count"],
368
389
  part_size=data["part_size"],
369
390
  expires_at=datetime.fromisoformat(data["expires_at"]),
@@ -383,6 +404,9 @@ class StorageClient:
383
404
  )
384
405
  data = resp.json()
385
406
  parts = [UploadPart.model_validate(p) for p in data["parts"]]
407
+ uploaded_parts = [
408
+ UploadedPart.model_validate(p) for p in data.get("uploaded_parts", [])
409
+ ]
386
410
 
387
411
  return ResumableUpload(
388
412
  transport=self._transport,
@@ -399,6 +423,7 @@ class StorageClient:
399
423
  session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
400
424
  parts=parts,
401
425
  content_type=data.get("content_type", "application/octet-stream"),
426
+ uploaded_parts=uploaded_parts,
402
427
  )
403
428
 
404
429
  # --- Download ---
@@ -41,6 +41,22 @@ class UploadPart(BaseModel):
41
41
  url_expires_at: datetime
42
42
 
43
43
 
44
+ class UploadedPart(BaseModel):
45
+ """A part already present in staging, reported by refresh/resume.
46
+
47
+ Enumerated live from the staging directory by the server. ``etag`` is the
48
+ GCS object etag (currently informational — complete() composes by part
49
+ number); ``size`` is the staged object size, useful for spotting a
50
+ truncated final part on resume.
51
+ """
52
+
53
+ model_config = ConfigDict(frozen=True)
54
+
55
+ part_number: int
56
+ size: int = 0
57
+ etag: str = ""
58
+
59
+
44
60
  class ListPage(BaseModel):
45
61
  model_config = ConfigDict(frozen=True)
46
62