riffsdk 0.2.0__tar.gz → 0.3.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.3.0/CHANGELOG.md +39 -0
  2. {riffsdk-0.2.0 → riffsdk-0.3.0}/PKG-INFO +1 -1
  3. {riffsdk-0.2.0 → riffsdk-0.3.0}/pyproject.toml +4 -1
  4. riffsdk-0.3.0/src/riffsdk/__main__.py +3 -0
  5. riffsdk-0.3.0/src/riffsdk/_cli.py +323 -0
  6. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_async_client.py +1 -1
  7. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_client.py +1 -1
  8. riffsdk-0.3.0/tests/test_cli.py +423 -0
  9. riffsdk-0.2.0/CHANGELOG.md +0 -21
  10. {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/ci.yml +0 -0
  11. {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/pr-comment.yml +0 -0
  12. {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/release.yml +0 -0
  13. {riffsdk-0.2.0 → riffsdk-0.3.0}/.gitignore +0 -0
  14. {riffsdk-0.2.0 → riffsdk-0.3.0}/.python-version +0 -0
  15. {riffsdk-0.2.0 → riffsdk-0.3.0}/AGENTS.md +0 -0
  16. {riffsdk-0.2.0 → riffsdk-0.3.0}/CLAUDE.md +0 -0
  17. {riffsdk-0.2.0 → riffsdk-0.3.0}/LICENCE +0 -0
  18. {riffsdk-0.2.0 → riffsdk-0.3.0}/README.md +0 -0
  19. {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/async_client.py +0 -0
  20. {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/basic_crud.py +0 -0
  21. {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/file_upload_download.py +0 -0
  22. {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/optimistic_concurrency.py +0 -0
  23. {riffsdk-0.2.0 → riffsdk-0.3.0}/mise.toml +0 -0
  24. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/__init__.py +0 -0
  25. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/__init__.py +0 -0
  26. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/auth.py +0 -0
  27. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/config.py +0 -0
  28. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/__init__.py +0 -0
  29. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_http.py +0 -0
  30. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_models.py +0 -0
  31. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_session.py +0 -0
  32. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_streaming.py +0 -0
  33. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_upload.py +0 -0
  34. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/exceptions.py +0 -0
  35. {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/py.typed +0 -0
  36. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/__init__.py +0 -0
  37. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_async_client.py +0 -0
  38. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_client.py +0 -0
  39. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_exceptions.py +0 -0
  40. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_http.py +0 -0
  41. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_imports.py +0 -0
  42. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_integration.py +0 -0
  43. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_models.py +0 -0
  44. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_session.py +0 -0
  45. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_streaming.py +0 -0
  46. {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_upload.py +0 -0
  47. {riffsdk-0.2.0 → riffsdk-0.3.0}/uv.lock +0 -0
@@ -0,0 +1,39 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.3.0 (2026-06-10)
6
+
7
+ ### Features
8
+
9
+ - Add CLI for upload, download, and file management
10
+ ([#8](https://github.com/databutton/riff-sdk-python/pull/8),
11
+ [`dd09191`](https://github.com/databutton/riff-sdk-python/commit/dd0919119f111c6a779a6933fb16f2425c52de20))
12
+
13
+
14
+ ## v0.2.1 (2026-06-10)
15
+
16
+ ### Bug Fixes
17
+
18
+ - Update download-url endpoint path to /v1/storage/download-url/:key
19
+ ([#7](https://github.com/databutton/riff-sdk-python/pull/7),
20
+ [`fd06ef6`](https://github.com/databutton/riff-sdk-python/commit/fd06ef62436c861d482d8ce8e530a23572d1e790))
21
+
22
+
23
+ ## v0.2.0 (2026-06-02)
24
+
25
+ ### Bug Fixes
26
+
27
+ - Try to make release work properly ([#6](https://github.com/databutton/riff-sdk-python/pull/6),
28
+ [`bc68e3b`](https://github.com/databutton/riff-sdk-python/commit/bc68e3b1e7655024ef2f01254f2afd5229115410))
29
+
30
+ ### Features
31
+
32
+ - Add text and JSON convenience methods to storage clients
33
+ ([#5](https://github.com/databutton/riff-sdk-python/pull/5),
34
+ [`b53f219`](https://github.com/databutton/riff-sdk-python/commit/b53f219857bca08e4e71ae3a65fd1a7fb0042d78))
35
+
36
+
37
+ ## v0.1.0 (2026-06-01)
38
+
39
+ - Initial Release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riffsdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for the Riff Storage API
5
5
  License-File: LICENCE
6
6
  Requires-Python: >=3.11
@@ -4,11 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "riffsdk"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for the Riff Storage API"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = ["httpx>=0.27", "pydantic>=2.6", "tenacity>=8.3"]
11
11
 
12
+ [project.scripts]
13
+ riffsdk = "riffsdk._cli:main"
14
+
12
15
  [dependency-groups]
13
16
  dev = [
14
17
  "pytest>=9.0",
@@ -0,0 +1,3 @@
1
+ from riffsdk._cli import main
2
+
3
+ main()
@@ -0,0 +1,323 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import mimetypes
5
+ import os
6
+ import sys
7
+ import tempfile
8
+
9
+ from riffsdk.storage._client import StorageClient
10
+ from riffsdk.storage._models import Scope
11
+ from riffsdk.storage.exceptions import (
12
+ AuthorisationError,
13
+ ObjectNotFoundError,
14
+ StorageError,
15
+ )
16
+
17
+
18
+ def _die(msg: str, code: int = 1) -> None:
19
+ sys.stderr.write(f"riffsdk: {msg}\n")
20
+ sys.exit(code)
21
+
22
+
23
+ def _resolve_key(args: argparse.Namespace) -> str:
24
+ key = getattr(args, "key", None) or getattr(args, "key_flag", None)
25
+ if not key:
26
+ _die("storage key is required (positional or -k/--key)")
27
+ return key
28
+
29
+
30
+ def _guess_content_type(path: str) -> str:
31
+ return mimetypes.guess_type(path)[0] or "application/octet-stream"
32
+
33
+
34
+ def _format_size(size: int) -> str:
35
+ for unit in ("B", "KB", "MB", "GB", "TB"):
36
+ if size < 1024:
37
+ return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}"
38
+ size /= 1024
39
+ return f"{size:.1f} PB"
40
+
41
+
42
+ def _make_progress(label: str) -> callable:
43
+ def callback(done: int, total: int) -> None:
44
+ if not sys.stderr.isatty():
45
+ return
46
+ pct = done * 100 // total if total else 0
47
+ bar_len = 30
48
+ filled = bar_len * done // total if total else 0
49
+ bar = "#" * filled + "-" * (bar_len - filled)
50
+ sys.stderr.write(
51
+ f"\r{label}: [{bar}] {pct}% {_format_size(done)}/{_format_size(total)}"
52
+ )
53
+ if done >= total:
54
+ sys.stderr.write("\n")
55
+ sys.stderr.flush()
56
+
57
+ return callback
58
+
59
+
60
+ # --- Subcommand handlers ---
61
+
62
+
63
+ def cmd_download(client: StorageClient, args: argparse.Namespace) -> None:
64
+ key = _resolve_key(args)
65
+ scope: Scope = getattr(args, "scope", None)
66
+
67
+ if args.stream:
68
+ if args.output:
69
+ _die("--output and --stream are mutually exclusive")
70
+ for chunk in client.download_stream(key, scope=scope):
71
+ sys.stdout.buffer.write(chunk)
72
+ sys.stdout.buffer.flush()
73
+ else:
74
+ output = args.output or os.path.basename(key)
75
+ parent = os.path.dirname(output)
76
+ if parent:
77
+ os.makedirs(parent, exist_ok=True)
78
+ progress = _make_progress(os.path.basename(key))
79
+ result = client.download_file(key, output, on_progress=progress, scope=scope)
80
+ if args.verbose:
81
+ sys.stderr.write(
82
+ f"Downloaded {result.key} ({_format_size(result.size)}, "
83
+ f"version={result.version})\n"
84
+ )
85
+
86
+
87
+ def cmd_upload(client: StorageClient, args: argparse.Namespace) -> None:
88
+ key = _resolve_key(args)
89
+ scope: Scope = getattr(args, "scope", None)
90
+ ct = getattr(args, "content_type", None)
91
+
92
+ if args.stream:
93
+ if args.input:
94
+ _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:
103
+ _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
+ )
109
+ if args.verbose:
110
+ sys.stderr.write(
111
+ f"Uploaded {result.key} ({_format_size(result.size)}, "
112
+ f"version={result.version})\n"
113
+ )
114
+ elif args.input:
115
+ if not os.path.isfile(args.input):
116
+ _die(f"file not found: {args.input}")
117
+ progress = _make_progress(os.path.basename(args.input))
118
+ result = client.upload_file(
119
+ key, args.input, content_type=ct, on_progress=progress, scope=scope
120
+ )
121
+ if args.verbose:
122
+ sys.stderr.write(
123
+ f"Uploaded {result.key} ({_format_size(result.size)}, "
124
+ f"version={result.version})\n"
125
+ )
126
+ else:
127
+ _die("specify --input/-i or --stream/-s")
128
+
129
+
130
+ def cmd_download_dir(client: StorageClient, args: argparse.Namespace) -> None:
131
+ prefix = _resolve_key(args)
132
+ scope: Scope = getattr(args, "scope", None)
133
+ output_dir = args.output
134
+ if not output_dir:
135
+ _die("--output/-o is required for download-dir")
136
+
137
+ count = 0
138
+ for obj in client.list(prefix, scope=scope):
139
+ rel = obj.key[len(prefix) :]
140
+ if not rel:
141
+ continue
142
+ local_path = os.path.join(output_dir, rel)
143
+ parent = os.path.dirname(local_path)
144
+ if parent:
145
+ os.makedirs(parent, exist_ok=True)
146
+ progress = _make_progress(rel) if args.verbose else None
147
+ client.download_file(obj.key, local_path, on_progress=progress, scope=scope)
148
+ count += 1
149
+ sys.stderr.write(f" {rel} ({_format_size(obj.size)})\n")
150
+
151
+ sys.stderr.write(f"Downloaded {count} file(s)\n")
152
+
153
+
154
+ def cmd_upload_dir(client: StorageClient, args: argparse.Namespace) -> None:
155
+ local_dir = args.path
156
+ prefix = _resolve_key(args)
157
+ scope: Scope = getattr(args, "scope", None)
158
+
159
+ if not os.path.isdir(local_dir):
160
+ _die(f"not a directory: {local_dir}")
161
+
162
+ count = 0
163
+ for dirpath, _dirnames, filenames in os.walk(local_dir):
164
+ for filename in filenames:
165
+ local_path = os.path.join(dirpath, filename)
166
+ rel = os.path.relpath(local_path, local_dir).replace(os.sep, "/")
167
+ key = prefix + rel
168
+ progress = _make_progress(rel) if args.verbose else None
169
+ client.upload_file(key, local_path, on_progress=progress, scope=scope)
170
+ count += 1
171
+ sys.stderr.write(f" {rel}\n")
172
+
173
+ sys.stderr.write(f"Uploaded {count} file(s)\n")
174
+
175
+
176
+ def cmd_ls(client: StorageClient, args: argparse.Namespace) -> None:
177
+ prefix = getattr(args, "prefix", "") or ""
178
+ scope: Scope = getattr(args, "scope", None)
179
+ delimiter = getattr(args, "delimiter", None)
180
+ long_fmt = getattr(args, "long", False)
181
+
182
+ for obj in client.list(prefix, delimiter=delimiter, scope=scope):
183
+ if long_fmt:
184
+ updated = obj.updated_at.isoformat() if obj.updated_at else ""
185
+ sys.stdout.write(
186
+ f"{updated}\t{obj.size:>10}\t{obj.content_type}\t{obj.key}\n"
187
+ )
188
+ else:
189
+ sys.stdout.write(f"{obj.key}\n")
190
+
191
+
192
+ def cmd_stat(client: StorageClient, args: argparse.Namespace) -> None:
193
+ key = _resolve_key(args)
194
+ scope: Scope = getattr(args, "scope", None)
195
+ obj = client.stat(key, scope=scope)
196
+
197
+ sys.stdout.write(f"Key: {obj.key}\n")
198
+ sys.stdout.write(f"Size: {obj.size} ({_format_size(obj.size)})\n")
199
+ sys.stdout.write(f"Content-Type: {obj.content_type}\n")
200
+ sys.stdout.write(f"Version: {obj.version}\n")
201
+ if obj.created_at:
202
+ sys.stdout.write(f"Created: {obj.created_at.isoformat()}\n")
203
+ if obj.updated_at:
204
+ sys.stdout.write(f"Updated: {obj.updated_at.isoformat()}\n")
205
+
206
+
207
+ def cmd_delete(client: StorageClient, args: argparse.Namespace) -> None:
208
+ key = _resolve_key(args)
209
+ scope: Scope = getattr(args, "scope", None)
210
+ version = getattr(args, "version", None)
211
+ client.delete(key, version=version, scope=scope)
212
+ sys.stderr.write(f"Deleted {key}\n")
213
+
214
+
215
+ # --- Argument parser ---
216
+
217
+
218
+ def _add_key_args(parser: argparse.ArgumentParser) -> None:
219
+ parser.add_argument("key", nargs="?", default=None, help="storage key")
220
+ parser.add_argument(
221
+ "-k", "--key", dest="key_flag", default=None, help="storage key"
222
+ )
223
+
224
+
225
+ def _build_parser() -> argparse.ArgumentParser:
226
+ parser = argparse.ArgumentParser(
227
+ prog="riffsdk",
228
+ description="Riff Storage CLI",
229
+ )
230
+ parser.add_argument(
231
+ "-v", "--verbose", action="store_true", help="verbose output to stderr"
232
+ )
233
+ parser.add_argument("--scope", default=None, help="storage scope")
234
+ parser.add_argument("--base-url", default=None, help="override API base URL")
235
+
236
+ sub = parser.add_subparsers(dest="command")
237
+
238
+ # download
239
+ p = sub.add_parser("download", help="download a file from storage")
240
+ _add_key_args(p)
241
+ p.add_argument("-o", "--output", default=None, help="local output path")
242
+ p.add_argument(
243
+ "-s", "--stream", action="store_true", help="write to stdout for piping"
244
+ )
245
+ p.set_defaults(func=cmd_download)
246
+
247
+ # upload
248
+ p = sub.add_parser("upload", help="upload a file to storage")
249
+ _add_key_args(p)
250
+ p.add_argument("-i", "--input", default=None, help="local input path")
251
+ p.add_argument(
252
+ "-s", "--stream", action="store_true", help="read from stdin for piping"
253
+ )
254
+ p.add_argument("-t", "--content-type", default=None, help="MIME content type")
255
+ p.set_defaults(func=cmd_upload)
256
+
257
+ # download-dir
258
+ p = sub.add_parser("download-dir", help="download a directory from storage")
259
+ _add_key_args(p)
260
+ p.add_argument("-o", "--output", required=True, help="local output directory")
261
+ p.set_defaults(func=cmd_download_dir)
262
+
263
+ # upload-dir
264
+ p = sub.add_parser("upload-dir", help="upload a directory to storage")
265
+ p.add_argument("path", help="local directory to upload")
266
+ p.add_argument("-k", "--key", dest="key_flag", required=True, help="storage prefix")
267
+ p.set_defaults(func=cmd_upload_dir)
268
+
269
+ # ls
270
+ p = sub.add_parser("ls", help="list objects")
271
+ p.add_argument("prefix", nargs="?", default="", help="key prefix filter")
272
+ p.add_argument("-l", "--long", action="store_true", help="detailed listing")
273
+ p.add_argument("-d", "--delimiter", default=None, help="delimiter for hierarchy")
274
+ p.set_defaults(func=cmd_ls)
275
+
276
+ # stat
277
+ p = sub.add_parser("stat", help="show object metadata")
278
+ _add_key_args(p)
279
+ p.set_defaults(func=cmd_stat)
280
+
281
+ # delete
282
+ p = sub.add_parser("delete", help="delete an object")
283
+ _add_key_args(p)
284
+ p.add_argument("--version", default=None, help="match specific version")
285
+ p.set_defaults(func=cmd_delete)
286
+
287
+ return parser
288
+
289
+
290
+ def _run(argv: list[str]) -> None:
291
+ parser = _build_parser()
292
+ args = parser.parse_args(argv)
293
+
294
+ if not args.command:
295
+ parser.print_help(sys.stderr)
296
+ sys.exit(1)
297
+
298
+ kwargs: dict = {}
299
+ if args.base_url:
300
+ kwargs["base_url"] = args.base_url
301
+ if args.scope:
302
+ kwargs["scope"] = args.scope
303
+
304
+ with StorageClient(**kwargs) as client:
305
+ args.func(client, args)
306
+
307
+
308
+ def main() -> None:
309
+ try:
310
+ _run(sys.argv[1:])
311
+ except AuthorisationError as e:
312
+ _die(f"authentication failed: {e.message}", code=2)
313
+ except ObjectNotFoundError as e:
314
+ _die(f"object not found: {e.key}", code=3)
315
+ except StorageError as e:
316
+ _die(str(e))
317
+ except KeyboardInterrupt:
318
+ sys.stderr.write("\nInterrupted\n")
319
+ sys.exit(130)
320
+ except BrokenPipeError:
321
+ devnull = os.open(os.devnull, os.O_WRONLY)
322
+ os.dup2(devnull, sys.stdout.fileno())
323
+ sys.exit(0)
@@ -590,7 +590,7 @@ class AsyncStorageClient:
590
590
 
591
591
  resp = await self._transport.request(
592
592
  "GET",
593
- f"/v1/storage/objects/{key}/download-url",
593
+ f"/v1/storage/download-url/{key}",
594
594
  token=token,
595
595
  params=params,
596
596
  headers=self._scope_headers(scope),
@@ -619,7 +619,7 @@ class StorageClient:
619
619
 
620
620
  resp = self._transport.request(
621
621
  "GET",
622
- f"/v1/storage/objects/{key}/download-url",
622
+ f"/v1/storage/download-url/{key}",
623
623
  token=token,
624
624
  params=params,
625
625
  headers=self._scope_headers(scope),
@@ -0,0 +1,423 @@
1
+ import io
2
+ import os
3
+ import tempfile
4
+ from datetime import UTC, datetime
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from riffsdk._cli import (
10
+ _build_parser,
11
+ _resolve_key,
12
+ cmd_delete,
13
+ cmd_download,
14
+ cmd_download_dir,
15
+ cmd_ls,
16
+ cmd_stat,
17
+ cmd_upload,
18
+ cmd_upload_dir,
19
+ main,
20
+ )
21
+ from riffsdk.storage._models import DownloadResult, ObjectMeta, UploadResult
22
+ from riffsdk.storage.exceptions import AuthorisationError, ObjectNotFoundError
23
+
24
+
25
+ def _ts() -> datetime:
26
+ return datetime(2025, 1, 1, tzinfo=UTC)
27
+
28
+
29
+ def _meta(key: str = "test.txt", size: int = 100) -> ObjectMeta:
30
+ return ObjectMeta(
31
+ key=key,
32
+ size=size,
33
+ content_type="text/plain",
34
+ version="v1",
35
+ created_at=_ts(),
36
+ updated_at=_ts(),
37
+ )
38
+
39
+
40
+ def _upload_result(key: str = "test.txt", size: int = 100) -> UploadResult:
41
+ return UploadResult(
42
+ key=key, version="v1", size=size, content_type="text/plain", created_at=_ts()
43
+ )
44
+
45
+
46
+ def _download_result(key: str = "test.txt", size: int = 100) -> DownloadResult:
47
+ return DownloadResult(
48
+ key=key, size=size, content_type="text/plain", version="v1"
49
+ )
50
+
51
+
52
+ class TestArgParsing:
53
+ def test_download_positional_key(self):
54
+ parser = _build_parser()
55
+ args = parser.parse_args(["download", "mykey", "-o", "out.txt"])
56
+ assert args.key == "mykey"
57
+ assert args.output == "out.txt"
58
+ assert args.command == "download"
59
+
60
+ def test_download_flag_key(self):
61
+ parser = _build_parser()
62
+ args = parser.parse_args(["download", "-k", "mykey", "-o", "out.txt"])
63
+ assert args.key_flag == "mykey"
64
+ assert args.output == "out.txt"
65
+
66
+ def test_upload_with_input(self):
67
+ parser = _build_parser()
68
+ args = parser.parse_args(["upload", "mykey", "-i", "in.txt"])
69
+ assert args.key == "mykey"
70
+ assert args.input == "in.txt"
71
+
72
+ def test_upload_stream(self):
73
+ parser = _build_parser()
74
+ args = parser.parse_args(["upload", "-k", "mykey", "--stream"])
75
+ assert args.key_flag == "mykey"
76
+ assert args.stream is True
77
+
78
+ def test_ls_default_prefix(self):
79
+ parser = _build_parser()
80
+ args = parser.parse_args(["ls"])
81
+ assert args.prefix == ""
82
+
83
+ def test_ls_with_prefix_and_long(self):
84
+ parser = _build_parser()
85
+ args = parser.parse_args(["ls", "data/", "-l"])
86
+ assert args.prefix == "data/"
87
+ assert args.long is True
88
+
89
+ def test_stat_positional(self):
90
+ parser = _build_parser()
91
+ args = parser.parse_args(["stat", "mykey"])
92
+ assert args.key == "mykey"
93
+
94
+ def test_delete_with_version(self):
95
+ parser = _build_parser()
96
+ args = parser.parse_args(["delete", "mykey", "--version", "v2"])
97
+ assert args.key == "mykey"
98
+ assert args.version == "v2"
99
+
100
+ def test_upload_dir(self):
101
+ parser = _build_parser()
102
+ args = parser.parse_args(["upload-dir", "/tmp/data", "-k", "prefix/"])
103
+ assert args.path == "/tmp/data"
104
+ assert args.key_flag == "prefix/"
105
+
106
+ def test_download_dir(self):
107
+ parser = _build_parser()
108
+ args = parser.parse_args(["download-dir", "prefix/", "-o", "/tmp/out"])
109
+ assert args.key == "prefix/"
110
+ assert args.output == "/tmp/out"
111
+
112
+ def test_global_verbose(self):
113
+ parser = _build_parser()
114
+ args = parser.parse_args(["-v", "ls"])
115
+ assert args.verbose is True
116
+
117
+ def test_resolve_key_positional(self):
118
+ parser = _build_parser()
119
+ args = parser.parse_args(["download", "mykey"])
120
+ assert _resolve_key(args) == "mykey"
121
+
122
+ def test_resolve_key_flag(self):
123
+ parser = _build_parser()
124
+ args = parser.parse_args(["download", "-k", "mykey"])
125
+ assert _resolve_key(args) == "mykey"
126
+
127
+ def test_resolve_key_missing(self):
128
+ parser = _build_parser()
129
+ args = parser.parse_args(["download"])
130
+ with pytest.raises(SystemExit, match="1"):
131
+ _resolve_key(args)
132
+
133
+
134
+ class TestDownload:
135
+ def test_download_to_file(self):
136
+ client = MagicMock()
137
+ client.download_file.return_value = _download_result()
138
+
139
+ parser = _build_parser()
140
+ with tempfile.TemporaryDirectory() as tmpdir:
141
+ out = os.path.join(tmpdir, "out.txt")
142
+ args = parser.parse_args(["download", "test.txt", "-o", out])
143
+ args.verbose = False
144
+ cmd_download(client, args)
145
+
146
+ client.download_file.assert_called_once()
147
+ call_args = client.download_file.call_args
148
+ assert call_args[0] == ("test.txt", out)
149
+
150
+ def test_download_default_output(self):
151
+ client = MagicMock()
152
+ client.download_file.return_value = _download_result(key="path/to/file.txt")
153
+
154
+ parser = _build_parser()
155
+ args = parser.parse_args(["download", "path/to/file.txt"])
156
+ args.verbose = False
157
+ cmd_download(client, args)
158
+
159
+ call_args = client.download_file.call_args
160
+ assert call_args[0][1] == "file.txt"
161
+
162
+ def test_download_stream(self):
163
+ client = MagicMock()
164
+ client.download_stream.return_value = iter([b"hello", b" world"])
165
+
166
+ parser = _build_parser()
167
+ args = parser.parse_args(["download", "test.txt", "--stream"])
168
+ args.verbose = False
169
+
170
+ buf = io.BytesIO()
171
+ with patch("sys.stdout") as mock_stdout:
172
+ mock_stdout.buffer = buf
173
+ cmd_download(client, args)
174
+
175
+ assert buf.getvalue() == b"hello world"
176
+
177
+ def test_download_stream_and_output_conflict(self):
178
+ client = MagicMock()
179
+ parser = _build_parser()
180
+ args = parser.parse_args(["download", "test.txt", "--stream", "-o", "out.txt"])
181
+ args.verbose = False
182
+
183
+ with pytest.raises(SystemExit):
184
+ cmd_download(client, args)
185
+
186
+
187
+ class TestUpload:
188
+ def test_upload_from_file(self):
189
+ client = MagicMock()
190
+ client.upload_file.return_value = _upload_result()
191
+
192
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
193
+ f.write(b"test data")
194
+ f.flush()
195
+ try:
196
+ parser = _build_parser()
197
+ args = parser.parse_args(["upload", "test.txt", "-i", f.name])
198
+ args.verbose = False
199
+ cmd_upload(client, args)
200
+
201
+ client.upload_file.assert_called_once()
202
+ call_args = client.upload_file.call_args
203
+ assert call_args[0] == ("test.txt", f.name)
204
+ finally:
205
+ os.unlink(f.name)
206
+
207
+ def test_upload_from_stdin(self):
208
+ client = MagicMock()
209
+ client.upload_stream.return_value = _upload_result()
210
+
211
+ parser = _build_parser()
212
+ args = parser.parse_args(["upload", "test.txt", "--stream"])
213
+ args.verbose = False
214
+
215
+ stdin_data = io.BytesIO(b"piped data")
216
+ with patch("sys.stdin") as mock_stdin:
217
+ mock_stdin.buffer = stdin_data
218
+ cmd_upload(client, args)
219
+
220
+ client.upload_stream.assert_called_once()
221
+ call_args = client.upload_stream.call_args
222
+ assert call_args[0][0] == "test.txt"
223
+ assert call_args[1]["total_size"] == 10
224
+
225
+ def test_upload_stdin_empty(self):
226
+ client = MagicMock()
227
+ parser = _build_parser()
228
+ args = parser.parse_args(["upload", "test.txt", "--stream"])
229
+ args.verbose = False
230
+
231
+ stdin_data = io.BytesIO(b"")
232
+ with patch("sys.stdin") as mock_stdin:
233
+ mock_stdin.buffer = stdin_data
234
+ with pytest.raises(SystemExit):
235
+ cmd_upload(client, args)
236
+
237
+ def test_upload_no_input_no_stream(self):
238
+ client = MagicMock()
239
+ parser = _build_parser()
240
+ args = parser.parse_args(["upload", "test.txt"])
241
+ args.verbose = False
242
+
243
+ with pytest.raises(SystemExit):
244
+ cmd_upload(client, args)
245
+
246
+ def test_upload_content_type_override(self):
247
+ client = MagicMock()
248
+ client.upload_stream.return_value = _upload_result()
249
+
250
+ parser = _build_parser()
251
+ args = parser.parse_args(
252
+ ["upload", "data", "--stream", "-t", "application/json"]
253
+ )
254
+ args.verbose = False
255
+
256
+ stdin_data = io.BytesIO(b'{"key": "value"}')
257
+ with patch("sys.stdin") as mock_stdin:
258
+ mock_stdin.buffer = stdin_data
259
+ cmd_upload(client, args)
260
+
261
+ call_args = client.upload_stream.call_args
262
+ assert call_args[1]["content_type"] == "application/json"
263
+
264
+ def test_upload_stream_and_input_conflict(self):
265
+ client = MagicMock()
266
+ parser = _build_parser()
267
+ args = parser.parse_args(
268
+ ["upload", "test.txt", "--stream", "-i", "file.txt"]
269
+ )
270
+ args.verbose = False
271
+
272
+ stdin_data = io.BytesIO(b"data")
273
+ with patch("sys.stdin") as mock_stdin:
274
+ mock_stdin.buffer = stdin_data
275
+ with pytest.raises(SystemExit):
276
+ cmd_upload(client, args)
277
+
278
+
279
+ class TestLs:
280
+ def test_ls_short(self, capsys):
281
+ client = MagicMock()
282
+ client.list.return_value = iter([_meta("a.txt"), _meta("b.txt")])
283
+
284
+ parser = _build_parser()
285
+ args = parser.parse_args(["ls"])
286
+ cmd_ls(client, args)
287
+
288
+ out = capsys.readouterr().out
289
+ assert "a.txt\n" in out
290
+ assert "b.txt\n" in out
291
+
292
+ def test_ls_long(self, capsys):
293
+ client = MagicMock()
294
+ client.list.return_value = iter([_meta("a.txt", size=1024)])
295
+
296
+ parser = _build_parser()
297
+ args = parser.parse_args(["ls", "-l"])
298
+ cmd_ls(client, args)
299
+
300
+ out = capsys.readouterr().out
301
+ assert "1024" in out
302
+ assert "a.txt" in out
303
+ assert "text/plain" in out
304
+
305
+ def test_ls_with_prefix(self):
306
+ client = MagicMock()
307
+ client.list.return_value = iter([])
308
+
309
+ parser = _build_parser()
310
+ args = parser.parse_args(["ls", "data/"])
311
+ cmd_ls(client, args)
312
+
313
+ client.list.assert_called_once_with("data/", delimiter=None, scope=None)
314
+
315
+
316
+ class TestStat:
317
+ def test_stat(self, capsys):
318
+ client = MagicMock()
319
+ client.stat.return_value = _meta("doc.pdf", size=2048)
320
+
321
+ parser = _build_parser()
322
+ args = parser.parse_args(["stat", "doc.pdf"])
323
+ cmd_stat(client, args)
324
+
325
+ out = capsys.readouterr().out
326
+ assert "doc.pdf" in out
327
+ assert "2048" in out
328
+ assert "text/plain" in out
329
+ assert "v1" in out
330
+
331
+
332
+ class TestDelete:
333
+ def test_delete(self):
334
+ client = MagicMock()
335
+
336
+ parser = _build_parser()
337
+ args = parser.parse_args(["delete", "test.txt"])
338
+ cmd_delete(client, args)
339
+
340
+ client.delete.assert_called_once_with("test.txt", version=None, scope=None)
341
+
342
+ def test_delete_with_version(self):
343
+ client = MagicMock()
344
+
345
+ parser = _build_parser()
346
+ args = parser.parse_args(["delete", "test.txt", "--version", "v2"])
347
+ cmd_delete(client, args)
348
+
349
+ client.delete.assert_called_once_with("test.txt", version="v2", scope=None)
350
+
351
+
352
+ class TestDownloadDir:
353
+ def test_download_dir(self):
354
+ client = MagicMock()
355
+ client.list.return_value = iter([
356
+ _meta("prefix/a.txt", size=10),
357
+ _meta("prefix/sub/b.txt", size=20),
358
+ ])
359
+ client.download_file.return_value = _download_result()
360
+
361
+ with tempfile.TemporaryDirectory() as tmpdir:
362
+ parser = _build_parser()
363
+ args = parser.parse_args(["download-dir", "prefix/", "-o", tmpdir])
364
+ args.verbose = False
365
+ cmd_download_dir(client, args)
366
+
367
+ assert client.download_file.call_count == 2
368
+ calls = client.download_file.call_args_list
369
+ assert calls[0][0][0] == "prefix/a.txt"
370
+ assert calls[1][0][0] == "prefix/sub/b.txt"
371
+
372
+
373
+ class TestUploadDir:
374
+ def test_upload_dir(self):
375
+ client = MagicMock()
376
+ client.upload_file.return_value = _upload_result()
377
+
378
+ with tempfile.TemporaryDirectory() as tmpdir:
379
+ os.makedirs(os.path.join(tmpdir, "sub"))
380
+ with open(os.path.join(tmpdir, "a.txt"), "w") as f:
381
+ f.write("hello")
382
+ with open(os.path.join(tmpdir, "sub", "b.txt"), "w") as f:
383
+ f.write("world")
384
+
385
+ parser = _build_parser()
386
+ args = parser.parse_args(["upload-dir", tmpdir, "-k", "prefix/"])
387
+ args.verbose = False
388
+ cmd_upload_dir(client, args)
389
+
390
+ assert client.upload_file.call_count == 2
391
+ keys = sorted(c[0][0] for c in client.upload_file.call_args_list)
392
+ assert keys == ["prefix/a.txt", "prefix/sub/b.txt"]
393
+
394
+
395
+ class TestErrorHandling:
396
+ def test_auth_error_exit_code(self):
397
+ with patch("riffsdk._cli._run") as mock_run:
398
+ mock_run.side_effect = AuthorisationError("bad token")
399
+ with pytest.raises(SystemExit) as exc_info:
400
+ main()
401
+ assert exc_info.value.code == 2
402
+
403
+ def test_not_found_exit_code(self):
404
+ with patch("riffsdk._cli._run") as mock_run:
405
+ mock_run.side_effect = ObjectNotFoundError(
406
+ "not found", key="missing.txt"
407
+ )
408
+ with pytest.raises(SystemExit) as exc_info:
409
+ main()
410
+ assert exc_info.value.code == 3
411
+
412
+ def test_no_command_exits(self):
413
+ with patch("sys.argv", ["riffsdk"]):
414
+ with pytest.raises(SystemExit) as exc_info:
415
+ main()
416
+ assert exc_info.value.code == 1
417
+
418
+ def test_keyboard_interrupt(self):
419
+ with patch("riffsdk._cli._run") as mock_run:
420
+ mock_run.side_effect = KeyboardInterrupt()
421
+ with pytest.raises(SystemExit) as exc_info:
422
+ main()
423
+ assert exc_info.value.code == 130
@@ -1,21 +0,0 @@
1
- # CHANGELOG
2
-
3
- <!-- version list -->
4
-
5
- ## v0.2.0 (2026-06-01)
6
-
7
- ### Bug Fixes
8
-
9
- - Try to make release work properly ([#6](https://github.com/databutton/riff-sdk-python/pull/6),
10
- [`bc68e3b`](https://github.com/databutton/riff-sdk-python/commit/bc68e3b1e7655024ef2f01254f2afd5229115410))
11
-
12
- ### Features
13
-
14
- - Add text and JSON convenience methods to storage clients
15
- ([#5](https://github.com/databutton/riff-sdk-python/pull/5),
16
- [`b53f219`](https://github.com/databutton/riff-sdk-python/commit/b53f219857bca08e4e71ae3a65fd1a7fb0042d78))
17
-
18
-
19
- ## v0.1.0 (2026-06-01)
20
-
21
- - Initial Release
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes