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.
- riffsdk-0.3.0/CHANGELOG.md +39 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/PKG-INFO +1 -1
- {riffsdk-0.2.0 → riffsdk-0.3.0}/pyproject.toml +4 -1
- riffsdk-0.3.0/src/riffsdk/__main__.py +3 -0
- riffsdk-0.3.0/src/riffsdk/_cli.py +323 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_async_client.py +1 -1
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_client.py +1 -1
- riffsdk-0.3.0/tests/test_cli.py +423 -0
- riffsdk-0.2.0/CHANGELOG.md +0 -21
- {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/ci.yml +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/pr-comment.yml +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/.github/workflows/release.yml +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/.gitignore +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/.python-version +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/AGENTS.md +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/CLAUDE.md +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/LICENCE +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/README.md +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/async_client.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/basic_crud.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/file_upload_download.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/examples/optimistic_concurrency.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/mise.toml +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/__init__.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/__init__.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/auth.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/_internal/config.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/__init__.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_http.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_models.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_session.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_streaming.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/_upload.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/exceptions.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/src/riffsdk/storage/py.typed +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/__init__.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_async_client.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_client.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_exceptions.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_http.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_imports.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_integration.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_models.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_session.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_streaming.py +0 -0
- {riffsdk-0.2.0 → riffsdk-0.3.0}/tests/test_upload.py +0 -0
- {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
|
|
@@ -4,11 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "riffsdk"
|
|
7
|
-
version = "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,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)
|
|
@@ -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
|
riffsdk-0.2.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|