asanypath 2.0.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.
asanypath/cli.py ADDED
@@ -0,0 +1,2566 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ """Rich-click powered CLI for AsAnyPath."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import inspect
14
+ import math
15
+ import os
16
+ import shlex
17
+ import stat as statmod
18
+ import sys
19
+ import time
20
+ from collections.abc import AsyncIterator
21
+ from datetime import datetime
22
+ from email.utils import parsedate_to_datetime
23
+ from os import getenv
24
+ from pathlib import Path
25
+
26
+ import rich_click as click
27
+ from rich.console import Console, Group
28
+ from rich.live import Live
29
+ from rich.progress import (
30
+ BarColumn,
31
+ MofNCompleteColumn,
32
+ Progress,
33
+ SpinnerColumn,
34
+ TaskProgressColumn,
35
+ TextColumn,
36
+ TimeElapsedColumn,
37
+ TimeRemainingColumn,
38
+ )
39
+ from rich.table import Table
40
+ from rich.text import Text
41
+
42
+ from asanypath import AsAnyPath
43
+ from asanypath._version import __version__
44
+ from asanypath.asanypath import PROTOCOL_MAP
45
+
46
+ console = Console()
47
+
48
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
49
+
50
+ DEFAULT_CONCURRENCY = 8
51
+
52
+ # Silence the harmless ``RuntimeError: Event loop is closed`` that pooled
53
+ # SSH/FTP subprocess transports raise from ``__del__`` after ``asyncio.run``
54
+ # has already torn down their loop. Only that exact message is suppressed.
55
+ _prev_unraisablehook = sys.unraisablehook
56
+
57
+
58
+ def _filtered_unraisablehook(unraisable): # type: ignore[no-untyped-def]
59
+ exc = unraisable.exc_value
60
+ if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc):
61
+ return
62
+ _prev_unraisablehook(unraisable)
63
+
64
+
65
+ sys.unraisablehook = _filtered_unraisablehook
66
+
67
+
68
+ def _fmt_bytes(n: float) -> str:
69
+ if n < 1024:
70
+ return f"{n:.0f} B"
71
+ for unit in ("KiB", "MiB", "GiB", "TiB"):
72
+ n /= 1024.0
73
+ if n < 1024:
74
+ return f"{n:.1f} {unit}"
75
+ return f"{n:.1f} PiB"
76
+
77
+
78
+ def _fmt_bytes_ratio(transferred: float, total: float) -> tuple[str, str]:
79
+ """Format two byte values with a shared unit. Returns (ratio_str, unit)."""
80
+ if total < 1024:
81
+ return f"{transferred:.0f} / {total:.0f}", "B"
82
+ for unit in ("KiB", "MiB", "GiB", "TiB"):
83
+ transferred_scaled = transferred / (1024 ** (1 + ["KiB", "MiB", "GiB", "TiB"].index(unit)))
84
+ total_scaled = total / (1024 ** (1 + ["KiB", "MiB", "GiB", "TiB"].index(unit)))
85
+ if total_scaled < 1024:
86
+ return f"{transferred_scaled:.1f} / {total_scaled:.1f}", unit
87
+ transferred_scaled = transferred / (1024**5)
88
+ total_scaled = total / (1024**5)
89
+ return f"{transferred_scaled:.1f} / {total_scaled:.1f}", "PiB"
90
+
91
+
92
+ def _make_overall_progress(*, show_transfer: bool = False) -> Progress:
93
+ cols = [
94
+ SpinnerColumn(),
95
+ TextColumn("[progress.description]{task.description}"),
96
+ BarColumn(),
97
+ MofNCompleteColumn(),
98
+ TaskProgressColumn(),
99
+ ]
100
+ if show_transfer:
101
+ cols.extend(
102
+ [
103
+ TextColumn("[cyan]{task.fields[bytes_str]}[/cyan]"),
104
+ TextColumn("[green]{task.fields[speed_str]}[/green]"),
105
+ ]
106
+ )
107
+ cols.extend(
108
+ [
109
+ TextColumn("[dim]elapsed[/dim]"),
110
+ TimeElapsedColumn(),
111
+ TextColumn("[dim]eta[/dim]"),
112
+ TimeRemainingColumn(),
113
+ ]
114
+ )
115
+ return Progress(*cols, console=console, transient=False)
116
+
117
+
118
+ def _make_file_progress(*, show_transfer: bool = False) -> Progress:
119
+ cols = [
120
+ SpinnerColumn(),
121
+ TextColumn("[progress.description]{task.description}"),
122
+ ]
123
+ if show_transfer:
124
+ cols.extend(
125
+ [
126
+ TextColumn("[cyan]{task.fields[bytes_str]}[/cyan]"),
127
+ TextColumn("[green]{task.fields[speed_str]}[/green]"),
128
+ ]
129
+ )
130
+ cols.extend(
131
+ [
132
+ TextColumn("[dim]elapsed[/dim]"),
133
+ TimeElapsedColumn(),
134
+ ]
135
+ )
136
+ return Progress(*cols, console=console, transient=False)
137
+
138
+
139
+ def _fmt_elapsed(seconds: float) -> str:
140
+ if seconds < 1:
141
+ return f"{seconds * 1000:.0f}ms"
142
+ if seconds < 60:
143
+ return f"{seconds:.1f}s"
144
+ m, s = divmod(int(seconds), 60)
145
+ if m < 60:
146
+ return f"{m}m{s:02d}s"
147
+ h, m = divmod(m, 60)
148
+ return f"{h}h{m:02d}m{s:02d}s"
149
+
150
+
151
+ def _task_label(path_like: str, *, max_len: int = 40) -> str:
152
+ """Compact task label: full relative path with leading ellipsis when long."""
153
+ if len(path_like) <= max_len:
154
+ return path_like
155
+ return f"…{path_like[-(max_len - 1) :]}"
156
+
157
+
158
+ class _TransferWork:
159
+ """Wrapper holding both transfer callable and source object for metadata."""
160
+
161
+ def __init__(self, fn, src_obj=None):
162
+ self.fn = fn
163
+ self.src_obj = src_obj
164
+
165
+ async def __call__(self, **kwargs):
166
+ return await self.fn(**kwargs)
167
+
168
+
169
+ async def _run_concurrent(
170
+ items: list[tuple[str, object]],
171
+ *,
172
+ description: str,
173
+ verbosity: int,
174
+ concurrency: int,
175
+ show_transfer: bool = False,
176
+ ) -> tuple[int, list[tuple[str, BaseException]], float]:
177
+ """Run a pre-collected list of work items concurrently."""
178
+
179
+ async def _iter_items() -> AsyncIterator[tuple[str, object]]:
180
+ for item in items:
181
+ yield item
182
+
183
+ return await _run_concurrent_stream(
184
+ _iter_items(),
185
+ description=description,
186
+ verbosity=verbosity,
187
+ concurrency=concurrency,
188
+ show_transfer=show_transfer,
189
+ total=len(items),
190
+ )
191
+
192
+
193
+ async def _run_concurrent_stream(
194
+ items: AsyncIterator[tuple[str, object]],
195
+ *,
196
+ description: str,
197
+ verbosity: int,
198
+ concurrency: int,
199
+ show_transfer: bool = False,
200
+ total: int | None = None,
201
+ ) -> tuple[int, list[tuple[str, BaseException]], float]:
202
+ """Run items from an async stream with up to ``concurrency`` workers."""
203
+ style = (
204
+ "none"
205
+ if verbosity <= 0 or (total is not None and total <= 1)
206
+ else "bar"
207
+ if verbosity == 1
208
+ else "tasks"
209
+ )
210
+ failures: list[tuple[str, BaseException]] = []
211
+ succeeded = 0
212
+ worker_count = max(1, concurrency)
213
+
214
+ overall_progress = (
215
+ _make_overall_progress(show_transfer=show_transfer) if style != "none" else None
216
+ )
217
+ file_progress = _make_file_progress(show_transfer=show_transfer) if style == "tasks" else None
218
+ overall = None
219
+ discovered = 0
220
+ total_bytes = 0
221
+ total_started = time.monotonic()
222
+ file_state: dict[int, dict[str, float]] = {}
223
+ if overall_progress is not None:
224
+ overall_progress.start()
225
+ overall = overall_progress.add_task(
226
+ f"{description}… (j={concurrency})",
227
+ total=total if total is not None else 0,
228
+ bytes_str="0 B",
229
+ speed_str="0 B/s",
230
+ )
231
+ if file_progress is not None:
232
+ file_progress.start()
233
+
234
+ queue: asyncio.Queue[tuple[str, object] | None] = asyncio.Queue(maxsize=worker_count * 2)
235
+
236
+ async def _run_one(name: str, work):
237
+ nonlocal total_bytes
238
+ nonlocal succeeded
239
+ # Extract source object and size if available
240
+ src_obj = None
241
+ src_size = None
242
+ if isinstance(work, _TransferWork):
243
+ src_obj = work.src_obj
244
+ if src_obj is not None:
245
+ src_size = await _get_size(src_obj)
246
+ sub = (
247
+ file_progress.add_task(
248
+ name,
249
+ bytes_str="--",
250
+ speed_str="--",
251
+ )
252
+ if file_progress is not None
253
+ else None
254
+ )
255
+ sub_started = time.monotonic()
256
+ sub_transferred = 0
257
+ if sub is not None:
258
+ file_state[sub] = {
259
+ "started": sub_started,
260
+ "transferred": 0.0,
261
+ "total": float(src_size) if src_size else 0.0,
262
+ }
263
+
264
+ async def _on_progress(delta: int, transferred: int, src, dst):
265
+ nonlocal total_bytes
266
+ nonlocal sub_transferred
267
+ if delta > 0:
268
+ total_bytes += delta
269
+ sub_transferred = max(sub_transferred, transferred)
270
+ if overall_progress is None:
271
+ return
272
+ now = time.monotonic()
273
+ total_elapsed = max(now - total_started, 1e-6)
274
+ total_speed = total_bytes / total_elapsed
275
+ if overall is not None:
276
+ overall_progress.update(
277
+ overall,
278
+ bytes_str=_fmt_bytes(float(total_bytes)),
279
+ speed_str=f"{_fmt_bytes(total_speed)}/s",
280
+ )
281
+ if sub is not None and file_progress is not None:
282
+ src_size = file_state[sub].get("total", 0.0)
283
+ file_state[sub]["transferred"] = float(sub_transferred)
284
+ # Format as "transferred / total" with shared unit
285
+ if src_size > 0:
286
+ ratio_str, unit = _fmt_bytes_ratio(float(transferred), src_size)
287
+ bytes_str = f"{ratio_str} {unit}"
288
+ else:
289
+ bytes_str = _fmt_bytes(float(transferred))
290
+ update_kwargs = {"bytes_str": bytes_str}
291
+ if delta > 0:
292
+ sub_elapsed = max(now - sub_started, 1e-6)
293
+ sub_speed = transferred / sub_elapsed
294
+ update_kwargs["speed_str"] = f"{_fmt_bytes(sub_speed)}/s"
295
+ file_progress.update(sub, **update_kwargs)
296
+
297
+ try:
298
+ if callable(work):
299
+ kwargs = {}
300
+ if show_transfer and overall_progress is not None:
301
+ kwargs["on_progress"] = _on_progress
302
+ try:
303
+ res = work(**kwargs)
304
+ except TypeError as exc:
305
+ # Backward compatibility for callables/mocks that don't
306
+ # accept the optional on_progress callback.
307
+ if kwargs and "on_progress" in str(exc):
308
+ res = work()
309
+ else:
310
+ raise
311
+ if inspect.isawaitable(res):
312
+ await res
313
+ else:
314
+ await work
315
+ succeeded += 1
316
+ except BaseException as exc: # noqa: BLE001
317
+ failures.append((name, exc))
318
+ finally:
319
+ if sub is not None and file_progress is not None:
320
+ file_state.pop(sub, None)
321
+ file_progress.remove_task(sub)
322
+ if overall_progress is not None and overall is not None:
323
+ overall_progress.update(overall, advance=1)
324
+
325
+ refresh_stop = asyncio.Event()
326
+
327
+ async def _refresh_metrics() -> None:
328
+ if not show_transfer or overall_progress is None:
329
+ return
330
+ while not refresh_stop.is_set():
331
+ await asyncio.sleep(0.1)
332
+ now = time.monotonic()
333
+ total_elapsed = max(now - total_started, 1e-6)
334
+ total_speed = total_bytes / total_elapsed
335
+ if overall is not None:
336
+ overall_progress.update(
337
+ overall,
338
+ bytes_str=_fmt_bytes(float(total_bytes)),
339
+ speed_str=f"{_fmt_bytes(total_speed)}/s",
340
+ )
341
+ if file_progress is None:
342
+ continue
343
+ for task_id, state in list(file_state.items()):
344
+ transferred = max(0.0, state["transferred"])
345
+ total = max(0.0, state.get("total", 0.0))
346
+ if transferred <= 0:
347
+ continue
348
+ started = state["started"]
349
+ elapsed = max(now - started, 1e-6)
350
+ speed = transferred / elapsed
351
+ # Format as "transferred / total" with shared unit
352
+ if total > 0:
353
+ ratio_str, unit = _fmt_bytes_ratio(transferred, total)
354
+ bytes_str = f"{ratio_str} {unit}"
355
+ else:
356
+ bytes_str = _fmt_bytes(transferred)
357
+ file_progress.update(
358
+ task_id,
359
+ bytes_str=bytes_str,
360
+ speed_str=f"{_fmt_bytes(speed)}/s",
361
+ )
362
+
363
+ async def _producer() -> None:
364
+ nonlocal discovered
365
+ try:
366
+ async for item in items:
367
+ discovered += 1
368
+ if overall_progress is not None and overall is not None and total is None:
369
+ overall_progress.update(overall, total=discovered)
370
+ await queue.put(item)
371
+ # Keep workers responsive even when the producer can enumerate quickly.
372
+ if discovered % 64 == 0:
373
+ await asyncio.sleep(0)
374
+ finally:
375
+ for _ in range(worker_count):
376
+ await queue.put(None)
377
+
378
+ async def _worker() -> None:
379
+ while True:
380
+ item = await queue.get()
381
+ try:
382
+ if item is None:
383
+ return
384
+ name, work = item
385
+ await _run_one(name, work)
386
+ finally:
387
+ queue.task_done()
388
+
389
+ t0 = time.monotonic()
390
+ producer_task = asyncio.create_task(_producer())
391
+ worker_tasks = [asyncio.create_task(_worker()) for _ in range(worker_count)]
392
+ refresh_task = asyncio.create_task(_refresh_metrics())
393
+ live_ctx = contextlib.nullcontext()
394
+ if overall_progress is not None:
395
+ live_renderable = (
396
+ Group(overall_progress, file_progress)
397
+ if file_progress is not None
398
+ else overall_progress
399
+ )
400
+ live_ctx = Live(
401
+ live_renderable,
402
+ console=console,
403
+ transient=True,
404
+ refresh_per_second=10,
405
+ )
406
+
407
+ try:
408
+ with live_ctx:
409
+ await queue.join()
410
+ await asyncio.gather(*worker_tasks)
411
+ await producer_task
412
+ finally:
413
+ refresh_stop.set()
414
+ refresh_task.cancel()
415
+ with contextlib.suppress(asyncio.CancelledError):
416
+ await refresh_task
417
+ elapsed = (
418
+ overall_progress.tasks[0].elapsed or 0.0
419
+ if overall_progress is not None and overall_progress.tasks
420
+ else time.monotonic() - t0
421
+ )
422
+ if file_progress is not None:
423
+ file_progress.stop()
424
+ if overall_progress is not None:
425
+ overall_progress.stop()
426
+
427
+ for name, exc in failures:
428
+ console.print(f"[red]error[/red] {name}: {exc}")
429
+ return succeeded, failures, elapsed
430
+
431
+
432
+ LS_COLOR_THEMES: dict[str, dict[str, str]] = {
433
+ "default": {
434
+ "root": "bold cyan",
435
+ "name": "bright_white",
436
+ "size": "magenta",
437
+ "created": "yellow",
438
+ "modified": "green",
439
+ "guide": "bright_black",
440
+ },
441
+ "ocean": {
442
+ "root": "bold bright_cyan",
443
+ "name": "bright_white",
444
+ "size": "bright_blue",
445
+ "created": "cyan",
446
+ "modified": "bright_green",
447
+ "guide": "blue",
448
+ },
449
+ "sunset": {
450
+ "root": "bold bright_yellow",
451
+ "name": "bright_white",
452
+ "size": "bright_magenta",
453
+ "created": "bright_red",
454
+ "modified": "bright_yellow",
455
+ "guide": "bright_black",
456
+ },
457
+ }
458
+
459
+ LS_DEPTH_GRADIENTS: dict[str, tuple[str, ...]] = {
460
+ "default": ("bright_white", "bright_cyan", "cyan", "blue", "magenta", "bright_magenta"),
461
+ "ocean": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue", "bright_green"),
462
+ "sunset": (
463
+ "bright_white",
464
+ "bright_yellow",
465
+ "yellow",
466
+ "bright_red",
467
+ "magenta",
468
+ "bright_magenta",
469
+ ),
470
+ }
471
+
472
+
473
+ def mask_token(token: str | None, max_visible: int = 4) -> str:
474
+ """Mask token for safe display, showing only first N chars."""
475
+ if not token:
476
+ return "(not set)"
477
+ if len(token) <= max_visible:
478
+ return token
479
+ return token[:max_visible] + "*" * (len(token) - max_visible)
480
+
481
+
482
+ def _maybe_rewrite_scp_style(value: str, check_config: bool = True) -> str:
483
+ """Rewrite scp-style ``[user@]host:path`` to ``ssh://[user@]host/path``.
484
+
485
+ When ``check_config=True`` (default), only rewrites if ``host`` matches
486
+ an entry in the user's ``~/.ssh/config`` (avoids hijacking local paths
487
+ that happen to contain a colon). When ``check_config=False`` (completion
488
+ mode), rewrites any scp-style prefix optimistically.
489
+ """
490
+ if "://" in value or ":" not in value:
491
+ return value
492
+ head, _, tail = value.partition(":")
493
+ user = None
494
+ host = head
495
+ if "@" in head:
496
+ user, _, host = head.partition("@")
497
+ # Reject Windows drive letters (defensive) and obvious local paths.
498
+ if not host or len(host) == 1 or "/" in host or host.startswith("."):
499
+ return value
500
+ if check_config:
501
+ try:
502
+ from asanypath.ssh import _discover_ssh_config, _resolve_alias
503
+ except ImportError:
504
+ return value
505
+ if not _discover_ssh_config():
506
+ return value
507
+ resolved_host, _, _ = _resolve_alias(host, None, None)
508
+ if resolved_host == host:
509
+ # No Host block matched — leave alone (don't probe DNS).
510
+ return value
511
+ prefix = f"{user}@" if user else ""
512
+ # scp semantics: a leading ``/`` means absolute, anything else (including
513
+ # empty) is relative to the remote home. ``/~`` is translated by
514
+ # SSHPath._item_path to SFTP cwd (= home after login).
515
+ if tail.startswith("/"):
516
+ return f"ssh://{prefix}{host}{tail}"
517
+ return f"ssh://{prefix}{host}/~/{tail}"
518
+
519
+
520
+ def _resolve_path_arg(ctx: click.Context, param: click.Parameter, value: str | None) -> str:
521
+ """Click callback: return value, falling back to a single line read from stdin."""
522
+ if value is not None:
523
+ return _maybe_rewrite_scp_style(value)
524
+ if sys.stdin.isatty():
525
+ raise click.UsageError("Missing argument 'PATH' (or pipe a path via stdin).")
526
+ line = sys.stdin.readline().strip()
527
+ if not line:
528
+ raise click.UsageError("Received empty path from stdin.")
529
+ return _maybe_rewrite_scp_style(line)
530
+
531
+
532
+ def _rewrite_path_arg(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
533
+ """Click callback: apply scp-style rewrite, no stdin fallback."""
534
+ return _maybe_rewrite_scp_style(value) if value is not None else value
535
+
536
+
537
+ def _rewrite_paths_arg(
538
+ ctx: click.Context, param: click.Parameter, value: tuple[str, ...]
539
+ ) -> tuple[str, ...]:
540
+ """Click callback: apply scp-style rewrite to each path, no stdin fallback."""
541
+ return tuple(_maybe_rewrite_scp_style(v) for v in value)
542
+
543
+
544
+ def _resolve_paths_arg(
545
+ ctx: click.Context, param: click.Parameter, value: tuple[str, ...]
546
+ ) -> tuple[str, ...]:
547
+ """Return paths, falling back to reading one path per line from stdin when empty."""
548
+ if value:
549
+ return tuple(_maybe_rewrite_scp_style(v) for v in value)
550
+ if sys.stdin.isatty():
551
+ raise click.UsageError("Missing argument 'PATHS' (or pipe paths via stdin).")
552
+ paths = tuple(_maybe_rewrite_scp_style(line.strip()) for line in sys.stdin if line.strip())
553
+ if not paths:
554
+ raise click.UsageError("Received no paths from stdin.")
555
+ return paths
556
+
557
+
558
+ def _fmt_mode(mode: int) -> str:
559
+ return f"({mode & 0o7777:04o}/{statmod.filemode(mode)})"
560
+
561
+
562
+ def _fmt_ids(uid: int | None, gid: int | None) -> tuple[str, str]:
563
+ uid_name = "?"
564
+ gid_name = "?"
565
+ if uid is not None:
566
+ try:
567
+ import pwd
568
+
569
+ uid_name = pwd.getpwuid(uid).pw_name
570
+ except Exception:
571
+ uid_name = "?"
572
+ if gid is not None:
573
+ try:
574
+ import grp
575
+
576
+ gid_name = grp.getgrgid(gid).gr_name
577
+ except Exception:
578
+ gid_name = "?"
579
+ uid_str = f"({uid:5d}/ {uid_name})" if uid is not None else "( ?/ ?)"
580
+ gid_str = f"({gid:5d}/ {gid_name})" if gid is not None else "( ?/ ?)"
581
+ return uid_str, gid_str
582
+
583
+
584
+ def _fmt_time(ts: float | None, ns: int | None) -> str:
585
+ if ts is None:
586
+ return "-"
587
+ dt = datetime.fromtimestamp(ts).astimezone()
588
+ frac_ns = int(ns % 1_000_000_000) if ns is not None else int((ts % 1) * 1_000_000_000)
589
+ return f"{dt.strftime('%Y-%m-%d %H:%M:%S')}.{frac_ns:09d} {dt.strftime('%z')}"
590
+
591
+
592
+ def _fmt_http_date(value: str, fmt: str = "%Y-%m-%d %H:%M:%S.%f %z") -> str:
593
+ try:
594
+ return parsedate_to_datetime(value).astimezone().strftime(fmt)
595
+ except Exception:
596
+ try:
597
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone().strftime(fmt)
598
+ except Exception:
599
+ return value
600
+
601
+
602
+ # Shell completion infrastructure
603
+ _COMPLETION_CACHE: dict[str, tuple[float, list[str]]] = {}
604
+ _COMPLETION_CACHE_TTL = 30.0
605
+ _COMPLETION_TIMEOUT = 5.0
606
+
607
+
608
+ def _completion_debug(msg: str) -> None:
609
+ """Emit completion diagnostics when explicitly enabled."""
610
+ if os.environ.get("ASANYPATH_COMPLETION_DEBUG"):
611
+ print(f"[asanypath completion] {msg}", file=sys.stderr)
612
+
613
+
614
+ def _ensure_endswith_slash(path: str) -> str:
615
+ """Ensure a path ends with a slash, for completion purposes."""
616
+ return path.rstrip("/") + "/"
617
+
618
+
619
+ async def _complete_path_prefix_async(prefix: str) -> list[str]:
620
+ """Async helper to complete cloud paths. Returns up to 50 candidates."""
621
+ if not prefix or "://" not in prefix:
622
+ return []
623
+
624
+ try:
625
+ p = AsAnyPath(prefix)
626
+ # Get parent directory for enumeration
627
+ parent = p.parent if not prefix.endswith("/") else p
628
+
629
+ async def _enumerate() -> list[str]:
630
+ candidates: list[str] = []
631
+ # Container/bucket/repo fast path: when at a service root the
632
+ # backend lists top-level names directly. These are always
633
+ # "directories", so mark them and skip per-entry is_dir probes.
634
+ list_containers = getattr(parent, "_list_containers", None)
635
+ if list_containers is not None:
636
+ names = await list_containers()
637
+ if names is not None:
638
+ return sorted(map(_ensure_endswith_slash, names))
639
+
640
+ # SSH fast path: use OpenSSH subprocess so that a pre-existing
641
+ # ControlMaster socket makes this near-instant.
642
+ list_dir_remote = getattr(parent, "_list_dir_remote", None)
643
+ if list_dir_remote is not None:
644
+ entries = await list_dir_remote()
645
+ if entries is not None:
646
+ for name, is_dir in entries:
647
+ child = parent / name
648
+ candidate = str(child)
649
+ if is_dir:
650
+ candidate = _ensure_endswith_slash(candidate)
651
+ candidates.append(candidate)
652
+ if len(candidates) >= 50:
653
+ break
654
+ return sorted(candidates)
655
+
656
+ async for child in parent.iterdir():
657
+ candidate = str(child)
658
+ try:
659
+ if await child.is_dir():
660
+ candidate = _ensure_endswith_slash(candidate)
661
+ except Exception:
662
+ # Best effort: keep candidate even if dir detection fails.
663
+ pass
664
+ candidates.append(candidate)
665
+ if len(candidates) >= 50:
666
+ break
667
+ return sorted(candidates)
668
+
669
+ # asyncio.wait_for keeps the timeout compatible with Python 3.10
670
+ # (asyncio.timeout is 3.11+).
671
+ return await asyncio.wait_for(_enumerate(), _COMPLETION_TIMEOUT)
672
+ except FileNotFoundError:
673
+ _completion_debug(f"not found for prefix: {prefix}")
674
+ return []
675
+ except NotImplementedError:
676
+ _completion_debug(f"not implemented for prefix: {prefix}")
677
+ return []
678
+ except asyncio.TimeoutError:
679
+ _completion_debug(f"timeout after {_COMPLETION_TIMEOUT}s for prefix: {prefix}")
680
+ return []
681
+ except Exception as exc:
682
+ _completion_debug(f"error for prefix {prefix!r}: {exc}")
683
+ return []
684
+
685
+
686
+ def _complete_cloud_path(prefix: str) -> list[str]:
687
+ """Complete cloud paths with caching (shallow, max 50 candidates)."""
688
+ if not prefix or "://" not in prefix:
689
+ return []
690
+
691
+ # Check cache
692
+ cache_key = prefix
693
+ if cache_key in _COMPLETION_CACHE:
694
+ cached_time, cached_results = _COMPLETION_CACHE[cache_key]
695
+ if time.time() - cached_time < _COMPLETION_CACHE_TTL:
696
+ return cached_results
697
+
698
+ try:
699
+ results = asyncio.run(_complete_path_prefix_async(prefix))
700
+ _COMPLETION_CACHE[cache_key] = (time.time(), results)
701
+ return results
702
+ except Exception as exc:
703
+ _completion_debug(f"runner failed for prefix {prefix!r}: {exc}")
704
+ return []
705
+
706
+
707
+ def _complete_local_path(prefix: str, limit: int = 50) -> list[str]:
708
+ """Complete local paths for plain tokens (no URI scheme)."""
709
+ if "://" in prefix:
710
+ return []
711
+
712
+ try:
713
+ expanded = os.path.expanduser(prefix)
714
+ sep = os.sep
715
+ altsep = os.altsep
716
+
717
+ has_sep = sep in expanded or (altsep is not None and altsep in expanded)
718
+ if has_sep:
719
+ parent_part, name_part = os.path.split(expanded)
720
+ parent = Path(parent_part) if parent_part else Path(".")
721
+ else:
722
+ parent = Path(".")
723
+ name_part = expanded
724
+
725
+ out: list[str] = []
726
+ for child in parent.iterdir():
727
+ if not child.name.startswith(name_part):
728
+ continue
729
+ suggestion = str(child)
730
+ if child.is_dir() and not suggestion.endswith("/"):
731
+ suggestion += "/"
732
+ out.append(suggestion)
733
+ if len(out) >= limit:
734
+ break
735
+ return sorted(out)
736
+ except Exception:
737
+ return []
738
+
739
+
740
+ def _normalize_completion_prefix(ctx: click.Context, incomplete: str) -> str:
741
+ """Normalize shell-split tokens (notably bash ':' word-break behavior)."""
742
+ if "://" in incomplete:
743
+ return incomplete
744
+
745
+ comp_words = os.environ.get("COMP_WORDS")
746
+ comp_cword = os.environ.get("COMP_CWORD")
747
+ if not comp_words or comp_cword is None:
748
+ return incomplete
749
+
750
+ try:
751
+ words = comp_words.split()
752
+ cword = int(comp_cword)
753
+ except Exception:
754
+ return incomplete
755
+
756
+ if cword <= 0 or cword >= len(words):
757
+ return incomplete
758
+
759
+ current = words[cword]
760
+ previous = words[cword - 1]
761
+
762
+ if "://" in current:
763
+ return current
764
+
765
+ # Bash commonly splits "s3://bucket/prefix" into ["s3", "//bucket/prefix"].
766
+ if current.startswith("//") and previous in PROTOCOL_MAP:
767
+ return f"{previous}:{current}"
768
+
769
+ if previous.endswith("://"):
770
+ return previous + current
771
+
772
+ if previous.endswith(":"):
773
+ scheme = previous[:-1]
774
+ if scheme in PROTOCOL_MAP:
775
+ return previous + current
776
+
777
+ return incomplete
778
+
779
+
780
+ def _path_complete(ctx: click.Context, param: click.Parameter, incomplete: str) -> list[str]:
781
+ """Click shell completion callback for path arguments."""
782
+ if not incomplete:
783
+ return []
784
+
785
+ normalized = _normalize_completion_prefix(ctx, incomplete)
786
+ scp_original = None # Track original scp-style prefix for result conversion.
787
+ if "://" not in normalized:
788
+ # Support scp-style shorthand (for example "cvat:") during completion
789
+ # without requiring SSH config validation (use check_config=False).
790
+ rewritten = _maybe_rewrite_scp_style(normalized, check_config=False)
791
+ if "://" in rewritten:
792
+ scp_original = normalized
793
+ normalized = rewritten
794
+
795
+ if "://" in normalized:
796
+ try:
797
+ # Cloud completion may require network operations
798
+ # Catch any errors to avoid breaking shell completion
799
+ results = _complete_cloud_path(normalized)
800
+ except Exception:
801
+ # On timeout or connection error, return empty
802
+ results = []
803
+
804
+ def _matches(candidate: str, prefix: str) -> bool:
805
+ if candidate.startswith(prefix):
806
+ return True
807
+ if "://" not in candidate or "://" not in prefix:
808
+ return False
809
+
810
+ c_scheme, c_right = candidate.split("://", 1)
811
+ p_scheme, p_right = prefix.split("://", 1)
812
+ if c_scheme != p_scheme:
813
+ return False
814
+
815
+ if c_right.startswith(p_right):
816
+ return True
817
+
818
+ # Some backends canonicalize to a fuller path prefix than the
819
+ # user typed (for example art://repo/... -> art://host/.../repo/...).
820
+ return f"/{p_right}" in f"/{c_right}"
821
+
822
+ def _render(candidate: str, prefix: str) -> str:
823
+ if "://" not in candidate or "://" not in prefix:
824
+ return candidate
825
+ c_scheme, c_right = candidate.split("://", 1)
826
+ p_scheme, p_right = prefix.split("://", 1)
827
+ if c_scheme != p_scheme:
828
+ return candidate
829
+ if p_scheme == "ssh" and scp_original:
830
+ # Convert ssh://host/~/path back to scp-style host:path
831
+ if "@" in c_right:
832
+ user, _, rest = c_right.partition("@")
833
+ host, _, path = rest.partition("/")
834
+ else:
835
+ host, _, path = c_right.partition("/")
836
+ # Extract original host from scp_original (e.g., "cvat:.confi" -> "cvat")
837
+ if "@" in scp_original:
838
+ orig_user, _, orig_rest = scp_original.partition("@")
839
+ orig_host = orig_rest.partition(":")[0]
840
+ orig_user_prefix = f"{orig_user}@"
841
+ else:
842
+ orig_host = scp_original.partition(":")[0]
843
+ orig_user_prefix = ""
844
+ if host == orig_host:
845
+ # Match: convert back to scp format
846
+ if path.startswith("~/"):
847
+ # Relative path from home
848
+ return f"{orig_user_prefix}{orig_host}:{path[2:]}"
849
+ elif path == "~/":
850
+ # Just home
851
+ return f"{orig_user_prefix}{orig_host}:"
852
+ else:
853
+ # Absolute path
854
+ return f"{orig_user_prefix}{orig_host}:{path}"
855
+ if p_scheme != "art":
856
+ return candidate
857
+ marker = f"/{p_right}"
858
+ idx = c_right.find(marker)
859
+ if idx < 0:
860
+ return candidate
861
+ suffix = c_right[idx + len(marker) :]
862
+ return f"{p_scheme}://{p_right}{suffix}"
863
+
864
+ filtered = [_render(r, normalized) for r in results if _matches(r, normalized)]
865
+ return list(dict.fromkeys(filtered))[:50]
866
+
867
+ # Preserve local path completion and also suggest cloud URI schemes.
868
+ local = _complete_local_path(incomplete)
869
+ schemes = [f"{scheme}://" for scheme in sorted(PROTOCOL_MAP) if scheme.startswith(incomplete)]
870
+ merged = list(dict.fromkeys(local + schemes))
871
+ return merged[:50]
872
+
873
+
874
+ @click.group(context_settings=CONTEXT_SETTINGS)
875
+ @click.version_option(version=__version__, message="%(prog)s, version %(version)s")
876
+ def cli():
877
+ """AsAnyPath CLI - unified interface for local and cloud storage.
878
+
879
+ Work with files across many cloud providers and the local
880
+ filesystem using a consistent async-first interface.
881
+
882
+ Shell Completions:
883
+ Path completions (local and cloud) for ls, cp, mv, rm, stat, cat, exists,
884
+ touch, checksums, mkdir, presign, and sync.
885
+ Activate in bash (add to ~/.bashrc to persist): eval "$(asanypath completion bash)"
886
+ """
887
+ pass
888
+
889
+
890
+ # --- Root Inspect-like Commands ---
891
+
892
+
893
+ @cli.command(short_help="List supported protocols", context_settings=CONTEXT_SETTINGS)
894
+ def protocols():
895
+ """Show all supported URI protocols and their implementations."""
896
+ protocol_descriptions = {
897
+ "art": "JFrog Artifactory repositories",
898
+ "az": "Azure Blob Storage containers",
899
+ "file": "Local filesystem paths",
900
+ "ftp": "FTP servers (creds via ~/.netrc)",
901
+ "ftps": "Explicit FTPS (AUTH TLS); creds via ~/.netrc",
902
+ "gs": "Google Cloud Storage buckets",
903
+ "http": "Unauthenticated HTTP resources",
904
+ "https": "HTTPS resources",
905
+ "s3": "Amazon S3 buckets",
906
+ "ssh": "SSH/SFTP servers; aliases honored from ~/.ssh/config",
907
+ }
908
+
909
+ table = Table(
910
+ title="Supported Protocols",
911
+ box=None,
912
+ show_edge=False,
913
+ pad_edge=False,
914
+ expand=False,
915
+ padding=(0, 2),
916
+ )
917
+ table.add_column("PROTOCOL", style="cyan", no_wrap=True)
918
+ table.add_column("EXAMPLE", style="bright_white", no_wrap=True)
919
+ table.add_column("DESCRIPTION", style="green")
920
+
921
+ for scheme, klass in sorted(PROTOCOL_MAP.items()):
922
+ desc = protocol_descriptions.get(scheme, "Backend implementation")
923
+ table.add_row(scheme, f"{scheme}://{klass.__name__}/some_url", desc)
924
+
925
+ with console.capture() as capture:
926
+ console.print(table, width=120)
927
+ click.echo(capture.get())
928
+
929
+
930
+ def _default_completion_exe() -> str:
931
+ """Best-effort executable path used by completion backend calls."""
932
+ argv0 = Path(sys.argv[0]).expanduser()
933
+ if argv0.exists():
934
+ try:
935
+ return str(argv0.resolve())
936
+ except Exception:
937
+ return str(argv0)
938
+
939
+ from shutil import which
940
+
941
+ found = which("asanypath")
942
+ if found:
943
+ return found
944
+ return "asanypath"
945
+
946
+
947
+ @cli.command(
948
+ "completion", short_help="Print shell completion script", context_settings=CONTEXT_SETTINGS
949
+ )
950
+ @click.argument("shell", required=False, default="bash", type=click.Choice(["bash"]))
951
+ @click.option(
952
+ "--exe",
953
+ "exe_path",
954
+ default=None,
955
+ help="Executable path used by completion backend calls (defaults to "
956
+ "current asanypath executable).",
957
+ )
958
+ def completion(shell: str, exe_path: str | None) -> None:
959
+ """Emit a shell completion script for asanypath (bash).
960
+
961
+ Activate it in your current shell:
962
+
963
+ eval "$(asanypath completion bash)"
964
+
965
+ Persist it by adding that same line to your ~/.bashrc.
966
+
967
+ Important: use *this* command — not Click's generic
968
+ 'eval "$(_ASANYPATH_COMPLETE=bash_source asanypath)"'. The generic script
969
+ leaves ':' in COMP_WORDBREAKS, so bash splits URI schemes and cloud paths
970
+ like 's3://bucket/…' or 'art://repo/…' never complete. This script drops
971
+ ':' from the word-break set so the whole URI stays a single token.
972
+
973
+ Tip: 'export ASANYPATH_COMPLETION_DEBUG=1' prints per-TAB diagnostics.
974
+ """
975
+ del shell # currently only bash is supported
976
+ exe = exe_path or _default_completion_exe()
977
+ quoted_exe = shlex.quote(exe)
978
+
979
+ script = f"""# Keep URI schemes (e.g. s3://) as a single completion token.
980
+ COMP_WORDBREAKS=${{COMP_WORDBREAKS//:}}
981
+
982
+ _asanypath_completion() {{
983
+ local IFS=$'\\n'
984
+ local response
985
+ response=$(env COMP_WORDS=\"${{COMP_WORDS[*]}}\" COMP_CWORD=$COMP_CWORD \\
986
+ _ASANYPATH_COMPLETE=bash_complete {quoted_exe})
987
+
988
+ # Default to regular spacing; switch to nospace only for dir-only results.
989
+ compopt +o nospace 2>/dev/null || true
990
+
991
+ COMPREPLY=()
992
+ local has_dir=0
993
+ local has_file=0
994
+ local type value
995
+ for completion in $response; do
996
+ IFS=',' read -r type value <<< \"$completion\"
997
+ if [[ $type == 'dir' ]]; then
998
+ COMPREPLY=()
999
+ compopt -o dirnames
1000
+ elif [[ $type == 'file' ]]; then
1001
+ COMPREPLY=()
1002
+ compopt -o default
1003
+ elif [[ $type == 'plain' ]]; then
1004
+ COMPREPLY+=(\"$value\")
1005
+ if [[ $value == */ ]]; then
1006
+ has_dir=1
1007
+ else
1008
+ has_file=1
1009
+ fi
1010
+ fi
1011
+ done
1012
+
1013
+ if [[ $has_dir -eq 1 && $has_file -eq 0 ]]; then
1014
+ compopt -o nospace
1015
+ else
1016
+ compopt +o nospace 2>/dev/null || true
1017
+ fi
1018
+
1019
+ return 0
1020
+ }}
1021
+
1022
+ complete -o nosort -F _asanypath_completion asanypath
1023
+ """
1024
+ click.echo(script)
1025
+
1026
+
1027
+ @cli.command(short_help="Check if path exists", context_settings=CONTEXT_SETTINGS)
1028
+ @click.argument(
1029
+ "path", type=str, required=False, callback=_resolve_path_arg, shell_complete=_path_complete
1030
+ )
1031
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1032
+ def exists(path: str, token: str | None):
1033
+ """Check if FILE exists at given path.
1034
+
1035
+ PATH can also be supplied via stdin: echo s3://bucket/key | asanypath exists
1036
+
1037
+ Examples:
1038
+ asanypath exists s3://bucket/key
1039
+ asanypath exists art://art.example.com/repo/file --token mytoken
1040
+ """
1041
+
1042
+ async def run():
1043
+ try:
1044
+ kwargs = {}
1045
+ if token:
1046
+ kwargs["token"] = token
1047
+ p = AsAnyPath(path, **kwargs)
1048
+ result = await p.exists()
1049
+ if result:
1050
+ console.print(f"[green]✓[/green] {path} exists")
1051
+ return 0
1052
+ else:
1053
+ console.print(f"[yellow]✗[/yellow] {path} does not exist")
1054
+ return 1
1055
+ except Exception as e:
1056
+ console.print(f"[red]Error:[/red] {e}")
1057
+ return 2
1058
+
1059
+ exit_code = asyncio.run(run())
1060
+ sys.exit(exit_code)
1061
+
1062
+
1063
+ @cli.command(short_help="Get path statistics", context_settings=CONTEXT_SETTINGS)
1064
+ @click.argument(
1065
+ "path", type=str, required=False, callback=_resolve_path_arg, shell_complete=_path_complete
1066
+ )
1067
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1068
+ def stat(path: str, token: str | None):
1069
+ """Display file statistics for PATH.
1070
+
1071
+ PATH can also be supplied via stdin: echo s3://bucket/key | asanypath stat
1072
+
1073
+ Example:
1074
+ asanypath stat s3://bucket/key
1075
+ """
1076
+
1077
+ async def run():
1078
+ try:
1079
+ kwargs = {}
1080
+ if token:
1081
+ kwargs["token"] = token
1082
+ p = AsAnyPath(path, **kwargs)
1083
+
1084
+ try:
1085
+ st = await p.stat()
1086
+ mode = getattr(st, "st_mode", None)
1087
+ ftype = (
1088
+ "directory" if (mode is not None and statmod.S_ISDIR(mode)) else "regular file"
1089
+ )
1090
+ size = getattr(st, "st_size", "?")
1091
+ blocks = getattr(st, "st_blocks", "?")
1092
+ blksize = getattr(st, "st_blksize", "?")
1093
+ dev = getattr(st, "st_dev", None)
1094
+ inode = getattr(st, "st_ino", "?")
1095
+ nlink = getattr(st, "st_nlink", "?")
1096
+ uid = getattr(st, "st_uid", None)
1097
+ gid = getattr(st, "st_gid", None)
1098
+ uid_txt, gid_txt = _fmt_ids(uid, gid)
1099
+ dev_txt = "?"
1100
+ if isinstance(dev, int):
1101
+ try:
1102
+ dev_txt = f"{os.major(dev)},{os.minor(dev)}"
1103
+ except Exception:
1104
+ dev_txt = str(dev)
1105
+ mode_txt = _fmt_mode(mode) if isinstance(mode, int) else "(?/?)"
1106
+ atime = _fmt_time(getattr(st, "st_atime", None), getattr(st, "st_atime_ns", None))
1107
+ mtime = _fmt_time(getattr(st, "st_mtime", None), getattr(st, "st_mtime_ns", None))
1108
+ ctime = _fmt_time(getattr(st, "st_ctime", None), getattr(st, "st_ctime_ns", None))
1109
+ birth_ts = getattr(st, "st_birthtime", None)
1110
+ birth_ns = getattr(st, "st_birthtime_ns", None)
1111
+ birth = _fmt_time(birth_ts, birth_ns) if birth_ts is not None else "-"
1112
+ except NotImplementedError:
1113
+ # Fallback for HTTPPath which lacks stat(): scrape metadata from HEAD.
1114
+ try:
1115
+ resp = await p.request("HEAD", url=str(p))
1116
+ head_headers = resp.headers
1117
+ except Exception:
1118
+ raise NotImplementedError(
1119
+ "stat() not implemented for general HTTPPaths"
1120
+ ) from None
1121
+ headers = {k.lower(): v for k, v in head_headers.items()}
1122
+ size = headers.get("content-length", "?")
1123
+ if isinstance(size, str) and size.isdigit():
1124
+ size_int = int(size)
1125
+ size = size_int
1126
+ blocks = math.ceil(size_int / 512) if size_int > 0 else 0
1127
+ else:
1128
+ blocks = "?"
1129
+ blksize = "?"
1130
+ ftype = "regular file"
1131
+ dev_txt = "?"
1132
+ inode = headers.get("etag", headers.get("x-amz-version-id", "?"))
1133
+ nlink = "?"
1134
+ uid_txt, gid_txt = "( ?/ ?)", "( ?/ ?)"
1135
+ mode_txt = "(?/?)"
1136
+ access_raw = headers.get("date")
1137
+ modify_raw = headers.get("last-modified")
1138
+ atime = _fmt_http_date(access_raw) if access_raw else "-"
1139
+ mtime = _fmt_http_date(modify_raw) if modify_raw else "-"
1140
+ ctime = mtime if mtime != "-" else atime
1141
+ birth = "-"
1142
+
1143
+ console.print(f" File: {path}")
1144
+ console.print(f" Size: {size:<14} Blocks: {blocks:<10} IO Block: {blksize:<6} {ftype}")
1145
+ console.print(f"Device: {dev_txt:<8} Inode: {inode:<11} Links: {nlink}")
1146
+ console.print(f"Access: {mode_txt} Uid: {uid_txt} Gid: {gid_txt}")
1147
+ console.print(f"Access: {atime}")
1148
+ console.print(f"Modify: {mtime}")
1149
+ console.print(f"Change: {ctime}")
1150
+ console.print(f" Birth: {birth}")
1151
+ return 0
1152
+ except NotImplementedError as e:
1153
+ console.print(f"[yellow]Not implemented:[/yellow] {e}")
1154
+ return 1
1155
+ except Exception as e:
1156
+ console.print(f"[red]Error:[/red] {e}")
1157
+ return 2
1158
+
1159
+ exit_code = asyncio.run(run())
1160
+ sys.exit(exit_code)
1161
+
1162
+
1163
+ # --- Read Commands ---
1164
+
1165
+
1166
+ @cli.command("cat", short_help="Print file contents", context_settings=CONTEXT_SETTINGS)
1167
+ @click.argument(
1168
+ "path", type=str, required=False, callback=_resolve_path_arg, shell_complete=_path_complete
1169
+ )
1170
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1171
+ @click.option("--binary", is_flag=True, help="Output as hex dump instead of text")
1172
+ def cat(path: str, token: str | None, binary: bool):
1173
+ """Read and print file contents.
1174
+
1175
+ PATH can also be supplied via stdin: echo s3://bucket/file | asanypath cat
1176
+
1177
+ Examples:
1178
+ asanypath cat s3://bucket/file.txt
1179
+ asanypath cat art://art.example.com/repo/file --token mytoken
1180
+ """
1181
+
1182
+ async def run():
1183
+ try:
1184
+ kwargs = {}
1185
+ if token:
1186
+ kwargs["token"] = token
1187
+ p = AsAnyPath(path, **kwargs)
1188
+
1189
+ if binary:
1190
+ data = await p.read_bytes()
1191
+ console.print(data.hex())
1192
+ else:
1193
+ text = await p.read_text()
1194
+ console.print(text, end="")
1195
+ return 0
1196
+ except NotImplementedError:
1197
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1198
+ return 1
1199
+ except FileNotFoundError:
1200
+ console.print(f"[red]Error:[/red] {path} not found")
1201
+ return 1
1202
+ except Exception as e:
1203
+ console.print(f"[red]Error:[/red] {e}")
1204
+ return 2
1205
+
1206
+ exit_code = asyncio.run(run())
1207
+ sys.exit(exit_code)
1208
+
1209
+
1210
+ @cli.command("ls", short_help="List directory contents", context_settings=CONTEXT_SETTINGS)
1211
+ @click.argument(
1212
+ "path", default=".", required=False, callback=_rewrite_path_arg, shell_complete=_path_complete
1213
+ )
1214
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1215
+ @click.option("--recursive", "-r", is_flag=True, help="Recursively list subtree as a tree")
1216
+ @click.option(
1217
+ "--depth",
1218
+ "-d",
1219
+ default=None,
1220
+ type=click.IntRange(min=1),
1221
+ help="Max recursion depth (implies -r)",
1222
+ )
1223
+ @click.option(
1224
+ "--long", "-l", "long_format", is_flag=True, help="Show size/created/modified metadata"
1225
+ )
1226
+ @click.option(
1227
+ "--simple", "-1", "simple", is_flag=True, help="Output one path per line (for piping)"
1228
+ )
1229
+ @click.option(
1230
+ "--color",
1231
+ default="default",
1232
+ help="Set tree color theme name; use 'off' to disable colors.",
1233
+ )
1234
+ @click.option(
1235
+ "--listing",
1236
+ default=None,
1237
+ help="HTTP directory-listing mode: 'python', 'apache', 'nginx', or a CSS selector.",
1238
+ )
1239
+ @click.option(
1240
+ "--listing-attr",
1241
+ default=None,
1242
+ help="HTML attribute holding the link URL (default: 'href').",
1243
+ )
1244
+ def ls(
1245
+ path: str,
1246
+ token: str | None,
1247
+ recursive: bool,
1248
+ depth: int | None,
1249
+ long_format: bool,
1250
+ simple: bool,
1251
+ color: str,
1252
+ listing: str | None,
1253
+ listing_attr: str | None,
1254
+ ):
1255
+ """List directory contents.
1256
+
1257
+ Defaults to the current directory if no PATH is given.
1258
+ Use -1/--simple for plain one-path-per-line output suitable for piping.
1259
+
1260
+ Examples:
1261
+ asanypath ls
1262
+ asanypath ls s3://bucket/prefix/
1263
+ asanypath ls -1 s3://bucket/prefix/ | xargs asanypath cat
1264
+ asanypath ls -r s3://bucket/prefix/
1265
+ asanypath ls -r -d 2 s3://bucket/prefix/
1266
+ asanypath ls -r --color ocean s3://bucket/prefix/
1267
+ asanypath ls art://art.example.com/repo/ --token mytoken
1268
+ """
1269
+
1270
+ async def run():
1271
+ try:
1272
+ if depth is not None:
1273
+ recursive_depth = depth
1274
+ elif recursive:
1275
+ recursive_depth = -1
1276
+ else:
1277
+ recursive_depth = 0
1278
+
1279
+ kwargs = {}
1280
+ if token:
1281
+ kwargs["token"] = token
1282
+ if listing:
1283
+ kwargs["listing"] = listing
1284
+ if listing_attr:
1285
+ kwargs["listing_attr"] = listing_attr
1286
+ p = AsAnyPath(path, **kwargs)
1287
+
1288
+ color_mode = color.strip().lower()
1289
+ colors_enabled = color_mode != "off"
1290
+ palette = LS_COLOR_THEMES.get(color_mode, LS_COLOR_THEMES["default"])
1291
+ depth_gradient = LS_DEPTH_GRADIENTS.get(color_mode, LS_DEPTH_GRADIENTS["default"])
1292
+
1293
+ def sty(key: str) -> str | None:
1294
+ if not colors_enabled:
1295
+ return None
1296
+ return palette.get(key)
1297
+
1298
+ def depth_sty(depth: int, *, bold: bool = False) -> str | None:
1299
+ if not colors_enabled:
1300
+ return None
1301
+ style = depth_gradient[min(depth, len(depth_gradient) - 1)]
1302
+ return f"bold {style}" if bold else style
1303
+
1304
+ async def _entry_metadata(item: AsAnyPath) -> dict[str, str]:
1305
+ if not long_format:
1306
+ return {"size": "", "created": "", "modified": ""}
1307
+ size_val = ""
1308
+ created_val = ""
1309
+ modified_val = ""
1310
+
1311
+ try:
1312
+ st = await item.stat()
1313
+ st_size = getattr(st, "st_size", None)
1314
+ st_ctime = getattr(st, "st_ctime", None)
1315
+ st_mtime = getattr(st, "st_mtime", None)
1316
+
1317
+ if isinstance(st_size, int):
1318
+ size_val = str(st_size)
1319
+ if isinstance(st_ctime, (int, float)):
1320
+ created_val = datetime.fromtimestamp(st_ctime).strftime("%Y-%m-%d %H:%M:%S")
1321
+ if isinstance(st_mtime, (int, float)):
1322
+ modified_val = datetime.fromtimestamp(st_mtime).strftime(
1323
+ "%Y-%m-%d %H:%M:%S"
1324
+ )
1325
+ except Exception:
1326
+ pass
1327
+
1328
+ return {
1329
+ "size": size_val,
1330
+ "created": created_val,
1331
+ "modified": modified_val,
1332
+ }
1333
+
1334
+ async def _children(path_obj: AsAnyPath) -> list[AsAnyPath]:
1335
+ return sorted([child async for child in path_obj.iterdir()], key=lambda x: x.name)
1336
+
1337
+ async def _collect(
1338
+ path_obj: AsAnyPath, depth: int = 0, branch_state: tuple[bool, ...] = ()
1339
+ ):
1340
+ children = await _children(path_obj)
1341
+ for index, child in enumerate(children):
1342
+ is_last = index == len(children) - 1
1343
+ state = branch_state + (is_last,)
1344
+ child_is_dir: bool | None = None
1345
+ should_descend = recursive_depth != 0 and (
1346
+ recursive_depth < 0 or depth + 1 < recursive_depth
1347
+ )
1348
+ if should_descend or getattr(child, "protocol", None) == "s3":
1349
+ try:
1350
+ child_is_dir = await child.is_dir()
1351
+ except Exception:
1352
+ child_is_dir = None
1353
+
1354
+ metadata = await _entry_metadata(child)
1355
+ yield {
1356
+ "item": child,
1357
+ "is_dir": child_is_dir,
1358
+ "metadata": metadata,
1359
+ "branch_state": state,
1360
+ }
1361
+ if should_descend and child_is_dir:
1362
+ async for row in _collect(child, depth + 1, state):
1363
+ yield row
1364
+
1365
+ def _tree_text(name: str, branch_state: tuple[bool, ...], is_dir: bool | None) -> Text:
1366
+ if not branch_state:
1367
+ return Text(name, style=depth_sty(0, bold=bool(is_dir)) or sty("name"))
1368
+
1369
+ text = Text()
1370
+ for depth_index, ancestor_is_last in enumerate(branch_state[:-1], start=1):
1371
+ if ancestor_is_last:
1372
+ text.append(" ")
1373
+ else:
1374
+ text.append("│ ", style=depth_sty(depth_index) or sty("guide"))
1375
+
1376
+ branch_depth = len(branch_state)
1377
+ branch_glyph = "└── " if branch_state[-1] else "├── "
1378
+ text.append(branch_glyph, style=depth_sty(branch_depth) or sty("guide"))
1379
+ text.append(name, style=depth_sty(branch_depth, bold=bool(is_dir)) or sty("name"))
1380
+ return text
1381
+
1382
+ rows = [row async for row in _collect(p)]
1383
+ found_any = bool(rows)
1384
+
1385
+ if not found_any:
1386
+ console.print("[dim](empty)[/dim]")
1387
+ return 0
1388
+
1389
+ if simple:
1390
+ for row in rows:
1391
+ click.echo(str(row["item"]))
1392
+ return 0
1393
+
1394
+ table = Table(
1395
+ box=None,
1396
+ show_edge=False,
1397
+ pad_edge=False,
1398
+ expand=False,
1399
+ padding=(0, 1),
1400
+ )
1401
+ table.add_column("NAME", style=sty("name"), header_style=sty("name"), no_wrap=True)
1402
+ if long_format:
1403
+ table.add_column(
1404
+ "SIZE",
1405
+ style=sty("size"),
1406
+ header_style=sty("size"),
1407
+ justify="right",
1408
+ no_wrap=True,
1409
+ )
1410
+ table.add_column(
1411
+ "CREATED",
1412
+ style=sty("created"),
1413
+ header_style=sty("created"),
1414
+ justify="right",
1415
+ no_wrap=True,
1416
+ )
1417
+ table.add_column(
1418
+ "MODIFIED",
1419
+ style=sty("modified"),
1420
+ header_style=sty("modified"),
1421
+ justify="right",
1422
+ no_wrap=True,
1423
+ )
1424
+
1425
+ console.print(Text(path, style=sty("root")))
1426
+ for row in rows:
1427
+ cols = [_tree_text(row["item"].name, row["branch_state"], row["is_dir"])]
1428
+ if long_format:
1429
+ cols.extend(
1430
+ [
1431
+ row["metadata"]["size"],
1432
+ row["metadata"]["created"],
1433
+ row["metadata"]["modified"],
1434
+ ]
1435
+ )
1436
+ table.add_row(*cols)
1437
+ console.print(table)
1438
+
1439
+ return 0
1440
+ except NotImplementedError:
1441
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1442
+ return 1
1443
+ except NotADirectoryError:
1444
+ console.print(f"[red]Error:[/red] {path} is not a directory")
1445
+ return 1
1446
+ except FileNotFoundError:
1447
+ console.print(f"[red]Error:[/red] {path} not found")
1448
+ return 1
1449
+ except Exception as e:
1450
+ console.print(f"[red]Error:[/red] {e}")
1451
+ return 2
1452
+
1453
+ exit_code = asyncio.run(run())
1454
+ sys.exit(exit_code)
1455
+
1456
+
1457
+ @cli.command("touch", short_help="Create empty file", context_settings=CONTEXT_SETTINGS)
1458
+ @click.argument(
1459
+ "path", type=str, required=False, callback=_resolve_path_arg, shell_complete=_path_complete
1460
+ )
1461
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1462
+ def touch(path: str, token: str | None):
1463
+ """Create an empty file or update its timestamp.
1464
+
1465
+ PATH can also be supplied via stdin: echo s3://bucket/file | asanypath touch
1466
+
1467
+ Examples:
1468
+ asanypath touch s3://bucket/file.txt
1469
+ asanypath touch art://repo/path/file.txt --token mytoken
1470
+ """
1471
+
1472
+ async def run():
1473
+ try:
1474
+ kwargs = {}
1475
+ if token:
1476
+ kwargs["token"] = token
1477
+ p = AsAnyPath(path, **kwargs)
1478
+ await p.touch(exist_ok=True)
1479
+ console.print(f"[green]\u2713[/green] Touched {path}")
1480
+ return 0
1481
+ except NotImplementedError:
1482
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1483
+ return 1
1484
+ except Exception as e:
1485
+ console.print(f"[red]Error:[/red] {e}")
1486
+ return 2
1487
+
1488
+ exit_code = asyncio.run(run())
1489
+ sys.exit(exit_code)
1490
+
1491
+
1492
+ @cli.command("checksums", short_help="Show file checksums", context_settings=CONTEXT_SETTINGS)
1493
+ @click.argument(
1494
+ "path", type=str, required=False, callback=_resolve_path_arg, shell_complete=_path_complete
1495
+ )
1496
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1497
+ def checksums(path: str, token: str | None):
1498
+ """Display checksums for a file.
1499
+
1500
+ PATH can also be supplied via stdin: echo s3://bucket/file | asanypath checksums
1501
+
1502
+ Shows available hash digests (md5, sha1, sha256, etc.) depending on the
1503
+ backend.
1504
+
1505
+ Examples:
1506
+ asanypath checksums s3://bucket/file.txt
1507
+ asanypath checksums /local/file.txt
1508
+ """
1509
+
1510
+ async def run():
1511
+ try:
1512
+ kwargs = {}
1513
+ if token:
1514
+ kwargs["token"] = token
1515
+ p = AsAnyPath(path, **kwargs)
1516
+ result = await p.checksums()
1517
+ if not result:
1518
+ console.print("[yellow]No checksums available[/yellow]")
1519
+ return 0
1520
+ for algo, value in sorted(result.items()):
1521
+ console.print(f" {algo}: {value}")
1522
+ return 0
1523
+ except NotImplementedError:
1524
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1525
+ return 1
1526
+ except FileNotFoundError:
1527
+ console.print(f"[red]Error:[/red] {path} not found")
1528
+ return 1
1529
+ except Exception as e:
1530
+ console.print(f"[red]Error:[/red] {e}")
1531
+ return 2
1532
+
1533
+ exit_code = asyncio.run(run())
1534
+ sys.exit(exit_code)
1535
+
1536
+
1537
+ # --- Mutate Commands ---
1538
+
1539
+
1540
+ @cli.command("mkdir", short_help="Create directory", context_settings=CONTEXT_SETTINGS)
1541
+ @click.argument("path", type=str, required=False, callback=_resolve_path_arg)
1542
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1543
+ @click.option("--parents", "-p", is_flag=True, help="Create parent directories")
1544
+ @click.option("--exist-ok", is_flag=True, help="Don't error if directory exists")
1545
+ def mkdir(path: str, token: str | None, parents: bool, exist_ok: bool):
1546
+ """Create directory at PATH.
1547
+
1548
+ PATH can also be supplied via stdin: echo s3://bucket/newdir | asanypath mkdir
1549
+
1550
+ Examples:
1551
+ asanypath mkdir s3://bucket/newdir
1552
+ asanypath mkdir s3://bucket/a/b/c --parents
1553
+ """
1554
+
1555
+ async def run():
1556
+ try:
1557
+ kwargs = {}
1558
+ if token:
1559
+ kwargs["token"] = token
1560
+ p = AsAnyPath(path, **kwargs)
1561
+
1562
+ await p.mkdir(parents=parents, exist_ok=exist_ok)
1563
+ console.print(f"[green]✓[/green] Created {path}")
1564
+ return 0
1565
+ except NotImplementedError:
1566
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1567
+ return 1
1568
+ except FileExistsError:
1569
+ if exist_ok:
1570
+ console.print(f"[yellow]→[/yellow] {path} already exists")
1571
+ return 0
1572
+ console.print(f"[red]Error:[/red] {path} already exists")
1573
+ return 1
1574
+ except Exception as e:
1575
+ console.print(f"[red]Error:[/red] {e}")
1576
+ return 2
1577
+
1578
+ exit_code = asyncio.run(run())
1579
+ sys.exit(exit_code)
1580
+
1581
+
1582
+ @cli.command("rm", short_help="Delete file", context_settings=CONTEXT_SETTINGS)
1583
+ @click.argument(
1584
+ "paths", nargs=-1, type=str, callback=_resolve_paths_arg, shell_complete=_path_complete
1585
+ )
1586
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1587
+ @click.option("--force", "-f", is_flag=True, help="Don't error if file doesn't exist")
1588
+ @click.option("--recursive", "recursive", "-r", is_flag=True, help="Remove directories recursively")
1589
+ @click.option("--verbose", "verbose", "-v", is_flag=True, help="Print each removed path")
1590
+ def rm(paths: tuple[str, ...], token: str | None, force: bool, recursive: bool, verbose: bool):
1591
+ """Delete files or directories.
1592
+
1593
+ Paths can also be supplied via stdin (one per line):
1594
+ asanypath ls -1 s3://bucket/prefix/ | asanypath rm
1595
+
1596
+ Examples:
1597
+ asanypath rm s3://bucket/file.txt
1598
+ asanypath rm -r s3://bucket/prefix/
1599
+ asanypath rm a.txt b.txt c.txt
1600
+ asanypath rm art://art.example.com/repo/file --token mytoken --force
1601
+ """
1602
+
1603
+ async def run():
1604
+ try:
1605
+ kwargs = {}
1606
+ if token:
1607
+ kwargs["token"] = token
1608
+
1609
+ path_objs = [AsAnyPath(path, **kwargs) for path in paths]
1610
+ for p in path_objs:
1611
+ exists, is_dir = await _exists_or_virtual_dir(p)
1612
+ if not exists and force:
1613
+ if verbose:
1614
+ console.print(f"removed '{p}'")
1615
+ continue
1616
+ if not exists:
1617
+ raise FileNotFoundError(2, f"No such file or directory: '{p}'")
1618
+ if is_dir and not recursive:
1619
+ raise IsADirectoryError(21, f"cannot remove '{p}': Is a directory")
1620
+
1621
+ if is_dir:
1622
+ await p.rmdir(recursive=True)
1623
+ if verbose:
1624
+ console.print(f"removed '{p}'")
1625
+ else:
1626
+ await p.unlink(missing_ok=force)
1627
+ if verbose:
1628
+ console.print(f"removed '{p}'")
1629
+
1630
+ if not verbose:
1631
+ console.print(f"[green]✓[/green] Deleted {len(paths)} path(s)")
1632
+ return 0
1633
+ except NotImplementedError:
1634
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1635
+ return 1
1636
+ except (FileNotFoundError, IsADirectoryError) as e:
1637
+ console.print(f"[red]Error:[/red] {e}")
1638
+ return 1
1639
+ except Exception as e:
1640
+ console.print(f"[red]Error:[/red] {e}")
1641
+ return 2
1642
+
1643
+ exit_code = asyncio.run(run())
1644
+ sys.exit(exit_code)
1645
+
1646
+
1647
+ @cli.command("mv", short_help="Move/rename file", context_settings=CONTEXT_SETTINGS)
1648
+ @click.argument(
1649
+ "paths", nargs=-1, type=str, callback=_rewrite_paths_arg, shell_complete=_path_complete
1650
+ )
1651
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1652
+ @click.option("--force", "force", is_flag=True, help="Overwrite differing existing destinations")
1653
+ @click.option(
1654
+ "--atomic",
1655
+ "atomic",
1656
+ is_flag=True,
1657
+ help="Stage local destination writes before finalizing",
1658
+ )
1659
+ @click.option(
1660
+ "--chunk-size",
1661
+ default=None,
1662
+ type=click.IntRange(min=0),
1663
+ help="Streaming chunk size for local-destination transfers (default: auto; 0 disables)",
1664
+ )
1665
+ @click.option(
1666
+ "--verbose",
1667
+ "verbosity",
1668
+ "-v",
1669
+ count=True,
1670
+ help="Show progress (-v=overall bar, -vv=per-file rows)",
1671
+ )
1672
+ @click.option(
1673
+ "--concurrency",
1674
+ "-j",
1675
+ default=DEFAULT_CONCURRENCY,
1676
+ type=click.IntRange(min=1),
1677
+ help=f"Maximum parallel moves (default: {DEFAULT_CONCURRENCY})",
1678
+ )
1679
+ def mv(
1680
+ paths: tuple[str, ...],
1681
+ token: str | None,
1682
+ force: bool,
1683
+ atomic: bool,
1684
+ chunk_size: int | None,
1685
+ verbosity: int,
1686
+ concurrency: int,
1687
+ ):
1688
+ """Move/rename files or directories.
1689
+
1690
+ Destination defaults to the current directory when omitted.
1691
+
1692
+ Example:
1693
+ asanypath mv s3://bucket/file.txt
1694
+ asanypath mv s3://bucket/old.txt s3://bucket/new.txt
1695
+ asanypath mv a.txt b.txt destdir/
1696
+ """
1697
+
1698
+ async def run():
1699
+ try:
1700
+ if len(paths) < 1:
1701
+ raise click.UsageError("mv expects at least one SRC argument.")
1702
+
1703
+ src_paths = list(paths[:-1]) if len(paths) > 1 else list(paths)
1704
+ dst_path_raw = paths[-1] if len(paths) > 1 else "."
1705
+
1706
+ kwargs = {}
1707
+ if token:
1708
+ kwargs["token"] = token
1709
+
1710
+ src_objs = [AsAnyPath(src, **kwargs) for src in src_paths]
1711
+ dst_obj = AsAnyPath(dst_path_raw, **kwargs)
1712
+
1713
+ dst_exists, dst_is_dir = await _exists_or_virtual_dir(dst_obj)
1714
+ # A trailing separator ("dir/", or a bare "host:" that expands to
1715
+ # "ssh://host/~/") means move INTO the directory even if it doesn't
1716
+ # exist yet — matches mv/scp semantics.
1717
+ dst_is_explicit_dir = dst_path_raw.rstrip().endswith(("/", os.sep))
1718
+ dst_is_dir = dst_is_dir or dst_is_explicit_dir
1719
+
1720
+ if len(src_objs) > 1 and not dst_is_dir:
1721
+ await dst_obj.mkdir(parents=True, exist_ok=True)
1722
+ dst_is_dir = True
1723
+
1724
+ items: list[tuple[str, object]] = []
1725
+ for src in src_objs:
1726
+ src_exists, src_is_dir = await _exists_or_virtual_dir(src)
1727
+ if not src_exists:
1728
+ raise FileNotFoundError(2, f"No such file or directory: '{src}'")
1729
+ if len(src_objs) > 1:
1730
+ target = dst_obj / src.name
1731
+ elif src_is_dir:
1732
+ target = (dst_obj / src.name) if dst_exists and dst_is_dir else dst_obj
1733
+ else:
1734
+ target = (dst_obj / src.name) if dst_is_dir else dst_obj
1735
+
1736
+ kw = {
1737
+ "remove_src": True,
1738
+ "force": force,
1739
+ "atomic": atomic,
1740
+ "chunk_size": chunk_size,
1741
+ }
1742
+ if src_is_dir:
1743
+ kw["recursive"] = True
1744
+
1745
+ items.append(
1746
+ (
1747
+ src.name,
1748
+ _TransferWork(
1749
+ _make_simple_copy_work(src, target, verbosity, copy_kwargs=kw),
1750
+ src,
1751
+ ),
1752
+ )
1753
+ )
1754
+
1755
+ async def _iter_move_items() -> AsyncIterator[tuple[str, object]]:
1756
+ for item in items:
1757
+ yield item
1758
+
1759
+ succeeded, failures, elapsed = await _run_concurrent_stream(
1760
+ _iter_move_items(),
1761
+ description="Moving",
1762
+ verbosity=verbosity,
1763
+ concurrency=concurrency,
1764
+ show_transfer=True,
1765
+ total=len(items),
1766
+ )
1767
+ if failures:
1768
+ return 2
1769
+ console.print(
1770
+ f"[green]✓[/green] Moved {succeeded} path(s) in just {_fmt_elapsed(elapsed)}"
1771
+ )
1772
+ return 0
1773
+ except NotImplementedError:
1774
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1775
+ return 1
1776
+ except (FileNotFoundError, FileExistsError, ValueError) as e:
1777
+ console.print(f"[red]Error:[/red] {e}")
1778
+ return 1
1779
+ except Exception as e:
1780
+ console.print(f"[red]Error:[/red] {e}")
1781
+ return 2
1782
+
1783
+ exit_code = asyncio.run(run())
1784
+ sys.exit(exit_code)
1785
+
1786
+
1787
+ # --- Root Copy Command ---
1788
+
1789
+
1790
+ @cli.command("cp", short_help="Copy file or directory", context_settings=CONTEXT_SETTINGS)
1791
+ @click.argument(
1792
+ "paths", nargs=-1, type=str, callback=_rewrite_paths_arg, shell_complete=_path_complete
1793
+ )
1794
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
1795
+ @click.option("--force", "force", is_flag=True, help="Overwrite differing existing destinations")
1796
+ @click.option(
1797
+ "--atomic",
1798
+ "atomic",
1799
+ is_flag=True,
1800
+ help="Stage local destination writes before finalizing",
1801
+ )
1802
+ @click.option(
1803
+ "--chunk-size",
1804
+ default=None,
1805
+ type=click.IntRange(min=0),
1806
+ help="Streaming chunk size for local-destination transfers (default: auto; 0 disables)",
1807
+ )
1808
+ @click.option("--recursive", "recursive", "-r", is_flag=True, help="Copy directories recursively")
1809
+ @click.option(
1810
+ "--verbose",
1811
+ "verbosity",
1812
+ "-v",
1813
+ count=True,
1814
+ help="Show progress (-v=overall bar, -vv=per-file rows)",
1815
+ )
1816
+ @click.option(
1817
+ "--concurrency",
1818
+ "-j",
1819
+ default=DEFAULT_CONCURRENCY,
1820
+ type=click.IntRange(min=1),
1821
+ help=f"Maximum parallel copies (default: {DEFAULT_CONCURRENCY})",
1822
+ )
1823
+ @click.option(
1824
+ "--listing",
1825
+ default=None,
1826
+ help="HTTP directory-listing mode: 'python', 'apache', 'nginx', or a CSS selector.",
1827
+ )
1828
+ @click.option(
1829
+ "--listing-attr",
1830
+ default=None,
1831
+ help="HTML attribute holding the link URL (default: 'href').",
1832
+ )
1833
+ def cp(
1834
+ paths: tuple[str, ...],
1835
+ token: str | None,
1836
+ force: bool,
1837
+ atomic: bool,
1838
+ chunk_size: int | None,
1839
+ recursive: bool,
1840
+ verbosity: int,
1841
+ concurrency: int,
1842
+ listing: str | None,
1843
+ listing_attr: str | None,
1844
+ ):
1845
+ """Copy files or directories.
1846
+
1847
+ Destination defaults to the current directory when omitted.
1848
+
1849
+ Use '-' as the source to read from stdin (binary).
1850
+
1851
+ Supports multiple source paths to one destination directory.
1852
+
1853
+ Examples:
1854
+ asanypath cp s3://bucket/file.txt
1855
+ asanypath cp src.txt dst.txt
1856
+ asanypath cp -r s3://bucket/src s3://bucket/dst
1857
+ asanypath cp a.txt b.txt destdir/
1858
+ pg_dump | gzip | asanypath cp - s3://bucket/backup.sql.gz
1859
+ """
1860
+
1861
+ async def run():
1862
+ try:
1863
+ if len(paths) < 1:
1864
+ raise click.UsageError("cp expects at least one SRC argument.")
1865
+
1866
+ src_paths = list(paths[:-1]) if len(paths) > 1 else list(paths)
1867
+ dst_path_raw = paths[-1] if len(paths) > 1 else "."
1868
+
1869
+ kwargs = {}
1870
+ if token:
1871
+ kwargs["token"] = token
1872
+ if listing:
1873
+ kwargs["listing"] = listing
1874
+ if listing_attr:
1875
+ kwargs["listing_attr"] = listing_attr
1876
+
1877
+ # Handle '-' as stdin source
1878
+ if src_paths == ["-"]:
1879
+ content = sys.stdin.buffer.read()
1880
+ dst_obj = AsAnyPath(dst_path_raw, **kwargs)
1881
+ await dst_obj.write_bytes(content)
1882
+ if verbosity:
1883
+ console.print(f" [dim]stdin → {dst_path_raw}[/dim]")
1884
+ return 0
1885
+
1886
+ src_objs = [AsAnyPath(src, **kwargs) for src in src_paths]
1887
+ dst_obj = AsAnyPath(dst_path_raw, **kwargs)
1888
+
1889
+ dst_exists, dst_is_dir = await _exists_or_virtual_dir(dst_obj)
1890
+ # A trailing separator ("dir/", or a bare "host:" that expands to
1891
+ # "ssh://host/~/") means copy INTO the directory even if it doesn't
1892
+ # exist yet — matches cp/scp semantics.
1893
+ dst_is_explicit_dir = dst_path_raw.rstrip().endswith(("/", os.sep))
1894
+ dst_is_dir = dst_is_dir or dst_is_explicit_dir
1895
+
1896
+ if len(src_objs) > 1 and not dst_is_dir:
1897
+ await dst_obj.mkdir(parents=True, exist_ok=True)
1898
+ dst_is_dir = True
1899
+
1900
+ plan: list[tuple[AsAnyPath, bool, AsAnyPath]] = []
1901
+ for src in src_objs:
1902
+ src_exists, src_is_dir = await _exists_or_virtual_dir(src)
1903
+ if not src_exists:
1904
+ raise FileNotFoundError(2, f"No such file or directory: '{src}'")
1905
+ if src_is_dir and not recursive:
1906
+ raise IsADirectoryError(
1907
+ 21, f"omitting directory '{src}' (use -r to copy recursively)"
1908
+ )
1909
+
1910
+ if len(src_objs) > 1:
1911
+ target = dst_obj / src.name
1912
+ elif src_is_dir:
1913
+ target = (dst_obj / src.name) if dst_exists and dst_is_dir else dst_obj
1914
+ else:
1915
+ target = (dst_obj / src.name) if dst_is_dir else dst_obj
1916
+
1917
+ plan.append((src, src_is_dir, target))
1918
+
1919
+ async def _iter_copy_items() -> AsyncIterator[tuple[str, object]]:
1920
+ for src, src_is_dir, target in plan:
1921
+ if src_is_dir:
1922
+ # Walk and enqueue per-file copies as soon as found.
1923
+ async for rel, src_file in _collect_files(src):
1924
+ dst_file = target / rel
1925
+
1926
+ yield (
1927
+ _task_label(rel),
1928
+ _TransferWork(
1929
+ _make_simple_copy_work(
1930
+ src_file,
1931
+ dst_file,
1932
+ verbosity,
1933
+ copy_kwargs={
1934
+ "force": force,
1935
+ "atomic": atomic,
1936
+ "chunk_size": chunk_size,
1937
+ },
1938
+ ),
1939
+ src_file,
1940
+ ),
1941
+ )
1942
+ else:
1943
+ yield (
1944
+ src.name,
1945
+ _TransferWork(
1946
+ _make_simple_copy_work(
1947
+ src,
1948
+ target,
1949
+ verbosity,
1950
+ copy_kwargs={
1951
+ "force": force,
1952
+ "atomic": atomic,
1953
+ "chunk_size": chunk_size,
1954
+ },
1955
+ ),
1956
+ src,
1957
+ ),
1958
+ )
1959
+
1960
+ succeeded, failures, elapsed = await _run_concurrent_stream(
1961
+ _iter_copy_items(),
1962
+ description="Copying",
1963
+ verbosity=verbosity,
1964
+ concurrency=concurrency,
1965
+ show_transfer=True,
1966
+ )
1967
+ if failures:
1968
+ return 2
1969
+ console.print(
1970
+ f"[green]✓[/green] Copied {succeeded} file(s) in just {_fmt_elapsed(elapsed)}"
1971
+ )
1972
+ return 0
1973
+ except NotImplementedError:
1974
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
1975
+ return 1
1976
+ except (FileNotFoundError, IsADirectoryError, FileExistsError, ValueError) as e:
1977
+ console.print(f"[red]Error:[/red] {e}")
1978
+ return 1
1979
+ except Exception as e:
1980
+ console.print(f"[red]Error:[/red] {e}")
1981
+ return 2
1982
+
1983
+ exit_code = asyncio.run(run())
1984
+ sys.exit(exit_code)
1985
+
1986
+
1987
+ # --- Sync Command ---
1988
+
1989
+
1990
+ async def _get_mtime(path_obj: AsAnyPath) -> float | None:
1991
+ """Get modification time for a path. Returns None if unavailable."""
1992
+ try:
1993
+ st = await path_obj.stat()
1994
+ mtime = getattr(st, "st_mtime", None)
1995
+ if isinstance(mtime, (int, float)):
1996
+ return float(mtime)
1997
+ except (NotImplementedError, FileNotFoundError, Exception):
1998
+ pass
1999
+ return None
2000
+
2001
+
2002
+ async def _get_size(path_obj: AsAnyPath) -> int | None:
2003
+ """Get file size for a path. Returns None if unavailable."""
2004
+ try:
2005
+ st = await path_obj.stat()
2006
+ size = getattr(st, "st_size", None)
2007
+ if isinstance(size, int):
2008
+ return size
2009
+ except (NotImplementedError, FileNotFoundError, Exception):
2010
+ pass
2011
+ return None
2012
+
2013
+
2014
+ async def _emit_progress(
2015
+ on_progress,
2016
+ delta: int,
2017
+ transferred: int,
2018
+ src_obj: AsAnyPath,
2019
+ dst_obj: AsAnyPath,
2020
+ ) -> None:
2021
+ if on_progress is None:
2022
+ return
2023
+ result = on_progress(delta, transferred, src_obj, dst_obj)
2024
+ if inspect.isawaitable(result):
2025
+ await result
2026
+
2027
+
2028
+ def _make_simple_copy_work(
2029
+ src_obj: AsAnyPath,
2030
+ dst_obj: AsAnyPath,
2031
+ verbosity: int,
2032
+ copy_kwargs: dict | None = None,
2033
+ ) -> object:
2034
+ """Create a copy work callable with standard progress fallback."""
2035
+ copy_kw = copy_kwargs or {}
2036
+
2037
+ async def _run_copy(*, on_progress=None):
2038
+ async def _call(progress_cb):
2039
+ cb_kw = {"on_progress": progress_cb} if progress_cb is not None else {}
2040
+ return await src_obj.copy(dst_obj, **copy_kw, **cb_kw)
2041
+
2042
+ return await _copy_with_progress_fallback(
2043
+ src_obj=src_obj,
2044
+ dst_obj=dst_obj,
2045
+ on_progress=on_progress,
2046
+ copy_call=_call,
2047
+ poll_interval=0.1 if verbosity >= 2 else None,
2048
+ )
2049
+
2050
+ return _run_copy
2051
+
2052
+
2053
+ async def _copy_with_progress_fallback(
2054
+ *,
2055
+ src_obj: AsAnyPath,
2056
+ dst_obj: AsAnyPath,
2057
+ on_progress,
2058
+ copy_call,
2059
+ poll_interval: float | None = None,
2060
+ ):
2061
+ """Run copy and backfill progress from destination size when callbacks are absent."""
2062
+ progress_emitted = False
2063
+ last_polled = 0
2064
+
2065
+ async def _progress_cb(delta: int, transferred: int, src, dst):
2066
+ nonlocal progress_emitted
2067
+ progress_emitted = True
2068
+ await _emit_progress(on_progress, delta, transferred, src, dst)
2069
+
2070
+ monitor_task = None
2071
+ if (
2072
+ on_progress is not None
2073
+ and poll_interval is not None
2074
+ and getattr(dst_obj, "protocol", None) == "file"
2075
+ ):
2076
+
2077
+ async def _monitor() -> None:
2078
+ nonlocal last_polled
2079
+ while True:
2080
+ await asyncio.sleep(poll_interval)
2081
+ size = await _get_size(dst_obj)
2082
+ if isinstance(size, int) and size > last_polled:
2083
+ delta = size - last_polled
2084
+ last_polled = size
2085
+ await _emit_progress(on_progress, delta, size, src_obj, dst_obj)
2086
+
2087
+ monitor_task = asyncio.create_task(_monitor())
2088
+
2089
+ try:
2090
+ out = await copy_call(_progress_cb if on_progress is not None else None)
2091
+ finally:
2092
+ if monitor_task is not None:
2093
+ monitor_task.cancel()
2094
+ with contextlib.suppress(asyncio.CancelledError):
2095
+ await monitor_task
2096
+
2097
+ if on_progress is not None and not progress_emitted:
2098
+ final_size = await _get_size(dst_obj)
2099
+ if isinstance(final_size, int) and final_size > last_polled:
2100
+ await _emit_progress(
2101
+ on_progress,
2102
+ final_size - last_polled,
2103
+ final_size,
2104
+ src_obj,
2105
+ dst_obj,
2106
+ )
2107
+
2108
+ return out
2109
+
2110
+
2111
+ async def _exists_or_virtual_dir(path_obj: AsAnyPath) -> tuple[bool, bool]:
2112
+ """Return ``(exists, is_dir)`` including prefix-only virtual directories."""
2113
+ exists = await path_obj.exists()
2114
+ exists = exists if isinstance(exists, bool) else False
2115
+ if exists:
2116
+ is_dir = await path_obj.is_dir()
2117
+ return True, is_dir if isinstance(is_dir, bool) else False
2118
+ try:
2119
+ is_dir = await path_obj.is_dir()
2120
+ except Exception: # noqa: BLE001
2121
+ return False, False
2122
+ is_dir = is_dir if isinstance(is_dir, bool) else False
2123
+ return is_dir, is_dir
2124
+
2125
+
2126
+ async def _collect_files(
2127
+ root: AsAnyPath,
2128
+ prefix: str = "",
2129
+ *,
2130
+ on_file=None,
2131
+ ) -> AsyncIterator[tuple[str, AsAnyPath]]:
2132
+ """Recursively collect all files under root, returning (relative_path, path_obj) pairs."""
2133
+ try:
2134
+ async for child in root.iterdir():
2135
+ rel = f"{prefix}{child.name}" if prefix else child.name
2136
+ is_dir = await child.is_dir()
2137
+ is_dir = is_dir if isinstance(is_dir, bool) else False
2138
+ if is_dir:
2139
+ async for nested_rel, nested_child in _collect_files(
2140
+ child,
2141
+ f"{rel}/",
2142
+ on_file=on_file,
2143
+ ):
2144
+ yield nested_rel, nested_child
2145
+ else:
2146
+ if on_file is not None:
2147
+ on_file(rel, child)
2148
+ yield rel, child
2149
+ except (NotADirectoryError, FileNotFoundError):
2150
+ return
2151
+
2152
+
2153
+ async def _needs_sync(
2154
+ src_obj: AsAnyPath,
2155
+ dst_obj: AsAnyPath,
2156
+ *,
2157
+ size_only: bool = False,
2158
+ ) -> bool:
2159
+ """Determine if src needs to be synced to dst.
2160
+
2161
+ Strategy: if dst doesn't exist, sync. Otherwise compare size, then mtime.
2162
+ """
2163
+ if not await dst_obj.exists():
2164
+ return True
2165
+
2166
+ src_size = await _get_size(src_obj)
2167
+ dst_size = await _get_size(dst_obj)
2168
+ if src_size is not None and dst_size is not None and src_size != dst_size:
2169
+ return True
2170
+
2171
+ if size_only:
2172
+ return False
2173
+
2174
+ src_mtime = await _get_mtime(src_obj)
2175
+ dst_mtime = await _get_mtime(dst_obj)
2176
+ if src_mtime is not None and dst_mtime is not None:
2177
+ return src_mtime > dst_mtime
2178
+
2179
+ # If we can't determine mtime, transfer if sizes differ or are unknown
2180
+ if src_size is None or dst_size is None:
2181
+ return True
2182
+ return False
2183
+
2184
+
2185
+ @cli.command("presign", short_help="Generate presigned URL", context_settings=CONTEXT_SETTINGS)
2186
+ @click.argument("path", type=str, callback=_rewrite_path_arg, shell_complete=_path_complete)
2187
+ @click.option("--expires", "-e", default=3600, type=int, help="URL validity in seconds")
2188
+ @click.option(
2189
+ "--method",
2190
+ "-m",
2191
+ default="GET",
2192
+ type=click.Choice(["GET", "PUT", "HEAD", "DELETE"]),
2193
+ help="HTTP method the URL will be used for",
2194
+ )
2195
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
2196
+ def presign(path: str, expires: int, method: str, token: str | None) -> None:
2197
+ """Generate a presigned URL for a cloud object.
2198
+
2199
+ The URL allows unauthenticated access for the specified duration.
2200
+ Supports S3, Azure Blob Storage, and GCS backends.
2201
+ """
2202
+
2203
+ async def _presign() -> str:
2204
+ kwargs = {}
2205
+ if token:
2206
+ kwargs["token"] = token
2207
+ p = AsAnyPath(path, **kwargs)
2208
+ return await p.presign(expires=expires, method=method)
2209
+
2210
+ try:
2211
+ url = asyncio.run(_presign())
2212
+ console.print(url)
2213
+ except (ValueError, NotImplementedError) as e:
2214
+ console.print(f"[red]Error:[/red] {e}", style="bold")
2215
+ raise SystemExit(1)
2216
+
2217
+
2218
+ @cli.command("sync", short_help="Sync directories", context_settings=CONTEXT_SETTINGS)
2219
+ @click.argument("src", type=str, shell_complete=_path_complete)
2220
+ @click.argument("dst", type=str, shell_complete=_path_complete)
2221
+ @click.option("--token", default=None, help="Bearer token for authenticated backends")
2222
+ @click.option("--delete", "delete_extra", is_flag=True, help="Delete files in DST not in SRC")
2223
+ @click.option(
2224
+ "--dry-run", "-n", is_flag=True, help="Show what would be transferred without doing it"
2225
+ )
2226
+ @click.option("--size-only", is_flag=True, help="Compare only file sizes, skip mtime check")
2227
+ @click.option(
2228
+ "--verbose",
2229
+ "verbosity",
2230
+ "-v",
2231
+ count=True,
2232
+ help="Show progress (-v=overall bar, -vv=per-file rows)",
2233
+ )
2234
+ @click.option(
2235
+ "--concurrency",
2236
+ "-j",
2237
+ default=DEFAULT_CONCURRENCY,
2238
+ type=click.IntRange(min=1),
2239
+ help=f"Maximum parallel transfers (default: {DEFAULT_CONCURRENCY})",
2240
+ )
2241
+ @click.option("--exclude", multiple=True, help="Exclude files matching glob pattern (repeatable)")
2242
+ @click.option(
2243
+ "--listing",
2244
+ default=None,
2245
+ help="HTTP directory-listing mode: 'python', 'apache', 'nginx', or a CSS selector.",
2246
+ )
2247
+ @click.option(
2248
+ "--listing-attr",
2249
+ default=None,
2250
+ help="HTML attribute holding the link URL (default: 'href').",
2251
+ )
2252
+ def sync(
2253
+ src: str,
2254
+ dst: str,
2255
+ token: str | None,
2256
+ delete_extra: bool,
2257
+ dry_run: bool,
2258
+ size_only: bool,
2259
+ verbosity: int,
2260
+ concurrency: int,
2261
+ exclude: tuple[str, ...],
2262
+ listing: str | None,
2263
+ listing_attr: str | None,
2264
+ ):
2265
+ """Sync files from SRC to DST (like aws s3 sync or rsync).
2266
+
2267
+ Only transfers files that are new or modified. Works across different
2268
+ cloud backends (e.g. S3 to Azure, local to GCS).
2269
+
2270
+ Examples:
2271
+ asanypath sync ./local-dir s3://bucket/prefix/
2272
+ asanypath sync s3://bucket/src az://container/dst
2273
+ asanypath sync gs://bucket/data ./local-backup --delete
2274
+ asanypath sync s3://src s3://dst --dry-run
2275
+ asanypath sync ./src s3://bucket --exclude '*.tmp' --exclude '.git/*'
2276
+ """
2277
+ import fnmatch
2278
+
2279
+ async def run():
2280
+ try:
2281
+ kwargs = {}
2282
+ if token:
2283
+ kwargs["token"] = token
2284
+ if listing:
2285
+ kwargs["listing"] = listing
2286
+ if listing_attr:
2287
+ kwargs["listing_attr"] = listing_attr
2288
+
2289
+ src_obj = AsAnyPath(src, **kwargs)
2290
+ dst_obj = AsAnyPath(dst, **kwargs)
2291
+
2292
+ # Ensure src exists
2293
+ src_exists, src_is_dir = await _exists_or_virtual_dir(src_obj)
2294
+ if not src_exists:
2295
+ console.print(f"[red]Error:[/red] source not found: {src}")
2296
+ return 1
2297
+
2298
+ if not src_is_dir:
2299
+ # Single file sync
2300
+ target = dst_obj
2301
+ dst_exists, dst_is_dir = await _exists_or_virtual_dir(dst_obj)
2302
+ if dst_exists and dst_is_dir:
2303
+ target = dst_obj / src_obj.name
2304
+
2305
+ if await _needs_sync(src_obj, target, size_only=size_only):
2306
+ if dry_run:
2307
+ console.print(f"[dim](dry-run)[/dim] {src_obj} -> {target}")
2308
+ else:
2309
+ await src_obj.copy(target, force=True)
2310
+ console.print("[green]✓[/green] Synced 1 file")
2311
+ else:
2312
+ console.print("[green]✓[/green] Already up-to-date")
2313
+ return 0
2314
+
2315
+ # Directory sync
2316
+ scan_count = 0
2317
+
2318
+ def _on_scanned(_rel, _obj):
2319
+ nonlocal scan_count
2320
+ scan_count += 1
2321
+ if scan_count % 200 == 0:
2322
+ scan.update(f"[dim]Scanning source... found {scan_count} file(s)[/dim]")
2323
+
2324
+ with console.status("[dim]Scanning source...[/dim]") as scan:
2325
+ src_files = [
2326
+ (rel, obj) async for rel, obj in _collect_files(src_obj, on_file=_on_scanned)
2327
+ ]
2328
+
2329
+ # Apply exclude patterns
2330
+ if exclude:
2331
+ filtered = []
2332
+ for rel, obj in src_files:
2333
+ if not any(fnmatch.fnmatch(rel, pat) for pat in exclude):
2334
+ filtered.append((rel, obj))
2335
+ excluded_count = len(src_files) - len(filtered)
2336
+ src_files = filtered
2337
+ if excluded_count and verbosity:
2338
+ console.print(f"[dim]Excluded {excluded_count} file(s)[/dim]")
2339
+
2340
+ # Determine what needs syncing
2341
+ to_transfer: list[tuple[str, AsAnyPath, AsAnyPath]] = []
2342
+ checked = 0
2343
+ with console.status("[dim]Comparing source and destination...[/dim]") as compare:
2344
+ for rel, src_file in src_files:
2345
+ checked += 1
2346
+ if checked % 200 == 0:
2347
+ compare.update(f"[dim]Comparing... {checked}/{len(src_files)}[/dim]")
2348
+ dst_file = dst_obj / rel
2349
+ if await _needs_sync(src_file, dst_file, size_only=size_only):
2350
+ to_transfer.append((rel, src_file, dst_file))
2351
+
2352
+ # Handle --delete: find files in dst not in src
2353
+ to_delete: list[tuple[str, AsAnyPath]] = []
2354
+ if delete_extra:
2355
+ dst_exists = await dst_obj.exists()
2356
+ if dst_exists:
2357
+ dst_files = [(rel, obj) async for rel, obj in _collect_files(dst_obj)]
2358
+ src_rel_set = {rel for rel, _ in src_files}
2359
+ for rel, dst_file in dst_files:
2360
+ if rel not in src_rel_set:
2361
+ to_delete.append((rel, dst_file))
2362
+
2363
+ # Summary
2364
+ if not to_transfer and not to_delete:
2365
+ console.print("[green]✓[/green] Already up-to-date")
2366
+ return 0
2367
+
2368
+ if dry_run:
2369
+ for rel, src_file, dst_file in to_transfer:
2370
+ console.print(f"[dim](would copy)[/dim] {src_file} -> {dst_file}")
2371
+ for rel, dst_file in to_delete:
2372
+ console.print(f"[dim](would delete)[/dim] {dst_file}")
2373
+ console.print(
2374
+ f"\n[dim]Dry run: {len(to_transfer)} to copy, {len(to_delete)} to delete[/dim]"
2375
+ )
2376
+ return 0
2377
+
2378
+ # Transfer files in parallel.
2379
+ transfer_items: list[tuple[str, object]] = []
2380
+ for _, s, d in to_transfer:
2381
+ transfer_items.append(
2382
+ (
2383
+ s.name,
2384
+ _TransferWork(
2385
+ _make_simple_copy_work(s, d, verbosity, copy_kwargs={"force": True}),
2386
+ s,
2387
+ ),
2388
+ )
2389
+ )
2390
+
2391
+ async def _iter_transfer_items() -> AsyncIterator[tuple[str, object]]:
2392
+ for item in transfer_items:
2393
+ yield item
2394
+
2395
+ transferred, copy_failures, copy_elapsed = await _run_concurrent_stream(
2396
+ _iter_transfer_items(),
2397
+ description="Syncing",
2398
+ verbosity=verbosity,
2399
+ concurrency=concurrency,
2400
+ show_transfer=True,
2401
+ total=len(transfer_items),
2402
+ )
2403
+
2404
+ # Delete extra files in parallel.
2405
+ deleted = 0
2406
+ delete_failures: list = []
2407
+ delete_elapsed = 0.0
2408
+ if to_delete:
2409
+ deleted, delete_failures, delete_elapsed = await _run_concurrent(
2410
+ [(p.name, p.unlink(missing_ok=True)) for _, p in to_delete],
2411
+ description="Deleting",
2412
+ verbosity=verbosity,
2413
+ concurrency=concurrency,
2414
+ )
2415
+
2416
+ if copy_failures or delete_failures:
2417
+ return 2
2418
+
2419
+ parts = []
2420
+ if transferred:
2421
+ parts.append(f"{transferred} copied")
2422
+ if deleted:
2423
+ parts.append(f"{deleted} deleted")
2424
+ skipped = len(src_files) - transferred
2425
+ if skipped:
2426
+ parts.append(f"{skipped} up-to-date")
2427
+ console.print(
2428
+ f"[green]✓[/green] Sync complete: {', '.join(parts)}"
2429
+ f" in just {_fmt_elapsed(copy_elapsed + delete_elapsed)}"
2430
+ )
2431
+ return 0
2432
+
2433
+ except NotImplementedError:
2434
+ console.print("[yellow]Operation not supported for this path type[/yellow]")
2435
+ return 1
2436
+ except Exception as e:
2437
+ console.print(f"[red]Error:[/red] {e}")
2438
+ return 2
2439
+
2440
+ exit_code = asyncio.run(run())
2441
+ sys.exit(exit_code)
2442
+
2443
+
2444
+ # --- Auth/Env Commands ---
2445
+
2446
+
2447
+ @cli.group(short_help="Auth and environment diagnostics", context_settings=CONTEXT_SETTINGS)
2448
+ def auth():
2449
+ """Inspect authentication and environment configuration."""
2450
+ pass
2451
+
2452
+
2453
+ @auth.command("show", short_help="Show auth configuration", context_settings=CONTEXT_SETTINGS)
2454
+ @click.argument("path", type=str, required=False, callback=_rewrite_path_arg)
2455
+ def auth_show(path: str | None):
2456
+ """Show active authentication and environment configuration.
2457
+
2458
+ If PATH is provided, show auth config specifically for that backend.
2459
+
2460
+ Examples:
2461
+ asanypath auth show
2462
+ asanypath auth show s3://bucket
2463
+ asanypath auth show art://art.example.com
2464
+ """
2465
+ from urllib.parse import urlparse
2466
+
2467
+ # Determine backend from path
2468
+ if path:
2469
+ scheme = urlparse(path).scheme.lower() or "file"
2470
+ else:
2471
+ scheme = None
2472
+
2473
+ console.print("[bold]Authentication Configuration[/bold]")
2474
+
2475
+ # S3 env vars
2476
+ if scheme is None or scheme == "s3":
2477
+ console.print("\n[cyan]AWS S3:[/cyan]")
2478
+ console.print(f" AWS_PROFILE: {getenv('AWS_PROFILE', '(not set)')}")
2479
+ console.print(f" AWS_REGION: {getenv('AWS_REGION', '(not set)')}")
2480
+ console.print(f" AWS_ACCESS_KEY_ID: {mask_token(getenv('AWS_ACCESS_KEY_ID'))}")
2481
+ console.print(f" AWS_SECRET_ACCESS_KEY: {mask_token(getenv('AWS_SECRET_ACCESS_KEY'))}")
2482
+ console.print(f" AWS_SESSION_TOKEN: {mask_token(getenv('AWS_SESSION_TOKEN'))}")
2483
+ creds_file = Path.home() / ".aws" / "credentials"
2484
+ console.print(f" Credentials file: {creds_file} {'✓' if creds_file.exists() else '✗'}")
2485
+
2486
+ # GCS env vars
2487
+ if scheme is None or scheme == "gs":
2488
+ console.print("\n[cyan]Google Cloud Storage:[/cyan]")
2489
+ console.print(f" GCP_PROJECT: {getenv('GCP_PROJECT', '(not set)')}")
2490
+ console.print(f" GOOGLE_CLOUD_PROJECT: {getenv('GOOGLE_CLOUD_PROJECT', '(not set)')}")
2491
+ creds_file = getenv("GOOGLE_APPLICATION_CREDENTIALS", "(not set)")
2492
+ exists = Path(creds_file).exists() if creds_file != "(not set)" else False
2493
+ console.print(f" GOOGLE_APPLICATION_CREDENTIALS: {creds_file} {'✓' if exists else '✗'}")
2494
+
2495
+ # Azure env vars
2496
+ if scheme is None or scheme == "az":
2497
+ console.print("\n[cyan]Azure Blob Storage:[/cyan]")
2498
+ console.print(f" AZURE_STORAGE_ACCOUNT: {getenv('AZURE_STORAGE_ACCOUNT', '(not set)')}")
2499
+ console.print(f" AZURE_STORAGE_KEY: {mask_token(getenv('AZURE_STORAGE_KEY'))}")
2500
+ console.print(f" AZURE_STORAGE_SAS_TOKEN: {mask_token(getenv('AZURE_STORAGE_SAS_TOKEN'))}")
2501
+
2502
+ # Artifactory env vars
2503
+ if scheme is None or scheme == "art":
2504
+ console.print("\n[cyan]JFrog Artifactory:[/cyan]")
2505
+ console.print(
2506
+ f" ARTIFACTORY_IDENTITY_TOKEN: {mask_token(getenv('ARTIFACTORY_IDENTITY_TOKEN'))}"
2507
+ )
2508
+
2509
+ # SSH env vars + ssh_config
2510
+ if scheme is None or scheme == "ssh":
2511
+ console.print("\n[cyan]SSH / SFTP:[/cyan]")
2512
+ console.print(f" SSH_USER: {getenv('SSH_USER', '(not set)')}")
2513
+ console.print(f" SSH_KEY_FILE: {getenv('SSH_KEY_FILE', '(not set)')}")
2514
+ console.print(f" SSH_KNOWN_HOSTS: {getenv('SSH_KNOWN_HOSTS', '(default)')}")
2515
+ try:
2516
+ from asanypath.ssh import SSHPath, _discover_ssh_config
2517
+
2518
+ cfg_paths = _discover_ssh_config()
2519
+ if cfg_paths:
2520
+ for p in cfg_paths:
2521
+ console.print(f" ssh_config: {p} ✓")
2522
+ else:
2523
+ console.print(" ssh_config: (none found)")
2524
+ if path and scheme == "ssh":
2525
+ try:
2526
+ console.print(f" resolved target: {SSHPath(path).resolved_target}")
2527
+ except Exception as exc: # noqa: BLE001
2528
+ console.print(f" resolved target: [red](error: {exc})[/red]")
2529
+ except ImportError:
2530
+ console.print(" ssh backend: [yellow](optional dependency not installed)[/yellow]")
2531
+ console.print(
2532
+ " [dim]Passwords are not read from env; use keys/agent or ~/.ssh/config.[/dim]"
2533
+ )
2534
+
2535
+ # FTP(S) env vars + netrc
2536
+ if scheme is None or scheme in ("ftp", "ftps"):
2537
+ console.print("\n[cyan]FTP / FTPS:[/cyan]")
2538
+ console.print(f" FTP_USER: {getenv('FTP_USER', '(default: anonymous)')}")
2539
+ console.print(f" FTP_HOST: {getenv('FTP_HOST', '(not set)')}")
2540
+ console.print(f" FTP_PORT: {getenv('FTP_PORT', '(default: 21)')}")
2541
+ netrc_path = Path(getenv("NETRC") or (Path.home() / ".netrc"))
2542
+ console.print(f" netrc: {netrc_path} {'✓' if netrc_path.exists() else '✗'}")
2543
+ if path and scheme in ("ftp", "ftps") and netrc_path.exists():
2544
+ try:
2545
+ import netrc as _netrc
2546
+ from urllib.parse import urlparse
2547
+
2548
+ host = urlparse(path).hostname
2549
+ rc = _netrc.netrc(str(netrc_path))
2550
+ entry = rc.authenticators(host) if host else None
2551
+ marker = "✓ entry found" if entry else "✗ no entry"
2552
+ console.print(f" netrc entry for {host}: {marker}")
2553
+ except Exception as exc: # noqa: BLE001
2554
+ console.print(f" netrc lookup: [red](error: {exc})[/red]")
2555
+ console.print(" [dim]Passwords are not read from env; use ~/.netrc.[/dim]")
2556
+
2557
+ console.print("\n[dim]Note: Tokens are masked to prevent accidental exposure.[/dim]")
2558
+
2559
+
2560
+ def main():
2561
+ """Entry point for CLI."""
2562
+ cli()
2563
+
2564
+
2565
+ if __name__ == "__main__":
2566
+ main()