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/__init__.py +24 -0
- fss_cli/_accounts.py +29 -0
- fss_cli/_app.py +663 -0
- fss_cli/_cat.py +391 -0
- fss_cli/_command.py +368 -0
- fss_cli/_concurrent.py +181 -0
- fss_cli/_content.py +94 -0
- fss_cli/_copy_file.py +65 -0
- fss_cli/_cp.py +740 -0
- fss_cli/_diagnostics.py +12 -0
- fss_cli/_du.py +90 -0
- fss_cli/_find.py +109 -0
- fss_cli/_head_tail.py +128 -0
- fss_cli/_info.py +179 -0
- fss_cli/_listing.py +289 -0
- fss_cli/_logging.py +72 -0
- fss_cli/_ls.py +435 -0
- fss_cli/_manifest.py +264 -0
- fss_cli/_metadata.py +70 -0
- fss_cli/_mkdir.py +97 -0
- fss_cli/_mv.py +146 -0
- fss_cli/_path.py +62 -0
- fss_cli/_post_copy.py +191 -0
- fss_cli/_progress.py +132 -0
- fss_cli/_recursive_cp.py +908 -0
- fss_cli/_recursive_rm.py +335 -0
- fss_cli/_results.py +115 -0
- fss_cli/_rm.py +149 -0
- fss_cli/_rmdir.py +148 -0
- fss_cli/_size.py +95 -0
- fss_cli/_sources.py +178 -0
- fss_cli/_stat.py +196 -0
- fss_cli/_test.py +59 -0
- fss_cli/_tree.py +208 -0
- fss_cli/_unlink.py +90 -0
- fss_cli/_walk.py +141 -0
- fss_cli-0.11.0.dist-info/METADATA +144 -0
- fss_cli-0.11.0.dist-info/RECORD +40 -0
- fss_cli-0.11.0.dist-info/WHEEL +4 -0
- fss_cli-0.11.0.dist-info/licenses/LICENSE +661 -0
fss_cli/_metadata.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Snapshot backend mappings through their authoritative iteration interface."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
_Key = TypeVar("_Key")
|
|
9
|
+
_Value = TypeVar("_Value")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def valid_display_text(value: str) -> bool:
|
|
13
|
+
"""Reject separators that can split or overwrite output records."""
|
|
14
|
+
return not any(character in value for character in ("\0", "\r", "\n"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def snapshot_mapping(value: Mapping[_Key, _Value]) -> dict[_Key, _Value]:
|
|
18
|
+
"""Freeze indexed values, rejecting incomplete or changing enumeration."""
|
|
19
|
+
expected = len(value)
|
|
20
|
+
result: dict[_Key, _Value] = {}
|
|
21
|
+
count = 0
|
|
22
|
+
for key in value:
|
|
23
|
+
if count >= expected:
|
|
24
|
+
message = "mapping enumerates more entries than its length"
|
|
25
|
+
raise ValueError(message)
|
|
26
|
+
result[key] = value[key]
|
|
27
|
+
count += 1
|
|
28
|
+
if count != expected or len(result) != expected or len(value) != expected:
|
|
29
|
+
message = "mapping enumeration does not match its length"
|
|
30
|
+
raise ValueError(message)
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _supported_time(normalized: float) -> float | None:
|
|
35
|
+
if not math.isfinite(normalized):
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
datetime.fromtimestamp(normalized, tz=timezone.utc)
|
|
39
|
+
except (OverflowError, OSError, ValueError):
|
|
40
|
+
return None
|
|
41
|
+
return normalized
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _datetime_time(value: datetime) -> float | None:
|
|
45
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
46
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
47
|
+
try:
|
|
48
|
+
return _supported_time(value.timestamp())
|
|
49
|
+
except (OverflowError, OSError, ValueError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_time(value: object) -> float | None:
|
|
54
|
+
if type(value) is int or type(value) is float:
|
|
55
|
+
try:
|
|
56
|
+
normalized = float(value)
|
|
57
|
+
except OverflowError:
|
|
58
|
+
return None
|
|
59
|
+
return _supported_time(normalized)
|
|
60
|
+
if isinstance(value, datetime):
|
|
61
|
+
return _datetime_time(value)
|
|
62
|
+
if not isinstance(value, str):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
spelling = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
66
|
+
try:
|
|
67
|
+
parsed = datetime.fromisoformat(spelling)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return _datetime_time(parsed)
|
fss_cli/_mkdir.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Typed async execution for ``mkdir``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ._command import (
|
|
10
|
+
_backend_category,
|
|
11
|
+
_drain_current_operation,
|
|
12
|
+
_Failure,
|
|
13
|
+
_MappedOperand,
|
|
14
|
+
_raise_operand_failures,
|
|
15
|
+
_render_operand_diagnostic,
|
|
16
|
+
_run_mapped_command,
|
|
17
|
+
)
|
|
18
|
+
from ._command import _render_failure as _render_base_failure
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from fsspec.asyn import AsyncFileSystem
|
|
22
|
+
|
|
23
|
+
from ._app import AsyncFilesystemSource
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class _MkdirRequest:
|
|
28
|
+
create_parents: bool
|
|
29
|
+
operands: tuple[_MappedOperand, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _run_mkdir(
|
|
33
|
+
command: str,
|
|
34
|
+
request: _MkdirRequest,
|
|
35
|
+
sources: Mapping[str, AsyncFilesystemSource],
|
|
36
|
+
) -> None:
|
|
37
|
+
async def operation(filesystems: Mapping[str, AsyncFileSystem]) -> None:
|
|
38
|
+
failures = await _trace_operands(request, filesystems)
|
|
39
|
+
if failures:
|
|
40
|
+
_raise_operand_failures(command, failures, _render_failure)
|
|
41
|
+
|
|
42
|
+
await _run_mapped_command(command, request.operands, sources, operation)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _trace_operands(
|
|
46
|
+
request: _MkdirRequest,
|
|
47
|
+
filesystems: Mapping[str, AsyncFileSystem],
|
|
48
|
+
) -> tuple[_Failure, ...]:
|
|
49
|
+
failures = []
|
|
50
|
+
for operand in request.operands:
|
|
51
|
+
result = await _create_operand(
|
|
52
|
+
operand,
|
|
53
|
+
filesystems[operand.name],
|
|
54
|
+
create_parents=request.create_parents,
|
|
55
|
+
)
|
|
56
|
+
if isinstance(result, _Failure):
|
|
57
|
+
failures.append(result)
|
|
58
|
+
return tuple(failures)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _create_operand(
|
|
62
|
+
operand: _MappedOperand,
|
|
63
|
+
filesystem: AsyncFileSystem,
|
|
64
|
+
*,
|
|
65
|
+
create_parents: bool,
|
|
66
|
+
) -> _Failure | None:
|
|
67
|
+
try:
|
|
68
|
+
if create_parents:
|
|
69
|
+
await _drain_current_operation(filesystem._makedirs(operand.path, exist_ok=True))
|
|
70
|
+
else:
|
|
71
|
+
await _drain_current_operation(filesystem._mkdir(operand.path, create_parents=False))
|
|
72
|
+
except Exception as error: # noqa: BLE001 - classify awaited backend failure.
|
|
73
|
+
return _Failure(operand, backend_error=error)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
info = await _drain_current_operation(filesystem._info(operand.path))
|
|
77
|
+
except Exception as error: # noqa: BLE001 - post-mutation verify is uncertain.
|
|
78
|
+
return _Failure(operand, backend_error=error, uncertain=True)
|
|
79
|
+
|
|
80
|
+
if not isinstance(info, Mapping) or not isinstance(info.get("type"), str):
|
|
81
|
+
return _Failure(operand, uncertain=True)
|
|
82
|
+
|
|
83
|
+
if info["type"] != "directory":
|
|
84
|
+
return _Failure(operand, uncertain=True)
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_failure(command: str, failure: _Failure) -> None:
|
|
90
|
+
if not failure.uncertain:
|
|
91
|
+
_render_base_failure(command, failure)
|
|
92
|
+
return
|
|
93
|
+
if failure.backend_error is None:
|
|
94
|
+
category = "uncertain state (incompatible result)"
|
|
95
|
+
else:
|
|
96
|
+
category = f"uncertain state ({_backend_category(failure.backend_error)})"
|
|
97
|
+
_render_operand_diagnostic(command, failure.operand, category)
|
fss_cli/_mv.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Typed async execution for same-source file ``mv``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from fsspec.asyn import AsyncFileSystem
|
|
11
|
+
|
|
12
|
+
from ._command import (
|
|
13
|
+
_call,
|
|
14
|
+
_CommandFailureError,
|
|
15
|
+
_MappedOperand,
|
|
16
|
+
_run_mapped_command,
|
|
17
|
+
_usage_error,
|
|
18
|
+
)
|
|
19
|
+
from ._cp import (
|
|
20
|
+
_CpFailure,
|
|
21
|
+
_CpRequest,
|
|
22
|
+
_log_verified_transfer,
|
|
23
|
+
_prepare_transfer,
|
|
24
|
+
_render_failure,
|
|
25
|
+
_require_directory,
|
|
26
|
+
_verify_transfer,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Mapping as MappingType
|
|
31
|
+
|
|
32
|
+
from ._app import AsyncFilesystemSource
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class _MvPlan:
|
|
37
|
+
operands: tuple[_MappedOperand, ...]
|
|
38
|
+
requests: tuple[_CpRequest, ...]
|
|
39
|
+
require_directory: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _plan_mv(
|
|
43
|
+
command: str,
|
|
44
|
+
operands: tuple[_MappedOperand, ...],
|
|
45
|
+
) -> _MvPlan:
|
|
46
|
+
sources = operands[:-1]
|
|
47
|
+
destination = operands[-1]
|
|
48
|
+
if any(source.name != destination.name for source in sources):
|
|
49
|
+
_usage_error(command, "cross-source move unsupported")
|
|
50
|
+
return _MvPlan(
|
|
51
|
+
operands=operands,
|
|
52
|
+
requests=tuple(_CpRequest(source, destination) for source in sources),
|
|
53
|
+
require_directory=len(sources) > 1,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _declared_move(filesystem: AsyncFileSystem) -> str | None:
|
|
58
|
+
"""Return the async move hook this filesystem actually implements.
|
|
59
|
+
|
|
60
|
+
``_mv_file`` is fsspec's hook. ``getattr`` sees an override inherited from
|
|
61
|
+
a parent. The stock ``AsyncFileSystem._mv_file`` (copy, then delete) does
|
|
62
|
+
not count: a class that declares ``_mv`` keeps that operation, and a
|
|
63
|
+
filesystem with neither stays refused.
|
|
64
|
+
"""
|
|
65
|
+
mv_file = getattr(filesystem, "_mv_file", None)
|
|
66
|
+
implementation = getattr(mv_file, "__func__", mv_file)
|
|
67
|
+
if inspect.iscoroutinefunction(mv_file) and implementation is not AsyncFileSystem._mv_file:
|
|
68
|
+
return "_mv_file"
|
|
69
|
+
declared = type(filesystem).__dict__.get("_mv")
|
|
70
|
+
if inspect.iscoroutinefunction(declared):
|
|
71
|
+
return "_mv"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _confirmed_mv_file(request: _CpRequest, filesystem: AsyncFileSystem) -> _CpFailure | None:
|
|
76
|
+
started = time.monotonic()
|
|
77
|
+
prepared = await _prepare_transfer(request, filesystem, filesystem)
|
|
78
|
+
if isinstance(prepared, _CpFailure):
|
|
79
|
+
return prepared
|
|
80
|
+
proof, resolved = prepared.proof, prepared.resolved
|
|
81
|
+
if request.source.path == resolved:
|
|
82
|
+
return None
|
|
83
|
+
operation = _declared_move(filesystem)
|
|
84
|
+
if operation is None:
|
|
85
|
+
return _CpFailure(
|
|
86
|
+
request.destination,
|
|
87
|
+
backend_error=NotImplementedError("_mv_file or _mv must be configured by source form"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
await _call(filesystem, operation, request.source.path, resolved)
|
|
92
|
+
except Exception as operation_error: # noqa: BLE001
|
|
93
|
+
return _CpFailure(
|
|
94
|
+
request.destination,
|
|
95
|
+
backend_error=operation_error,
|
|
96
|
+
uncertain=True,
|
|
97
|
+
residue=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
failure = await _verify_transfer(
|
|
101
|
+
filesystem,
|
|
102
|
+
filesystem,
|
|
103
|
+
request.source.path,
|
|
104
|
+
resolved,
|
|
105
|
+
proof,
|
|
106
|
+
request.destination,
|
|
107
|
+
require_source_absent=True,
|
|
108
|
+
)
|
|
109
|
+
if failure is None:
|
|
110
|
+
_log_verified_transfer("moved", request, resolved, proof, started)
|
|
111
|
+
return failure
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _run_mv(
|
|
115
|
+
command: str,
|
|
116
|
+
plan: _MvPlan,
|
|
117
|
+
sources: MappingType[str, AsyncFilesystemSource],
|
|
118
|
+
) -> None:
|
|
119
|
+
async def operation(filesystems: MappingType[str, AsyncFileSystem]) -> None:
|
|
120
|
+
filesystem = filesystems[plan.requests[0].source.name]
|
|
121
|
+
failure = None
|
|
122
|
+
if plan.require_directory:
|
|
123
|
+
failure = await _require_directory(
|
|
124
|
+
plan.requests[0].destination,
|
|
125
|
+
filesystem,
|
|
126
|
+
)
|
|
127
|
+
if failure is None:
|
|
128
|
+
for request in plan.requests:
|
|
129
|
+
failure = await _confirmed_mv_file(request, filesystem)
|
|
130
|
+
if failure is not None:
|
|
131
|
+
break
|
|
132
|
+
if failure is not None:
|
|
133
|
+
try:
|
|
134
|
+
_render_failure(command, failure)
|
|
135
|
+
except Exception as error:
|
|
136
|
+
raise _CommandFailureError(
|
|
137
|
+
error=failure.backend_error,
|
|
138
|
+
render=False,
|
|
139
|
+
propagate=error,
|
|
140
|
+
) from error
|
|
141
|
+
raise _CommandFailureError(
|
|
142
|
+
error=failure.backend_error,
|
|
143
|
+
render=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
await _run_mapped_command(command, plan.operands, sources, operation)
|
fss_cli/_path.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pure lexical path helpers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _is_root(path: str) -> bool:
|
|
5
|
+
return bool(path) and not path.rstrip("/")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _lexical_root(path: str) -> str:
|
|
9
|
+
return path.rstrip("/") or "/"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _has_dot_segment(path: str) -> bool:
|
|
13
|
+
return any(component in {".", ".."} for component in path.split("/"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lexical_components(path: str) -> tuple[str, ...]:
|
|
17
|
+
return tuple(component for component in path.split("/") if component)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _same_lexical_path(left: str, right: str) -> bool:
|
|
21
|
+
return _lexical_components(left) == _lexical_components(right)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_same_or_descendant(parent: str, candidate: str) -> bool:
|
|
25
|
+
parent_components = _lexical_components(parent)
|
|
26
|
+
candidate_components = _lexical_components(candidate)
|
|
27
|
+
return candidate_components[: len(parent_components)] == parent_components
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lexical_relative(parent: str, candidate: str) -> str | None:
|
|
31
|
+
if not _is_same_or_descendant(parent, candidate):
|
|
32
|
+
return None
|
|
33
|
+
return "/".join(_lexical_components(candidate)[len(_lexical_components(parent)) :])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lexical_basename(path: str) -> str:
|
|
37
|
+
if _is_root(path):
|
|
38
|
+
return "/"
|
|
39
|
+
path = path.rstrip("/")
|
|
40
|
+
return path.rsplit("/", 1)[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_final_dot_segment(path: str) -> bool:
|
|
44
|
+
return _lexical_basename(path) in {".", ".."}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _lexical_parent(path: str) -> str:
|
|
48
|
+
if _is_root(path):
|
|
49
|
+
return "/"
|
|
50
|
+
path = path.rstrip("/")
|
|
51
|
+
if "/" not in path:
|
|
52
|
+
return "."
|
|
53
|
+
parent = path.rpartition("/")[0]
|
|
54
|
+
return parent or "/"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _lexical_join(parent: str, child: str) -> str:
|
|
58
|
+
parent = parent.rstrip("/")
|
|
59
|
+
child = "" if child == "/" else child
|
|
60
|
+
if parent in {"", "/"}:
|
|
61
|
+
return f"/{child}"
|
|
62
|
+
return f"{parent}/{child}"
|
fss_cli/_post_copy.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Post-copy destination verification shared by file and recursive ``cp``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from ._command import (
|
|
13
|
+
_CommandFailureError,
|
|
14
|
+
_drain_current_operation,
|
|
15
|
+
_MappedOperand,
|
|
16
|
+
_render_operand_diagnostic,
|
|
17
|
+
)
|
|
18
|
+
from ._diagnostics import _render_diagnostic_value
|
|
19
|
+
from ._manifest import _TOKEN_ALIASES
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from fsspec.asyn import AsyncFileSystem
|
|
23
|
+
|
|
24
|
+
_DEFAULT_METADATA_WAIT_SECONDS = 60
|
|
25
|
+
_METADATA_POLL_SECONDS = 5
|
|
26
|
+
_report_gate: ContextVar[asyncio.Event | None] = ContextVar("fss_cli_report_gate", default=None)
|
|
27
|
+
_report_ready: ContextVar[bool] = ContextVar("fss_cli_report_ready", default=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class _TransferProof:
|
|
32
|
+
expected_size: int
|
|
33
|
+
tokens: tuple[tuple[str, frozenset[str | bytes]], ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class _PreviousFile:
|
|
38
|
+
"""The file a resolved destination held before the copy, from resolution ``_info``."""
|
|
39
|
+
|
|
40
|
+
size: int | None
|
|
41
|
+
tokens: Mapping[str, frozenset[str | bytes]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class _Verdict:
|
|
46
|
+
"""One classified post-copy ``_info``; ``text`` is ``None`` once verified."""
|
|
47
|
+
|
|
48
|
+
text: str | None
|
|
49
|
+
stale: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class _Unverified:
|
|
54
|
+
"""Why a copied file did not verify; ``error`` is a raised ``_info`` failure."""
|
|
55
|
+
|
|
56
|
+
text: str
|
|
57
|
+
error: Exception | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _require_file_size(info: object) -> int | None:
|
|
61
|
+
if not isinstance(info, Mapping):
|
|
62
|
+
return None
|
|
63
|
+
result_type = info.get("type")
|
|
64
|
+
if not isinstance(result_type, str) or result_type != "file":
|
|
65
|
+
return None
|
|
66
|
+
size = info.get("size")
|
|
67
|
+
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
|
|
68
|
+
return None
|
|
69
|
+
return size
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _verification_tokens(info: object) -> dict[str, frozenset[str | bytes]]:
|
|
73
|
+
if not isinstance(info, Mapping):
|
|
74
|
+
return {}
|
|
75
|
+
tokens: dict[str, frozenset[str | bytes]] = {}
|
|
76
|
+
for normalized, aliases in _TOKEN_ALIASES:
|
|
77
|
+
values = frozenset(
|
|
78
|
+
value for alias in aliases if (type(value := info.get(alias)) is str or type(value) is bytes)
|
|
79
|
+
)
|
|
80
|
+
if values:
|
|
81
|
+
tokens[normalized] = values
|
|
82
|
+
return tokens
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _post_copy_verdict( # noqa: PLR0911 - one return per verification outcome.
|
|
86
|
+
info: object,
|
|
87
|
+
proof: _TransferProof,
|
|
88
|
+
previous: _PreviousFile | None,
|
|
89
|
+
mutation: str,
|
|
90
|
+
) -> _Verdict:
|
|
91
|
+
"""Classify one post-copy destination ``_info`` against the frozen proof.
|
|
92
|
+
|
|
93
|
+
Stale means the reported metadata still looks like no size, or like the
|
|
94
|
+
file that was there before this copy; only a stale verdict is polled.
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(info, Mapping) or not isinstance(info.get("type"), str):
|
|
97
|
+
return _Verdict("verification failure")
|
|
98
|
+
if info["type"] != "file":
|
|
99
|
+
return _Verdict(f"not a file after {mutation}")
|
|
100
|
+
observed = _require_file_size(info)
|
|
101
|
+
if observed is None:
|
|
102
|
+
return _Verdict("verification failure")
|
|
103
|
+
expected = proof.expected_size
|
|
104
|
+
if observed != expected:
|
|
105
|
+
if observed == 0 or (previous is not None and observed == previous.size):
|
|
106
|
+
return _Verdict(f"not yet verified (size {observed}, expected {expected})", stale=True)
|
|
107
|
+
return _Verdict(f"size mismatch (expected {expected}, observed {observed})")
|
|
108
|
+
tokens = _verification_tokens(info)
|
|
109
|
+
differing = [name for name, values in proof.tokens if name in tokens and tokens[name] != values]
|
|
110
|
+
if not differing:
|
|
111
|
+
return _Verdict(None)
|
|
112
|
+
changed = [name for name in differing if previous is None or previous.tokens.get(name) != tokens[name]]
|
|
113
|
+
if changed:
|
|
114
|
+
return _Verdict(f"metadata mismatch ({changed[0]})")
|
|
115
|
+
return _Verdict(f"not yet verified ({differing[0]} unchanged)", stale=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _await_verified( # noqa: PLR0913 - one explicit post-copy proof boundary.
|
|
119
|
+
filesystem: AsyncFileSystem,
|
|
120
|
+
path: str,
|
|
121
|
+
proof: _TransferProof,
|
|
122
|
+
previous: _PreviousFile | None,
|
|
123
|
+
*,
|
|
124
|
+
mutation: str,
|
|
125
|
+
wait: int,
|
|
126
|
+
) -> _Unverified | None:
|
|
127
|
+
"""Read destination ``_info`` until it matches the proof, stops being stale, or ``wait`` ends."""
|
|
128
|
+
deadline = time.monotonic() + wait
|
|
129
|
+
while True:
|
|
130
|
+
try:
|
|
131
|
+
info = await _drain_current_operation(filesystem._info(path))
|
|
132
|
+
except Exception as error: # noqa: BLE001 - post-copy verify is residue-bearing.
|
|
133
|
+
rendered_class = _render_diagnostic_value(type(error).__name__)
|
|
134
|
+
return _Unverified(f"verification failure ({rendered_class})", error)
|
|
135
|
+
verdict = _post_copy_verdict(info, proof, previous, mutation)
|
|
136
|
+
if verdict.text is None:
|
|
137
|
+
return None
|
|
138
|
+
remaining = deadline - time.monotonic()
|
|
139
|
+
if not verdict.stale or remaining <= 0:
|
|
140
|
+
return _Unverified(verdict.text)
|
|
141
|
+
await asyncio.sleep(min(_METADATA_POLL_SECONDS, remaining))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def report_verified( # noqa: PLR0913 - shared per-file outcome and verification contract.
|
|
145
|
+
command: str,
|
|
146
|
+
filesystem: AsyncFileSystem,
|
|
147
|
+
target: _MappedOperand,
|
|
148
|
+
proof: _TransferProof,
|
|
149
|
+
previous: _PreviousFile | None,
|
|
150
|
+
*,
|
|
151
|
+
mutation: str,
|
|
152
|
+
wait: int,
|
|
153
|
+
) -> _Unverified | None:
|
|
154
|
+
"""Report a completed transfer and its proof in this operand's output turn."""
|
|
155
|
+
await _await_report_turn()
|
|
156
|
+
verb = "copied" if mutation == "copy" else "uploaded"
|
|
157
|
+
_report(command, target, f"{verb} {proof.expected_size} bytes")
|
|
158
|
+
unverified = await _await_verified(filesystem, target.path, proof, previous, mutation=mutation, wait=wait)
|
|
159
|
+
if unverified is None:
|
|
160
|
+
_report(command, target, f"verified {proof.expected_size} bytes")
|
|
161
|
+
return unverified
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolved_operand(destination: _MappedOperand, resolved: str) -> _MappedOperand:
|
|
165
|
+
return _MappedOperand(
|
|
166
|
+
spelling=f"{destination.name}:{resolved}",
|
|
167
|
+
name=destination.name,
|
|
168
|
+
path=resolved,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _await_report_turn() -> None:
|
|
173
|
+
"""Let earlier multi-source copies finish printing before this one does.
|
|
174
|
+
|
|
175
|
+
The wait happens once per copy, immediately before its first outcome line,
|
|
176
|
+
so the byte transfer itself still overlaps and a failed write still aborts
|
|
177
|
+
the rest of that copy.
|
|
178
|
+
"""
|
|
179
|
+
gate = _report_gate.get()
|
|
180
|
+
if gate is None or _report_ready.get():
|
|
181
|
+
return
|
|
182
|
+
await gate.wait()
|
|
183
|
+
_report_ready.set(True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _report(command: str, operand: _MappedOperand, outcome: str) -> None:
|
|
187
|
+
"""Write one copy outcome line to stderr; a failed write ends the invocation."""
|
|
188
|
+
try:
|
|
189
|
+
_render_operand_diagnostic(command, operand, outcome)
|
|
190
|
+
except Exception as error:
|
|
191
|
+
raise _CommandFailureError(error=error, render=False, propagate=error) from error
|
fss_cli/_progress.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""One shared Rich progress display for a ``cp`` invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import contextmanager, redirect_stderr, suppress
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from fsspec.callbacks import DEFAULT_CALLBACK, Callback
|
|
11
|
+
from rich import progress as rich_progress
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.file_proxy import FileProxy
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
|
|
16
|
+
from ._diagnostics import _render_diagnostic_value
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _draws_progress() -> bool:
|
|
23
|
+
"""Draw only on a terminal stderr, and never when ``NO_COLOR`` is set to a value.
|
|
24
|
+
|
|
25
|
+
Rich's own terminal detection is not used: ``FORCE_COLOR`` makes it treat
|
|
26
|
+
a pipe as a terminal, and a pipe must never receive a bar.
|
|
27
|
+
"""
|
|
28
|
+
if os.environ.get("NO_COLOR"):
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
return sys.stderr.isatty()
|
|
32
|
+
except (AttributeError, ValueError):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _StderrProxy(FileProxy):
|
|
37
|
+
"""Rich's line proxy, reporting the text attributes click checks before writing to a stream.
|
|
38
|
+
|
|
39
|
+
Without them click treats the proxy as incompatible and writes to the
|
|
40
|
+
proxied file's binary buffer, through the live bars.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def encoding(self) -> str:
|
|
45
|
+
return getattr(self.rich_proxied_file, "encoding", None) or "utf-8"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def errors(self) -> str:
|
|
49
|
+
return getattr(self.rich_proxied_file, "errors", None) or "strict"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _TaskCallback(Callback):
|
|
53
|
+
"""Mirror one fsspec transfer's byte counts onto a Rich task."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, progress: rich_progress.Progress, task: rich_progress.TaskID) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self._progress = progress
|
|
58
|
+
self._task = task
|
|
59
|
+
|
|
60
|
+
def call(self, hook_name: str | None = None, **kwargs: object) -> None:
|
|
61
|
+
del hook_name, kwargs
|
|
62
|
+
with suppress(KeyError): # A backend reporting after its hook returned.
|
|
63
|
+
self._progress.update(self._task, total=self.size, completed=self.value, visible=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Transfer:
|
|
67
|
+
"""One file's bar: hidden until its backend reports bytes, removed by ``finish``."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, progress: rich_progress.Progress | None) -> None:
|
|
70
|
+
self._progress = progress
|
|
71
|
+
self._task: rich_progress.TaskID | None = None
|
|
72
|
+
|
|
73
|
+
def phase(self, verb: str, label: str) -> Callback:
|
|
74
|
+
"""Point the bar at the next hook call and return that call's callback."""
|
|
75
|
+
progress = self._progress
|
|
76
|
+
if progress is None:
|
|
77
|
+
return DEFAULT_CALLBACK
|
|
78
|
+
description = f"{verb} {escape(_render_diagnostic_value(label))}"
|
|
79
|
+
if self._task is None:
|
|
80
|
+
self._task = progress.add_task(description, total=None, visible=False)
|
|
81
|
+
else:
|
|
82
|
+
progress.reset(self._task, total=None, completed=0, visible=False, description=description)
|
|
83
|
+
return _TaskCallback(progress, self._task)
|
|
84
|
+
|
|
85
|
+
def finish(self) -> None:
|
|
86
|
+
if self._progress is not None and self._task is not None:
|
|
87
|
+
self._progress.remove_task(self._task)
|
|
88
|
+
# Lines printed next redraw the last rendered frame, which still holds this bar.
|
|
89
|
+
self._progress.refresh()
|
|
90
|
+
self._task = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _TransferDisplay:
|
|
94
|
+
"""The invocation's bars, or none when stderr is not a terminal."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, progress: rich_progress.Progress | None = None) -> None:
|
|
97
|
+
self._progress = progress
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def transfer(self) -> Generator[_Transfer, None, None]:
|
|
101
|
+
transfer = _Transfer(self._progress)
|
|
102
|
+
try:
|
|
103
|
+
yield transfer
|
|
104
|
+
finally:
|
|
105
|
+
transfer.finish()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_NO_DISPLAY = _TransferDisplay()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def _transfer_display() -> Generator[_TransferDisplay, None, None]:
|
|
113
|
+
"""Run one ``cp`` invocation under a shared stderr ``Progress`` when bars are drawn.
|
|
114
|
+
|
|
115
|
+
Stderr writes during the invocation go through Rich so outcome lines print
|
|
116
|
+
above the live bars instead of through them.
|
|
117
|
+
"""
|
|
118
|
+
if not _draws_progress():
|
|
119
|
+
yield _NO_DISPLAY
|
|
120
|
+
return
|
|
121
|
+
console = Console(file=sys.stderr)
|
|
122
|
+
progress = rich_progress.Progress(
|
|
123
|
+
rich_progress.TextColumn("[bold blue]{task.description}"),
|
|
124
|
+
rich_progress.BarColumn(bar_width=None),
|
|
125
|
+
rich_progress.TimeRemainingColumn(),
|
|
126
|
+
console=console,
|
|
127
|
+
transient=True,
|
|
128
|
+
redirect_stdout=False,
|
|
129
|
+
redirect_stderr=False,
|
|
130
|
+
)
|
|
131
|
+
with progress, redirect_stderr(_StderrProxy(console, sys.stderr)):
|
|
132
|
+
yield _TransferDisplay(progress)
|