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/_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)