bucklet 0.1.0__py3-none-any.whl
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.
- bucklet/__init__.py +18 -0
- bucklet/__main__.py +8 -0
- bucklet/cli.py +534 -0
- bucklet/config.py +251 -0
- bucklet/errors.py +12 -0
- bucklet/formatting.py +87 -0
- bucklet/models.py +153 -0
- bucklet/rclone.py +55 -0
- bucklet/s3.py +234 -0
- bucklet/service.py +263 -0
- bucklet/storage.py +131 -0
- bucklet/tui/__init__.py +5 -0
- bucklet/tui/app.py +797 -0
- bucklet/tui/screens.py +302 -0
- bucklet-0.1.0.dist-info/METADATA +99 -0
- bucklet-0.1.0.dist-info/RECORD +18 -0
- bucklet-0.1.0.dist-info/WHEEL +4 -0
- bucklet-0.1.0.dist-info/entry_points.txt +3 -0
bucklet/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
""" "Manage S3 objects in any storage class."
|
|
2
|
+
|
|
3
|
+
The CLI and the Textual TUI are thin frontends over one UI-agnostic core
|
|
4
|
+
(:mod:`bucklet.service`). The modules underneath it are:
|
|
5
|
+
|
|
6
|
+
bucklet.storage storage-class vocabulary and object-state logic (pure)
|
|
7
|
+
bucklet.models the Profile, ObjectInfo and ObjectStatus dataclasses
|
|
8
|
+
bucklet.rclone reads credentials from an rclone remote
|
|
9
|
+
bucklet.config saved profiles and the default selection
|
|
10
|
+
bucklet.s3 thin boto3 wrappers that raise BuckletError
|
|
11
|
+
bucklet.service the high-level operations both front-ends call
|
|
12
|
+
bucklet.cli the argparse front-end
|
|
13
|
+
bucklet.tui the Textual front-end
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__"]
|
bucklet/__main__.py
ADDED
bucklet/cli.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Command-line front-end.
|
|
2
|
+
|
|
3
|
+
Every subcommand operates on one profile (``--profile NAME``, accepted before or
|
|
4
|
+
after the subcommand; it falls back to the configured default). Running bucklet
|
|
5
|
+
with no subcommand launches the Textual TUI.
|
|
6
|
+
|
|
7
|
+
The CLI covers everything the TUI does, with one deliberate exception: object
|
|
8
|
+
deletion. Deleting is destructive and offered only interactively, in the TUI,
|
|
9
|
+
and only when bucklet is launched with ``--allow-deletion``. There is no delete
|
|
10
|
+
subcommand, by design.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import storage
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .errors import BuckletError
|
|
23
|
+
from .formatting import fmt_date, human, parse_size
|
|
24
|
+
from .models import TUNABLES, Profile
|
|
25
|
+
from .service import Service
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import argcomplete
|
|
29
|
+
from argcomplete.completers import DirectoriesCompleter, FilesCompleter
|
|
30
|
+
|
|
31
|
+
_FILES = FilesCompleter()
|
|
32
|
+
_DIRS = DirectoriesCompleter()
|
|
33
|
+
except ImportError: # tab completion is optional; the CLI works fine without it
|
|
34
|
+
argcomplete = None
|
|
35
|
+
_FILES = _DIRS = None
|
|
36
|
+
|
|
37
|
+
PROG = "bucklet"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _set_completer(action, completer):
|
|
41
|
+
"""Attach a tab-completion source to an argparse action, if argcomplete is installed."""
|
|
42
|
+
if argcomplete is not None and completer is not None:
|
|
43
|
+
action.completer = completer
|
|
44
|
+
return action
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _class_completer(**_):
|
|
48
|
+
"""Complete --class with the canonical storage classes and their aliases."""
|
|
49
|
+
return [c.lower() for c in storage.STORAGE_CLASSES] + [a.lower() for a in storage._ALIASES]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _profile_completer(**_):
|
|
53
|
+
"""Complete --profile with saved profile names (read from the config, no network)."""
|
|
54
|
+
try:
|
|
55
|
+
return Config.load().names()
|
|
56
|
+
except Exception:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser():
|
|
61
|
+
# Shared --profile, added to the top parser and every subparser so it works
|
|
62
|
+
# in either position. SUPPRESS keeps a subparser default from clobbering a
|
|
63
|
+
# value given before the subcommand.
|
|
64
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
65
|
+
_set_completer(
|
|
66
|
+
common.add_argument(
|
|
67
|
+
"--profile",
|
|
68
|
+
metavar="NAME",
|
|
69
|
+
default=argparse.SUPPRESS,
|
|
70
|
+
help="profile to use (a saved name, or a raw bucket name); "
|
|
71
|
+
"defaults to the configured default profile",
|
|
72
|
+
),
|
|
73
|
+
_profile_completer,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
class_list = ", ".join(c.lower() for c in storage.STORAGE_CLASSES)
|
|
77
|
+
class_help = f"storage class (e.g. {class_list})"
|
|
78
|
+
|
|
79
|
+
p = argparse.ArgumentParser(
|
|
80
|
+
prog=PROG, parents=[common], description="Manage S3 objects in any storage class."
|
|
81
|
+
)
|
|
82
|
+
# TUI-only guard: with no subcommand bucklet opens the TUI, and this flag is
|
|
83
|
+
# what unlocks object deletion there. It has no effect on the subcommands.
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--allow-deletion",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="allow deleting objects in the TUI (no effect on the subcommands)",
|
|
88
|
+
)
|
|
89
|
+
sub = p.add_subparsers(dest="cmd")
|
|
90
|
+
|
|
91
|
+
up = sub.add_parser("up", parents=[common], help="upload files/dirs (mirrors absolute path)")
|
|
92
|
+
_set_completer(up.add_argument("paths", nargs="+"), _FILES)
|
|
93
|
+
_set_completer(
|
|
94
|
+
up.add_argument("-c", "--class", dest="storage_class", metavar="CLASS", help=class_help),
|
|
95
|
+
_class_completer,
|
|
96
|
+
)
|
|
97
|
+
up.add_argument("--prefix", default="", help="key prefix to store objects under")
|
|
98
|
+
|
|
99
|
+
get = sub.add_parser("get", parents=[common], help="download objects (globs allowed)")
|
|
100
|
+
get.add_argument("keys", nargs="+")
|
|
101
|
+
_set_completer(
|
|
102
|
+
get.add_argument("-o", "--outdir", default=".", help="output directory (default .)"),
|
|
103
|
+
_DIRS,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
thaw = sub.add_parser("thaw", parents=[common], help="restore archived objects (globs allowed)")
|
|
107
|
+
thaw.add_argument("keys", nargs="+")
|
|
108
|
+
thaw.add_argument(
|
|
109
|
+
"--tier",
|
|
110
|
+
choices=["Bulk", "Standard", "Expedited"],
|
|
111
|
+
default="Bulk",
|
|
112
|
+
help="restore tier (default Bulk, ~48h, cheapest)",
|
|
113
|
+
)
|
|
114
|
+
thaw.add_argument("--standard", action="store_true", help="shortcut for --tier Standard (~12h)")
|
|
115
|
+
thaw.add_argument(
|
|
116
|
+
"--days", type=int, default=7, help="days to keep the restored copy (default 7)"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
ls = sub.add_parser("ls", parents=[common], help="list objects")
|
|
120
|
+
ls.add_argument("prefix", nargs="?", default="")
|
|
121
|
+
ls.add_argument("-l", "--long", action="store_true", help="long format showing class and state")
|
|
122
|
+
ls.add_argument("--search", metavar="TERM", help="only keys containing TERM")
|
|
123
|
+
ls.add_argument(
|
|
124
|
+
"--state",
|
|
125
|
+
choices=[storage.AVAILABLE, storage.COLD, storage.THAWING, storage.THAWED],
|
|
126
|
+
help="only objects in this state (HEADs archived objects to refine)",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
stat = sub.add_parser(
|
|
130
|
+
"stat", parents=[common], help="show detailed status of objects (globs allowed)"
|
|
131
|
+
)
|
|
132
|
+
stat.add_argument("keys", nargs="+")
|
|
133
|
+
|
|
134
|
+
_build_profile_parser(sub, common, class_help)
|
|
135
|
+
return p
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _build_profile_parser(
|
|
139
|
+
sub: argparse._SubParsersAction,
|
|
140
|
+
common: argparse.ArgumentParser,
|
|
141
|
+
class_help: str,
|
|
142
|
+
):
|
|
143
|
+
pf = sub.add_parser("profile", parents=[common], help="manage saved profiles")
|
|
144
|
+
ps = pf.add_subparsers(dest="pcmd")
|
|
145
|
+
|
|
146
|
+
add = ps.add_parser("add", parents=[common], help="add or overwrite a profile")
|
|
147
|
+
add.add_argument("name")
|
|
148
|
+
add.add_argument("--bucket", required=True)
|
|
149
|
+
add.add_argument("--region")
|
|
150
|
+
_set_completer(
|
|
151
|
+
add.add_argument(
|
|
152
|
+
"-c",
|
|
153
|
+
"--class",
|
|
154
|
+
dest="storage_class",
|
|
155
|
+
metavar="CLASS",
|
|
156
|
+
help="default upload " + class_help,
|
|
157
|
+
),
|
|
158
|
+
_class_completer,
|
|
159
|
+
)
|
|
160
|
+
add.add_argument("--access-key", dest="access_key_id")
|
|
161
|
+
add.add_argument("--secret", dest="secret_access_key")
|
|
162
|
+
add.add_argument(
|
|
163
|
+
"--rclone-remote", dest="rclone_remote", help="rclone remote to read credentials from"
|
|
164
|
+
)
|
|
165
|
+
add.add_argument(
|
|
166
|
+
"--endpoint-url", dest="endpoint_url", help="custom S3 endpoint (for S3-compatible storage)"
|
|
167
|
+
)
|
|
168
|
+
add.add_argument("--default", action="store_true", help="make this the default profile")
|
|
169
|
+
|
|
170
|
+
ps.add_parser("ls", parents=[common], help="list saved profiles")
|
|
171
|
+
|
|
172
|
+
rm = ps.add_parser("rm", parents=[common], help="remove a profile")
|
|
173
|
+
rm.add_argument("name")
|
|
174
|
+
|
|
175
|
+
dflt = ps.add_parser("default", parents=[common], help="set the default profile")
|
|
176
|
+
dflt.add_argument("name")
|
|
177
|
+
|
|
178
|
+
show = ps.add_parser("show", parents=[common], help="show a resolved profile")
|
|
179
|
+
show.add_argument("name", nargs="?")
|
|
180
|
+
|
|
181
|
+
tune = ps.add_parser(
|
|
182
|
+
"tune", parents=[common], help="set per-profile transfer tuning (chunk size, concurrency)"
|
|
183
|
+
)
|
|
184
|
+
tune.add_argument("name")
|
|
185
|
+
for t in TUNABLES:
|
|
186
|
+
tune.add_argument(
|
|
187
|
+
"--" + t.key.replace("_", "-"),
|
|
188
|
+
dest=t.key,
|
|
189
|
+
metavar="SIZE" if t.is_size else "N",
|
|
190
|
+
help=f"{t.label} (default {_fmt_tunable(t)})",
|
|
191
|
+
)
|
|
192
|
+
tune.add_argument(
|
|
193
|
+
"--reset",
|
|
194
|
+
nargs="+",
|
|
195
|
+
metavar="FIELD",
|
|
196
|
+
choices=[t.key.replace("_", "-") for t in TUNABLES] + ["all"],
|
|
197
|
+
help="reset the named setting(s) to default ('all' for every one)",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _profile_arg(args: argparse.Namespace) -> str | None:
|
|
202
|
+
return getattr(args, "profile", None)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _open_service(config: Config, args: argparse.Namespace, *, validate: bool = True):
|
|
206
|
+
profile = config.resolve(_profile_arg(args))
|
|
207
|
+
if profile is None or not profile.bucket:
|
|
208
|
+
raise BuckletError(
|
|
209
|
+
"no profile configured. add one with "
|
|
210
|
+
"'bucklet profile add NAME --bucket BUCKET ...', or pass --profile."
|
|
211
|
+
)
|
|
212
|
+
return Service.open(profile, validate=validate)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _UploadProgress:
|
|
216
|
+
"""Single-line aggregate progress for a batch upload (to stderr).
|
|
217
|
+
|
|
218
|
+
Concurrent uploads can't each own a progress line without garbling, so this
|
|
219
|
+
shows one rolling total. Updates are throttled to whole-percent / file
|
|
220
|
+
boundaries to keep the write rate sane across many small files.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self):
|
|
224
|
+
self._last: tuple[int, int] = (-1, -1)
|
|
225
|
+
|
|
226
|
+
def __call__(self, sent: int, total: int, done: int, total_files: int):
|
|
227
|
+
pct = min(100, sent * 100 // total)
|
|
228
|
+
if (pct, done) == self._last:
|
|
229
|
+
return
|
|
230
|
+
self._last = (pct, done)
|
|
231
|
+
sys.stderr.write(
|
|
232
|
+
f"\r {done}/{total_files} files · {human(sent)}/{human(total)} {pct:3d}% "
|
|
233
|
+
)
|
|
234
|
+
sys.stderr.flush()
|
|
235
|
+
|
|
236
|
+
def finish(self):
|
|
237
|
+
sys.stderr.write("\n")
|
|
238
|
+
sys.stderr.flush()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cmd_up(config: Config, args: argparse.Namespace):
|
|
242
|
+
service = _open_service(config, args)
|
|
243
|
+
plan = service.plan_upload(args.paths, prefix=args.prefix)
|
|
244
|
+
if not plan:
|
|
245
|
+
print("nothing to upload.")
|
|
246
|
+
return 0
|
|
247
|
+
cls = service.resolve_storage_class(args.storage_class)
|
|
248
|
+
conc = service.profile.tuning.upload_concurrency
|
|
249
|
+
print(f"uploading {len(plan)} file(s) -> [{cls.lower()}], up to {conc} at a time")
|
|
250
|
+
reporter = _UploadProgress()
|
|
251
|
+
results = service.upload_many(plan, storage_class=args.storage_class, progress=reporter)
|
|
252
|
+
reporter.finish()
|
|
253
|
+
failures = [(key, err) for key, err in results if err is not None]
|
|
254
|
+
for key, err in failures:
|
|
255
|
+
print(f"ERR {key}: {err}")
|
|
256
|
+
ok = len(results) - len(failures)
|
|
257
|
+
summary = f"{ok}/{len(results)} uploaded"
|
|
258
|
+
if failures:
|
|
259
|
+
summary += f", {len(failures)} failed"
|
|
260
|
+
print(summary, file=sys.stderr)
|
|
261
|
+
return 1 if failures else 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_get(config: Config, args: argparse.Namespace):
|
|
265
|
+
service = _open_service(config, args)
|
|
266
|
+
resolution = service.resolve_keys(args.keys)
|
|
267
|
+
for miss in resolution.missing:
|
|
268
|
+
sys.stderr.write(f"no match: {miss}\n")
|
|
269
|
+
if not resolution.matched:
|
|
270
|
+
raise BuckletError("no matching objects.")
|
|
271
|
+
outdir = Path(args.outdir)
|
|
272
|
+
rc = 0
|
|
273
|
+
for key in resolution.matched:
|
|
274
|
+
dest = outdir / key
|
|
275
|
+
try:
|
|
276
|
+
service.download(key, dest)
|
|
277
|
+
print(f"ok {key} -> {dest}")
|
|
278
|
+
except BuckletError as exc:
|
|
279
|
+
print(f"ERR {key}: {exc}")
|
|
280
|
+
rc = 1
|
|
281
|
+
return rc
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_thaw(config: Config, args: argparse.Namespace):
|
|
285
|
+
service = _open_service(config, args)
|
|
286
|
+
tier = "Standard" if args.standard else args.tier
|
|
287
|
+
resolution = service.resolve_keys(args.keys)
|
|
288
|
+
for miss in resolution.missing:
|
|
289
|
+
sys.stderr.write(f"no match: {miss}\n")
|
|
290
|
+
if not resolution.matched:
|
|
291
|
+
raise BuckletError("no matching objects.")
|
|
292
|
+
rc = 0
|
|
293
|
+
for key in resolution.matched:
|
|
294
|
+
try:
|
|
295
|
+
message = service.restore(key, tier=tier, days=args.days)
|
|
296
|
+
print(f"ok {key}: {message}")
|
|
297
|
+
except BuckletError as exc:
|
|
298
|
+
print(f"ERR {key}: {exc}")
|
|
299
|
+
rc = 1
|
|
300
|
+
return rc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def cmd_ls(config: Config, args: argparse.Namespace):
|
|
304
|
+
service = _open_service(config, args)
|
|
305
|
+
objects = service.list_objects(args.prefix or "")
|
|
306
|
+
if args.search:
|
|
307
|
+
term = args.search.lower()
|
|
308
|
+
objects = [o for o in objects if term in o.key.lower()]
|
|
309
|
+
|
|
310
|
+
states: dict[str, str] = {}
|
|
311
|
+
if args.state:
|
|
312
|
+
# A listing never carries the Restore header, so any object that could be
|
|
313
|
+
# archived or restoring needs a HEAD; the rest are known to be available
|
|
314
|
+
# straight from the listing.
|
|
315
|
+
for o in objects:
|
|
316
|
+
if (o.storage_class or "").upper() in storage.RESTORABLE_CLASSES:
|
|
317
|
+
states[o.key] = service.status(o.key).state
|
|
318
|
+
else:
|
|
319
|
+
states[o.key] = o.baseline_state
|
|
320
|
+
objects = [o for o in objects if states.get(o.key) == args.state]
|
|
321
|
+
|
|
322
|
+
if not objects:
|
|
323
|
+
print("(no objects)")
|
|
324
|
+
return 0
|
|
325
|
+
for o in objects:
|
|
326
|
+
if args.long:
|
|
327
|
+
state = states.get(o.key, o.baseline_state)
|
|
328
|
+
label = storage.STATE_LABEL.get(state, "?")
|
|
329
|
+
print(
|
|
330
|
+
f"{human(o.size):>10} {fmt_date(o.last_modified)} "
|
|
331
|
+
f"{o.storage_class:<20} {label:<6} {o.key}"
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
print(o.key)
|
|
335
|
+
total = sum(o.size for o in objects)
|
|
336
|
+
print(f"\n{len(objects)} object(s), {human(total)} total", file=sys.stderr)
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cmd_stat(config: Config, args: argparse.Namespace):
|
|
341
|
+
service = _open_service(config, args)
|
|
342
|
+
resolution = service.resolve_keys(args.keys)
|
|
343
|
+
for miss in resolution.missing:
|
|
344
|
+
sys.stderr.write(f"no match: {miss}\n")
|
|
345
|
+
if not resolution.matched:
|
|
346
|
+
raise BuckletError("no matching objects.")
|
|
347
|
+
for key in resolution.matched:
|
|
348
|
+
st = service.status(key)
|
|
349
|
+
print(key)
|
|
350
|
+
print(f" class : {st.storage_class}")
|
|
351
|
+
print(f" state : {st.state}" + (f" ({st.error})" if st.error else ""))
|
|
352
|
+
if st.size is not None:
|
|
353
|
+
print(f" size : {human(st.size)} ({st.size} bytes)")
|
|
354
|
+
if st.last_modified is not None:
|
|
355
|
+
print(f" modified : {fmt_date(st.last_modified)}")
|
|
356
|
+
if st.restore_expiry:
|
|
357
|
+
print(f" restored : until {st.restore_expiry}")
|
|
358
|
+
if storage.can_thaw(st.state):
|
|
359
|
+
print(" note : archived; run 'bucklet thaw' before downloading")
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cmd_profile(config: Config, args: argparse.Namespace):
|
|
364
|
+
pcmd = getattr(args, "pcmd", None)
|
|
365
|
+
if pcmd == "add":
|
|
366
|
+
return _profile_add(config, args)
|
|
367
|
+
if pcmd == "ls" or pcmd is None:
|
|
368
|
+
return _profile_ls(config)
|
|
369
|
+
if pcmd == "rm":
|
|
370
|
+
config.remove(args.name)
|
|
371
|
+
config.save()
|
|
372
|
+
print(f"removed '{args.name}'")
|
|
373
|
+
return 0
|
|
374
|
+
if pcmd == "default":
|
|
375
|
+
config.set_default(args.name)
|
|
376
|
+
config.save()
|
|
377
|
+
print(f"default -> {args.name}")
|
|
378
|
+
return 0
|
|
379
|
+
if pcmd == "show":
|
|
380
|
+
return _profile_show(config, args)
|
|
381
|
+
if pcmd == "tune":
|
|
382
|
+
return _profile_tune(config, args)
|
|
383
|
+
# Unreachable via the CLI: argparse rejects an unknown subcommand (printing
|
|
384
|
+
# its own usage with the valid choices) long before we get here. No need to
|
|
385
|
+
# restate the command list and let it drift.
|
|
386
|
+
raise BuckletError(f"unknown profile subcommand: {pcmd!r}")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _profile_add(config: Config, args: argparse.Namespace):
|
|
390
|
+
cls = storage.DEFAULT_STORAGE_CLASS
|
|
391
|
+
if args.storage_class:
|
|
392
|
+
cls = storage.normalize_storage_class(args.storage_class)
|
|
393
|
+
# `add` overwrites connection settings, but it has no flags for the transfer
|
|
394
|
+
# tuning (that's `profile tune`'s job), so carry any existing tuning across
|
|
395
|
+
# an overwrite rather than silently wiping it.
|
|
396
|
+
prior = config.stored(args.name) if config.has(args.name) else {}
|
|
397
|
+
profile = Profile(
|
|
398
|
+
name=args.name,
|
|
399
|
+
bucket=args.bucket,
|
|
400
|
+
region=args.region,
|
|
401
|
+
access_key_id=args.access_key_id,
|
|
402
|
+
secret_access_key=args.secret_access_key,
|
|
403
|
+
rclone_remote=args.rclone_remote,
|
|
404
|
+
endpoint_url=args.endpoint_url,
|
|
405
|
+
storage_class=cls,
|
|
406
|
+
multipart_threshold=prior.get("multipart_threshold"),
|
|
407
|
+
multipart_chunksize=prior.get("multipart_chunksize"),
|
|
408
|
+
upload_concurrency=prior.get("upload_concurrency"),
|
|
409
|
+
max_concurrency=prior.get("max_concurrency"),
|
|
410
|
+
)
|
|
411
|
+
config.add(profile, make_default=args.default)
|
|
412
|
+
config.save()
|
|
413
|
+
tag = " (default)" if config.default == profile.name else ""
|
|
414
|
+
print(f"saved profile '{profile.name}'{tag}")
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _profile_ls(config: Config):
|
|
419
|
+
if not config.profiles:
|
|
420
|
+
print("no profiles. add one: bucklet profile add NAME --bucket BUCKET [--class CLASS] ...")
|
|
421
|
+
return 0
|
|
422
|
+
for name in config.names():
|
|
423
|
+
prof = config.get(name)
|
|
424
|
+
marker = "*" if config.default == name else " "
|
|
425
|
+
bucket = prof.bucket or "?"
|
|
426
|
+
region = prof.region or "?"
|
|
427
|
+
print(
|
|
428
|
+
f"{marker} {name:<16} {bucket:<40} {region:<15} "
|
|
429
|
+
f"{prof.storage_class.lower():<14} [{prof.credential_source}]"
|
|
430
|
+
)
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _profile_show(config: Config, args: argparse.Namespace):
|
|
435
|
+
profile = config.resolve(args.name)
|
|
436
|
+
if profile is None:
|
|
437
|
+
raise BuckletError("no such profile (and no default set)")
|
|
438
|
+
archival = storage.needs_restore(profile.storage_class)
|
|
439
|
+
note = " [uploads are archived, need thaw]" if archival else ""
|
|
440
|
+
print(f"profile : {profile.name}")
|
|
441
|
+
print(f"bucket : {profile.bucket or '?'}")
|
|
442
|
+
print(f"region : {profile.region or '(default)'}")
|
|
443
|
+
print(f"class : {profile.storage_class}{note}")
|
|
444
|
+
if profile.endpoint_url:
|
|
445
|
+
print(f"endpoint : {profile.endpoint_url}")
|
|
446
|
+
print(f"creds : {profile.credential_source}")
|
|
447
|
+
if profile.has_explicit_keys:
|
|
448
|
+
print(f"key id : {profile.access_key_id}")
|
|
449
|
+
print("secret : ****")
|
|
450
|
+
_print_tuning(profile)
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _fmt_tunable(t) -> str:
|
|
455
|
+
"""Human form of a tunable's default (a size or a count)."""
|
|
456
|
+
return human(t.default) if t.is_size else str(t.default)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _parse_count(raw: str) -> int:
|
|
460
|
+
try:
|
|
461
|
+
value = int(raw)
|
|
462
|
+
except ValueError as exc:
|
|
463
|
+
raise BuckletError(f"expected a whole number, got {raw!r}") from exc
|
|
464
|
+
if value <= 0:
|
|
465
|
+
raise BuckletError(f"must be positive: {raw!r}")
|
|
466
|
+
return value
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _print_tuning(profile: Profile):
|
|
470
|
+
print("tuning :")
|
|
471
|
+
for t in TUNABLES:
|
|
472
|
+
raw = getattr(profile, t.key)
|
|
473
|
+
effective = t.default if raw is None else raw
|
|
474
|
+
shown = human(effective) if t.is_size else str(effective)
|
|
475
|
+
tag = " (default)" if raw is None else ""
|
|
476
|
+
print(f" {t.label:<20} {shown}{tag}")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _profile_tune(config: Config, args: argparse.Namespace):
|
|
480
|
+
name = args.name
|
|
481
|
+
if not config.has(name):
|
|
482
|
+
raise BuckletError(f"no such profile: {name}")
|
|
483
|
+
stored = config.stored(name)
|
|
484
|
+
resets = set(getattr(args, "reset", None) or [])
|
|
485
|
+
if "all" in resets:
|
|
486
|
+
resets = {t.key.replace("_", "-") for t in TUNABLES}
|
|
487
|
+
changed = False
|
|
488
|
+
for t in TUNABLES:
|
|
489
|
+
raw = getattr(args, t.key, None)
|
|
490
|
+
if raw is not None:
|
|
491
|
+
# An explicit value wins over a reset of the same field.
|
|
492
|
+
stored[t.key] = parse_size(raw) if t.is_size else _parse_count(raw)
|
|
493
|
+
changed = True
|
|
494
|
+
elif t.key.replace("_", "-") in resets:
|
|
495
|
+
if stored.pop(t.key, None) is not None:
|
|
496
|
+
changed = True
|
|
497
|
+
if changed:
|
|
498
|
+
config.save()
|
|
499
|
+
_print_tuning(config.get(name))
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
_HANDLERS = {
|
|
504
|
+
"up": cmd_up,
|
|
505
|
+
"get": cmd_get,
|
|
506
|
+
"thaw": cmd_thaw,
|
|
507
|
+
"ls": cmd_ls,
|
|
508
|
+
"stat": cmd_stat,
|
|
509
|
+
"profile": cmd_profile,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def main(argv: list[str] | None = None):
|
|
514
|
+
parser = build_parser()
|
|
515
|
+
if argcomplete is not None:
|
|
516
|
+
argcomplete.autocomplete(parser)
|
|
517
|
+
args = parser.parse_args(argv)
|
|
518
|
+
try:
|
|
519
|
+
config = Config.load()
|
|
520
|
+
if args.cmd is None:
|
|
521
|
+
from .tui.app import run_tui
|
|
522
|
+
|
|
523
|
+
run_tui(config, _profile_arg(args), allow_deletion=args.allow_deletion)
|
|
524
|
+
return 0
|
|
525
|
+
return _HANDLERS[args.cmd](config, args)
|
|
526
|
+
except BuckletError as exc:
|
|
527
|
+
sys.stderr.write(f"{PROG}: {exc}\n")
|
|
528
|
+
return 1
|
|
529
|
+
except KeyboardInterrupt:
|
|
530
|
+
return 130
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
sys.exit(main())
|