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 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
@@ -0,0 +1,8 @@
1
+ """Run bucklet with ``python -m bucklet``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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())