fss-cli 0.11.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.
fss_cli/_app.py ADDED
@@ -0,0 +1,663 @@
1
+ """Embedded Typer application for mapped async fsspec sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable, Coroutine, Mapping, Sequence
7
+ from contextlib import AbstractAsyncContextManager
8
+ from dataclasses import dataclass
9
+ from types import MappingProxyType
10
+ from typing import Annotated, Any, Literal, TypeAlias, TypedDict
11
+
12
+ import typer
13
+ from fsspec import AbstractFileSystem
14
+
15
+ from ._cat import _run_cat, _StdinOperand
16
+ from ._command import (
17
+ _MappedOperand,
18
+ _parse_mapped_operand,
19
+ _usage_error,
20
+ )
21
+ from ._cp import _DEFAULT_METADATA_WAIT_SECONDS, _cp_plan, _run_cp
22
+ from ._diagnostics import _render_diagnostic_prefix, _render_diagnostic_value
23
+ from ._du import _DuRequest, _run_du
24
+ from ._find import _FindRequest, _run_find
25
+ from ._head_tail import _read_head, _read_tail, _run_byte_range
26
+ from ._info import _run_info
27
+ from ._ls import _LsRequest, _run_ls
28
+ from ._mkdir import _MkdirRequest, _run_mkdir
29
+ from ._mv import _plan_mv, _run_mv
30
+ from ._path import _has_dot_segment, _has_final_dot_segment, _is_root
31
+ from ._rm import _RmRequest, _run_rm
32
+ from ._rmdir import _run_rmdir
33
+ from ._size import _run_size
34
+ from ._stat import _run_stat
35
+ from ._test import _run_test
36
+ from ._tree import _run_tree, _TreeRequest
37
+ from ._unlink import _run_unlink
38
+
39
+ AsyncFilesystemSource: TypeAlias = Callable[[], AbstractAsyncContextManager[AbstractFileSystem]]
40
+
41
+
42
+ class RecursionCapabilities(TypedDict, total=False):
43
+ """Application policy for recursive core commands."""
44
+
45
+ copy: bool
46
+ remove: bool
47
+
48
+
49
+ class AppCapabilities(TypedDict, total=False):
50
+ """Application-level core command policy."""
51
+
52
+ recursion: RecursionCapabilities
53
+
54
+
55
+ CommandCallback: TypeAlias = Callable[..., None]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CommandContext:
60
+ """Immutable application context available to command callbacks."""
61
+
62
+ sources: Mapping[str, AsyncFilesystemSource]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _Capabilities:
67
+ recursive_copy: bool = True
68
+ recursive_remove: bool = False
69
+
70
+
71
+ # Shared parameter annotations. Typer reads the metavar, option spellings, and
72
+ # help text from these aliases, so every command that takes the same operand or
73
+ # option shape stays spelled and documented identically.
74
+ _Operand: TypeAlias = Annotated[str, typer.Argument(metavar="name:/path")]
75
+ _Operands: TypeAlias = Annotated[list[str], typer.Argument(metavar="name:/path")]
76
+ _OptionalOperands: TypeAlias = Annotated[
77
+ list[str] | None,
78
+ typer.Argument(metavar="name:/path"),
79
+ ]
80
+ _CatOperands: TypeAlias = Annotated[
81
+ list[str] | None,
82
+ typer.Argument(metavar="name:/path|-"),
83
+ ]
84
+ _CpOperands: TypeAlias = Annotated[
85
+ list[str],
86
+ typer.Argument(metavar="SOURCE... DESTINATION"),
87
+ ]
88
+ _MvOperands: TypeAlias = Annotated[
89
+ list[str],
90
+ typer.Argument(
91
+ metavar="name:/path",
92
+ help="One or more source files followed by one destination.",
93
+ ),
94
+ ]
95
+ _ByteCount: TypeAlias = Annotated[
96
+ int,
97
+ typer.Option("-c", metavar="N", min=0, help="Number of bytes to display."),
98
+ ]
99
+ _MaxDepth: TypeAlias = Annotated[
100
+ int | None,
101
+ typer.Option(
102
+ "--maxdepth",
103
+ metavar="N",
104
+ min=0,
105
+ help="Descend at most N levels below the operand.",
106
+ ),
107
+ ]
108
+ _FindType: TypeAlias = Annotated[
109
+ Literal["f", "d"],
110
+ typer.Option("--type", metavar="f|d", help="Report files (f) or directories (d)."),
111
+ ]
112
+ _AlmostAll: TypeAlias = Annotated[
113
+ bool,
114
+ typer.Option("-A", help="Include entries whose name begins with a dot."),
115
+ ]
116
+ _LongListing: TypeAlias = Annotated[
117
+ bool,
118
+ typer.Option("-l", help="Use the long listing format."),
119
+ ]
120
+ _HumanSizes: TypeAlias = Annotated[
121
+ bool,
122
+ typer.Option("-h", help="Print sizes in human-readable units."),
123
+ ]
124
+ _Summarize: TypeAlias = Annotated[
125
+ bool,
126
+ typer.Option("-s", help="Print only the total for the operand."),
127
+ ]
128
+ _CreateParents: TypeAlias = Annotated[
129
+ bool,
130
+ typer.Option("-p", help="Create missing parent directories."),
131
+ ]
132
+ _CopyRecursive: TypeAlias = Annotated[
133
+ bool,
134
+ typer.Option("-R", "-r", help="Copy one directory and its descendants."),
135
+ ]
136
+ _MetadataWait: TypeAlias = Annotated[
137
+ int,
138
+ typer.Option(
139
+ "--metadata-wait",
140
+ metavar="SECONDS",
141
+ min=0,
142
+ help=(
143
+ "Seconds to poll after each successful file copy while destination info "
144
+ "still shows no size, or the size from before this copy."
145
+ ),
146
+ ),
147
+ ]
148
+ _TestExists: TypeAlias = Annotated[
149
+ bool,
150
+ typer.Option("-e", help="Succeed when the operand exists."),
151
+ ]
152
+ _TestDirectory: TypeAlias = Annotated[
153
+ bool,
154
+ typer.Option("-d", help="Succeed when the operand is a directory."),
155
+ ]
156
+ _TestFile: TypeAlias = Annotated[
157
+ bool,
158
+ typer.Option("-f", help="Succeed when the operand is a regular file."),
159
+ ]
160
+ # The `rm` help text is compatibility surface: with recursion disabled the
161
+ # rendered help must contain no `-R` or `-r` spelling at all.
162
+ _RmDirectory: TypeAlias = Annotated[
163
+ bool,
164
+ typer.Option("-d", help="Remove empty directories."),
165
+ ]
166
+ _RmForce: TypeAlias = Annotated[
167
+ bool,
168
+ typer.Option("-f", help="Ignore missing operands."),
169
+ ]
170
+ _RmVerbose: TypeAlias = Annotated[
171
+ int,
172
+ typer.Option("-v", count=True, help="Print removed operands."),
173
+ ]
174
+ _RmRecursive: TypeAlias = Annotated[
175
+ bool,
176
+ typer.Option("-R", "-r", help="Remove directory trees with guarded traversal."),
177
+ ]
178
+
179
+
180
+ def _snapshot_capabilities(capabilities: AppCapabilities | None) -> _Capabilities:
181
+ if capabilities is None:
182
+ return _Capabilities()
183
+ if not isinstance(capabilities, Mapping):
184
+ msg = "capabilities must be a mapping"
185
+ raise TypeError(msg)
186
+ for name in capabilities:
187
+ if name != "recursion":
188
+ msg = f"capabilities.{name}: unknown capability"
189
+ raise ValueError(msg)
190
+
191
+ recursion = capabilities.get("recursion", {})
192
+ if not isinstance(recursion, Mapping):
193
+ msg = "capabilities.recursion must be a mapping"
194
+ raise TypeError(msg)
195
+ for name in recursion:
196
+ if name not in {"copy", "remove"}:
197
+ msg = f"capabilities.recursion.{name}: unknown capability"
198
+ raise ValueError(msg)
199
+ for name in ("copy", "remove"):
200
+ if name in recursion and type(recursion[name]) is not bool:
201
+ msg = f"capabilities.recursion.{name} must be a bool"
202
+ raise TypeError(msg)
203
+ return _Capabilities(
204
+ recursive_copy=recursion.get("copy", True),
205
+ recursive_remove=recursion.get("remove", False),
206
+ )
207
+
208
+
209
+ def _validate_source_name(name: object) -> None:
210
+ if not isinstance(name, str):
211
+ msg = "async filesystem source names must be strings"
212
+ raise TypeError(msg)
213
+ if not name or any(character in name for character in (":", "\0", "\n")):
214
+ msg = "async filesystem source names must be non-empty and contain no colon, NUL, or newline"
215
+ raise ValueError(msg)
216
+ if name.startswith("-"):
217
+ msg = "async filesystem source names must not start with '-'"
218
+ raise ValueError(msg)
219
+
220
+
221
+ def _ensure_no_active_event_loop(command: str) -> None:
222
+ try:
223
+ asyncio.get_running_loop()
224
+ except RuntimeError:
225
+ return
226
+ prefix = _render_diagnostic_prefix(command)
227
+ typer.echo(
228
+ f"{prefix} cannot run from an active event loop",
229
+ err=True,
230
+ color=True,
231
+ )
232
+ raise typer.Exit(1)
233
+
234
+
235
+ def _run_async_command(
236
+ command: str,
237
+ operation: Callable[[], Coroutine[Any, Any, None]],
238
+ ) -> None:
239
+ """Run one preflighted command coroutine from a synchronous callback."""
240
+ _ensure_no_active_event_loop(command)
241
+ asyncio.run(operation())
242
+
243
+
244
+ class App:
245
+ """Embedded core commands plus explicitly selected extensions."""
246
+
247
+ typer_app: typer.Typer
248
+
249
+ def __init__(
250
+ self,
251
+ sources: Mapping[str, AsyncFilesystemSource],
252
+ *,
253
+ capabilities: AppCapabilities | None = None,
254
+ extensions: Sequence[CommandCallback] = (),
255
+ ) -> None:
256
+ """Snapshot sources and register the requested command surface."""
257
+ self._sources = MappingProxyType(dict(sources))
258
+ if not self._sources:
259
+ msg = "at least one async filesystem source is required"
260
+ raise ValueError(msg)
261
+ for name in self._sources:
262
+ _validate_source_name(name)
263
+ self._capabilities = _snapshot_capabilities(capabilities)
264
+
265
+ self.typer_app = typer.Typer(add_completion=False)
266
+ self._register_commands()
267
+ for extension in extensions:
268
+ self.typer_app.command()(extension)
269
+
270
+ # Operand preflight shared by the command callbacks.
271
+
272
+ def _mapped(self, command: str, spelling: str) -> _MappedOperand:
273
+ """Parse one ``name:/path`` operand against the configured sources."""
274
+ return _parse_mapped_operand(command, spelling, self._sources)
275
+
276
+ def _mapped_all(
277
+ self,
278
+ command: str,
279
+ spellings: Sequence[str],
280
+ ) -> tuple[_MappedOperand, ...]:
281
+ """Parse every ``name:/path`` operand in invocation order."""
282
+ return tuple(self._mapped(command, spelling) for spelling in spellings)
283
+
284
+ def _destructive_operand(self, command: str, spelling: str) -> _MappedOperand:
285
+ """Parse one operand and reject roots and final dot segments."""
286
+ operand = self._mapped(command, spelling)
287
+ if _is_root(operand.path) or _has_final_dot_segment(operand.path):
288
+ rendered = _render_diagnostic_value(spelling)
289
+ _usage_error(command, f"{rendered}: rejected path")
290
+ return operand
291
+
292
+ # Command registration.
293
+
294
+ def _register_commands(self) -> None:
295
+ @self.typer_app.callback()
296
+ def root(ctx: typer.Context) -> None:
297
+ ctx.obj = CommandContext(self._sources)
298
+
299
+ self._register_query_commands()
300
+ self._register_listing_commands()
301
+ self._register_read_commands()
302
+ self._register_transfer_commands()
303
+ self._register_mutation_commands()
304
+
305
+ def _register_query_commands(self) -> None:
306
+ """Register the metadata and predicate commands."""
307
+
308
+ @self.typer_app.command()
309
+ def info(operand: _Operand) -> None:
310
+ """Display normalized file information."""
311
+ mapped = self._mapped("info", operand)
312
+ _run_async_command(
313
+ "info",
314
+ lambda: _run_info("info", mapped, self._sources),
315
+ )
316
+
317
+ @self.typer_app.command()
318
+ def size(operands: _Operands) -> None:
319
+ """Display exact file sizes."""
320
+ mapped = self._mapped_all("size", operands)
321
+ _run_async_command(
322
+ "size",
323
+ lambda: _run_size("size", mapped, self._sources),
324
+ )
325
+
326
+ @self.typer_app.command()
327
+ def stat(operands: _Operands) -> None:
328
+ """Display file status."""
329
+ mapped = self._mapped_all("stat", operands)
330
+ _run_async_command(
331
+ "stat",
332
+ lambda: _run_stat("stat", mapped, self._sources),
333
+ )
334
+
335
+ @self.typer_app.command()
336
+ def test(
337
+ operand: _Operand,
338
+ *,
339
+ exists: _TestExists = False,
340
+ directory: _TestDirectory = False,
341
+ file: _TestFile = False,
342
+ ) -> None:
343
+ """Evaluate a file predicate."""
344
+ mapped = self._mapped("test", operand)
345
+ selected: list[Literal["e", "d", "f"]] = [
346
+ predicate
347
+ for predicate, enabled in (
348
+ ("e", exists),
349
+ ("d", directory),
350
+ ("f", file),
351
+ )
352
+ if enabled
353
+ ]
354
+ if len(selected) != 1:
355
+ _usage_error("test", "exactly one predicate selector is required")
356
+ _run_async_command(
357
+ "test",
358
+ lambda: _run_test("test", selected[0], mapped, self._sources),
359
+ )
360
+
361
+ def _register_listing_commands(self) -> None:
362
+ """Register the directory listing and traversal commands."""
363
+
364
+ @self.typer_app.command()
365
+ def ls(
366
+ operands: _Operands,
367
+ *,
368
+ include_almost_all: _AlmostAll = False,
369
+ long_listing: _LongListing = False,
370
+ human_readable: _HumanSizes = False,
371
+ ) -> None:
372
+ """List directory contents."""
373
+ self._listing(
374
+ operands,
375
+ include_almost_all=include_almost_all,
376
+ long_listing=long_listing,
377
+ human_readable=human_readable,
378
+ )
379
+
380
+ @self.typer_app.command()
381
+ def du(
382
+ operand: _Operand,
383
+ *,
384
+ summarize: _Summarize = False,
385
+ human_readable: _HumanSizes = False,
386
+ ) -> None:
387
+ """Estimate file space usage."""
388
+ mapped = self._mapped("du", operand)
389
+ _run_async_command(
390
+ "du",
391
+ lambda: _run_du(
392
+ "du",
393
+ _DuRequest(
394
+ summarize=summarize,
395
+ human_readable=human_readable,
396
+ operand=mapped,
397
+ ),
398
+ self._sources,
399
+ ),
400
+ )
401
+
402
+ @self.typer_app.command()
403
+ def find(
404
+ operand: _Operand,
405
+ maxdepth: _MaxDepth = None,
406
+ kind: _FindType = "f",
407
+ ) -> None:
408
+ """Find files recursively."""
409
+ mapped = self._mapped("find", operand)
410
+ _run_async_command(
411
+ "find",
412
+ lambda: _run_find(
413
+ "find",
414
+ _FindRequest(maxdepth=maxdepth, kind=kind, operand=mapped),
415
+ self._sources,
416
+ ),
417
+ )
418
+
419
+ @self.typer_app.command()
420
+ def tree(operand: _Operand, maxdepth: _MaxDepth = None) -> None:
421
+ """Display a recursive directory tree."""
422
+ mapped = self._mapped("tree", operand)
423
+ _run_async_command(
424
+ "tree",
425
+ lambda: _run_tree(
426
+ "tree",
427
+ _TreeRequest(maxdepth=maxdepth, operand=mapped),
428
+ self._sources,
429
+ ),
430
+ )
431
+
432
+ def _listing(
433
+ self,
434
+ operands: Sequence[str],
435
+ *,
436
+ include_almost_all: bool,
437
+ long_listing: bool,
438
+ human_readable: bool,
439
+ ) -> None:
440
+ """Preflight and run one ``ls`` invocation."""
441
+ mapped = self._mapped_all("ls", operands)
442
+ if human_readable and not long_listing:
443
+ _usage_error("ls", "-h: requires long listing")
444
+ _run_async_command(
445
+ "ls",
446
+ lambda: _run_ls(
447
+ "ls",
448
+ _LsRequest(
449
+ include_almost_all=include_almost_all,
450
+ long_listing=long_listing,
451
+ human_readable=human_readable,
452
+ operands=mapped,
453
+ ),
454
+ self._sources,
455
+ ),
456
+ )
457
+
458
+ def _register_read_commands(self) -> None:
459
+ """Register the byte-reading commands."""
460
+
461
+ @self.typer_app.command()
462
+ def head(operand: _Operand, count: _ByteCount) -> None:
463
+ """Display leading bytes."""
464
+ mapped = self._mapped("head", operand)
465
+ _run_async_command(
466
+ "head",
467
+ lambda: _run_byte_range(
468
+ "head",
469
+ count,
470
+ mapped,
471
+ self._sources,
472
+ _read_head,
473
+ ),
474
+ )
475
+
476
+ @self.typer_app.command()
477
+ def tail(operand: _Operand, count: _ByteCount) -> None:
478
+ """Display trailing bytes."""
479
+ mapped = self._mapped("tail", operand)
480
+ _run_async_command(
481
+ "tail",
482
+ lambda: _run_byte_range(
483
+ "tail",
484
+ count,
485
+ mapped,
486
+ self._sources,
487
+ _read_tail,
488
+ ),
489
+ )
490
+
491
+ @self.typer_app.command()
492
+ def cat(operands: _CatOperands = None) -> None:
493
+ """Concatenate files to standard output."""
494
+ parsed = tuple(
495
+ _StdinOperand() if operand == "-" else _parse_mapped_operand("cat", operand, self._sources)
496
+ for operand in operands or ("-",)
497
+ )
498
+ _run_async_command(
499
+ "cat",
500
+ lambda: _run_cat("cat", parsed, self._sources),
501
+ )
502
+
503
+ def _register_transfer_commands(self) -> None:
504
+ """Register ``cp`` in its capability-selected form, plus ``mv``."""
505
+ if self._capabilities.recursive_copy:
506
+
507
+ @self.typer_app.command()
508
+ def cp(
509
+ operands: _CpOperands,
510
+ *,
511
+ recursive: _CopyRecursive = False,
512
+ metadata_wait: _MetadataWait = _DEFAULT_METADATA_WAIT_SECONDS,
513
+ ) -> None:
514
+ """Copy files or one directory."""
515
+ self._copy(operands, recursive=recursive, metadata_wait=metadata_wait)
516
+
517
+ else:
518
+
519
+ @self.typer_app.command()
520
+ def cp(
521
+ operands: _CpOperands,
522
+ *,
523
+ metadata_wait: _MetadataWait = _DEFAULT_METADATA_WAIT_SECONDS,
524
+ ) -> None:
525
+ """Copy one or more files."""
526
+ self._copy(operands, recursive=False, metadata_wait=metadata_wait)
527
+
528
+ @self.typer_app.command()
529
+ def mv(operands: _MvOperands) -> None:
530
+ """Move or rename files on one mapped filesystem."""
531
+ if len(operands) < 2: # noqa: PLR2004 - command arity.
532
+ message = "requires at least one source and one destination"
533
+ raise typer.BadParameter(message, param_hint="name:/path")
534
+ plan = _plan_mv("mv", self._mapped_all("mv", operands))
535
+ _run_async_command(
536
+ "mv",
537
+ lambda: _run_mv("mv", plan, self._sources),
538
+ )
539
+
540
+ def _copy(self, operands: Sequence[str], *, recursive: bool, metadata_wait: int) -> None:
541
+ """Preflight and run one shared ``cp`` invocation."""
542
+ plan = _cp_plan(
543
+ "cp",
544
+ tuple(operands),
545
+ self._sources,
546
+ recursive=recursive,
547
+ metadata_wait=metadata_wait,
548
+ )
549
+ _run_async_command("cp", lambda: _run_cp("cp", plan, self._sources))
550
+
551
+ def _register_mutation_commands(self) -> None:
552
+ """Register the namespace creation and removal commands."""
553
+
554
+ @self.typer_app.command()
555
+ def mkdir(operands: _Operands, *, parents: _CreateParents = False) -> None:
556
+ """Create directories."""
557
+ mapped = self._mapped_all("mkdir", operands)
558
+ _run_async_command(
559
+ "mkdir",
560
+ lambda: _run_mkdir(
561
+ "mkdir",
562
+ _MkdirRequest(create_parents=parents, operands=mapped),
563
+ self._sources,
564
+ ),
565
+ )
566
+
567
+ @self.typer_app.command()
568
+ def rmdir(operands: _Operands) -> None:
569
+ """Remove empty directories."""
570
+ mapped = tuple(self._destructive_operand("rmdir", operand) for operand in operands)
571
+ _run_async_command(
572
+ "rmdir",
573
+ lambda: _run_rmdir("rmdir", mapped, self._sources),
574
+ )
575
+
576
+ @self.typer_app.command()
577
+ def unlink(operand: _Operand) -> None:
578
+ """Remove a single file."""
579
+ mapped = self._destructive_operand("unlink", operand)
580
+ _run_async_command(
581
+ "unlink",
582
+ lambda: _run_unlink("unlink", mapped, self._sources),
583
+ )
584
+
585
+ if self._capabilities.recursive_remove:
586
+
587
+ @self.typer_app.command(name="rm")
588
+ def recursive_rm(
589
+ operands: _OptionalOperands = None,
590
+ *,
591
+ directory: _RmDirectory = False,
592
+ force: _RmForce = False,
593
+ verbose: _RmVerbose = 0,
594
+ recursive: _RmRecursive = False,
595
+ ) -> None:
596
+ """Remove files or directories with guarded -R or -r."""
597
+ self._remove(
598
+ operands,
599
+ directory=directory,
600
+ force=force,
601
+ verbose=verbose,
602
+ recursive=recursive,
603
+ )
604
+
605
+ else:
606
+
607
+ @self.typer_app.command(name="rm")
608
+ def rm(
609
+ operands: _OptionalOperands = None,
610
+ *,
611
+ directory: _RmDirectory = False,
612
+ force: _RmForce = False,
613
+ verbose: _RmVerbose = 0,
614
+ ) -> None:
615
+ """Remove files; -d removes empty directories."""
616
+ self._remove(
617
+ operands,
618
+ directory=directory,
619
+ force=force,
620
+ verbose=verbose,
621
+ )
622
+
623
+ def _remove(
624
+ self,
625
+ operands: Sequence[str] | None,
626
+ *,
627
+ directory: bool,
628
+ force: bool,
629
+ verbose: int,
630
+ recursive: bool = False,
631
+ ) -> None:
632
+ """Preflight option combinations and paths, then run one ``rm``."""
633
+ if verbose > 1:
634
+ _usage_error("rm", "-v: may be supplied once")
635
+ if directory and (force or verbose or recursive):
636
+ _usage_error("rm", "-d: cannot combine with other options")
637
+ if not recursive and force and verbose:
638
+ _usage_error("rm", "-f: cannot combine with -v")
639
+ spellings = list(operands or [])
640
+ if not spellings and not force:
641
+ _usage_error("rm", "missing mapped filesystem operand")
642
+ mapped = self._mapped_all("rm", spellings)
643
+ for spelling, operand in zip(spellings, mapped, strict=True):
644
+ rejected = _is_root(operand.path) or (
645
+ _has_dot_segment(operand.path) if recursive else _has_final_dot_segment(operand.path)
646
+ )
647
+ if rejected:
648
+ rendered = _render_diagnostic_value(spelling)
649
+ _usage_error("rm", f"{rendered}: rejected path")
650
+ _run_async_command(
651
+ "rm",
652
+ lambda: _run_rm(
653
+ "rm",
654
+ _RmRequest(
655
+ force=force,
656
+ directory=directory,
657
+ recursive=recursive,
658
+ verbose=bool(verbose),
659
+ operands=mapped,
660
+ ),
661
+ self._sources,
662
+ ),
663
+ )