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.
- {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/release.yml +16 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/CHANGELOG.md +42 -0
- riffsdk-0.5.0/PKG-INFO +127 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/pyproject.toml +8 -1
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_cli.py +124 -15
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/__init__.py +2 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_async_client.py +37 -12
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_client.py +37 -12
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_models.py +16 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_upload.py +78 -3
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_async_client.py +78 -1
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_cli.py +89 -1
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_client.py +66 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_integration.py +122 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_upload.py +202 -1
- riffsdk-0.4.2/PKG-INFO +0 -9
- {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/ci.yml +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/.github/workflows/pr-comment.yml +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/.gitignore +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/.python-version +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/AGENTS.md +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/CLAUDE.md +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/LICENCE +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/README.md +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/async_client.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/basic_crud.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/file_upload_download.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/examples/optimistic_concurrency.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/mise.toml +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/__init__.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/__main__.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/__init__.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/auth.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/_internal/config.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_http.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_session.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/_streaming.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/exceptions.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/src/riffsdk/storage/py.typed +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/__init__.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_exceptions.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_http.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_imports.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_models.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_session.py +0 -0
- {riffsdk-0.4.2 → riffsdk-0.5.0}/tests/test_streaming.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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,
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|