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/_listing.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Pure normalization and rendering helpers for heterogeneous fsspec metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import stat
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import TYPE_CHECKING, Literal
|
|
11
|
+
|
|
12
|
+
from ._accounts import group_name, owner_name
|
|
13
|
+
from ._metadata import normalize_time as _normalize_time
|
|
14
|
+
from ._metadata import snapshot_mapping, valid_display_text
|
|
15
|
+
from ._path import _lexical_basename
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
_INFO_FIELDS = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"name",
|
|
23
|
+
"type",
|
|
24
|
+
"islink",
|
|
25
|
+
"size",
|
|
26
|
+
"mtime",
|
|
27
|
+
"LastModified",
|
|
28
|
+
"last_modified",
|
|
29
|
+
"mode",
|
|
30
|
+
"nlink",
|
|
31
|
+
"uid",
|
|
32
|
+
"gid",
|
|
33
|
+
"destination",
|
|
34
|
+
"target",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
_MTIME_FIELDS = ("mtime", "LastModified", "last_modified")
|
|
38
|
+
_SIZE_UNITS = ("K", "M", "G", "T", "P", "E", "Z", "Y")
|
|
39
|
+
_SIZE_BASE = 1024
|
|
40
|
+
_SINGLE_DECIMAL_LIMIT = 100
|
|
41
|
+
# SGR 22 ends bold without resetting colors a name may carry; SGR 0 would.
|
|
42
|
+
_BOLD = "\x1b[1m"
|
|
43
|
+
_NORMAL_INTENSITY = "\x1b[22m"
|
|
44
|
+
|
|
45
|
+
ListingKind = Literal["file", "dir", "link", "other"]
|
|
46
|
+
_MODE_KIND_BY_TYPE: dict[int, ListingKind] = {
|
|
47
|
+
stat.S_IFREG: "file",
|
|
48
|
+
stat.S_IFDIR: "dir",
|
|
49
|
+
stat.S_IFLNK: "link",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ListingRow:
|
|
55
|
+
"""Backend-neutral metadata used by listing and information commands."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
kind: ListingKind
|
|
59
|
+
size: int | None
|
|
60
|
+
mtime: float | None
|
|
61
|
+
mode: int | None
|
|
62
|
+
nlink: int | None
|
|
63
|
+
owner: str | int | None
|
|
64
|
+
group: str | int | None
|
|
65
|
+
link_target: str | None
|
|
66
|
+
extra: Mapping[str, object]
|
|
67
|
+
permissions: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _kind(info: Mapping[str, object]) -> ListingKind:
|
|
71
|
+
if info.get("islink") is True:
|
|
72
|
+
return "link"
|
|
73
|
+
|
|
74
|
+
value = info.get("type")
|
|
75
|
+
if value == "file":
|
|
76
|
+
return "file"
|
|
77
|
+
if value in {"dir", "directory"}:
|
|
78
|
+
return "dir"
|
|
79
|
+
if value in {"link", "symlink"}:
|
|
80
|
+
return "link"
|
|
81
|
+
return "other"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _non_negative_int(value: object) -> int | None:
|
|
85
|
+
return value if type(value) is int and value >= 0 else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _identity(value: object) -> str | int | None:
|
|
89
|
+
if type(value) is int:
|
|
90
|
+
return value if value >= 0 else None
|
|
91
|
+
if type(value) is str:
|
|
92
|
+
if not valid_display_text(value):
|
|
93
|
+
message = "identity contains a terminal control character"
|
|
94
|
+
raise ValueError(message)
|
|
95
|
+
return value
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _group(info: Mapping[str, object]) -> str | int | None:
|
|
100
|
+
for field in ("group", "gid"):
|
|
101
|
+
if info.get(field) is not None:
|
|
102
|
+
return _identity(info[field])
|
|
103
|
+
if "read_groups" not in info and "write_groups" not in info:
|
|
104
|
+
return None
|
|
105
|
+
summaries = []
|
|
106
|
+
for field, prefix in (("read_groups", "r"), ("write_groups", "w")):
|
|
107
|
+
groups = info.get(field)
|
|
108
|
+
if groups is None:
|
|
109
|
+
summary = "?"
|
|
110
|
+
elif isinstance(groups, (tuple, list)) and all(
|
|
111
|
+
type(group) is str and group and valid_display_text(group) for group in groups
|
|
112
|
+
):
|
|
113
|
+
summary = ",".join(str(group) for group in groups) if groups else "NONE"
|
|
114
|
+
else:
|
|
115
|
+
message = "invalid access group list"
|
|
116
|
+
raise ValueError(message)
|
|
117
|
+
summaries.append(f"{prefix}={summary}")
|
|
118
|
+
return ",".join(summaries)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _permissions(info: Mapping[str, object]) -> str | None:
|
|
122
|
+
value = info.get("permissions")
|
|
123
|
+
if value is None:
|
|
124
|
+
return None
|
|
125
|
+
if type(value) is not str or re.fullmatch(r"[-dl?][rwx?-]{9}", value) is None:
|
|
126
|
+
message = "invalid access summary"
|
|
127
|
+
raise ValueError(message)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _mtime(info: Mapping[str, object]) -> float | None:
|
|
132
|
+
for field in _MTIME_FIELDS:
|
|
133
|
+
if field in info:
|
|
134
|
+
return _normalize_time(info[field])
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _link_target(info: Mapping[str, object]) -> str | None:
|
|
139
|
+
for field in ("destination", "target"):
|
|
140
|
+
value = info.get(field)
|
|
141
|
+
if type(value) is str:
|
|
142
|
+
if not valid_display_text(value):
|
|
143
|
+
message = "link target contains a terminal control character"
|
|
144
|
+
raise ValueError(message)
|
|
145
|
+
return value
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extra(info: Mapping[str, object]) -> Mapping[str, object]:
|
|
150
|
+
values = {key: value for key, value in info.items() if type(key) is str and key not in _INFO_FIELDS}
|
|
151
|
+
return MappingProxyType(values)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def to_listing(info: Mapping[str, object]) -> ListingRow:
|
|
155
|
+
"""Normalize one fsspec ``info`` mapping without filesystem I/O."""
|
|
156
|
+
info = snapshot_mapping(info)
|
|
157
|
+
name = info.get("name")
|
|
158
|
+
if type(name) is not str or not valid_display_text(name):
|
|
159
|
+
message = "info name must be a string"
|
|
160
|
+
raise ValueError(message)
|
|
161
|
+
|
|
162
|
+
return ListingRow(
|
|
163
|
+
name=_lexical_basename(name),
|
|
164
|
+
kind=_kind(info),
|
|
165
|
+
size=_non_negative_int(info.get("size")),
|
|
166
|
+
mtime=_mtime(info),
|
|
167
|
+
mode=_non_negative_int(info.get("mode")),
|
|
168
|
+
nlink=_non_negative_int(info.get("nlink")),
|
|
169
|
+
owner=_identity(info.get("owner", info.get("uid"))),
|
|
170
|
+
group=_group(info),
|
|
171
|
+
link_target=_link_target(info),
|
|
172
|
+
extra=_extra(info),
|
|
173
|
+
permissions=_permissions(info),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def format_size(size: int | None, *, human_readable: bool = False) -> str:
|
|
178
|
+
"""Render an exact byte count, or a compact 1024-base human size."""
|
|
179
|
+
if size is None:
|
|
180
|
+
return "-"
|
|
181
|
+
if type(size) is not int or size < 0:
|
|
182
|
+
message = "size must be a non-negative integer or None"
|
|
183
|
+
raise ValueError(message)
|
|
184
|
+
if not human_readable:
|
|
185
|
+
return str(size)
|
|
186
|
+
if size < _SIZE_BASE:
|
|
187
|
+
return f"{size}B"
|
|
188
|
+
|
|
189
|
+
unit_index = 0
|
|
190
|
+
unit_size = _SIZE_BASE
|
|
191
|
+
while True:
|
|
192
|
+
tenths = (size * 10 + unit_size // 2) // unit_size
|
|
193
|
+
if tenths < _SINGLE_DECIMAL_LIMIT:
|
|
194
|
+
whole, decimal = divmod(tenths, 10)
|
|
195
|
+
rendered = str(whole) if decimal == 0 else f"{whole}.{decimal}"
|
|
196
|
+
return f"{rendered}{_SIZE_UNITS[unit_index]}"
|
|
197
|
+
|
|
198
|
+
rounded = (size + unit_size // 2) // unit_size
|
|
199
|
+
if rounded < _SIZE_BASE or unit_index == len(_SIZE_UNITS) - 1:
|
|
200
|
+
return f"{rounded}{_SIZE_UNITS[unit_index]}"
|
|
201
|
+
unit_index += 1
|
|
202
|
+
unit_size *= _SIZE_BASE
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_mtime(value: float | None) -> str:
|
|
206
|
+
if value is None:
|
|
207
|
+
return "-"
|
|
208
|
+
now = time.time()
|
|
209
|
+
recent = now - 15_552_000 <= value <= now + 3600
|
|
210
|
+
return time.strftime("%b %e %H:%M" if recent else "%b %e %Y", time.localtime(value))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _type_indicator(row: ListingRow) -> str:
|
|
214
|
+
if row.mode is None:
|
|
215
|
+
return row.permissions or {"file": "-", "dir": "d", "link": "l", "other": "?"}[row.kind] + "?" * 9
|
|
216
|
+
mode_kind = _MODE_KIND_BY_TYPE.get(stat.S_IFMT(row.mode), "other")
|
|
217
|
+
if mode_kind != row.kind:
|
|
218
|
+
return {"file": "-", "dir": "d", "link": "l", "other": "?"}[row.kind] + "?" * 9
|
|
219
|
+
return stat.filemode(row.mode)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def emphasize(text: str) -> str:
|
|
223
|
+
"""Wrap text in bold, leaving every character of it untouched."""
|
|
224
|
+
return f"{_BOLD}{text}{_NORMAL_INTENSITY}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _display_name(row: ListingRow) -> str:
|
|
228
|
+
if row.kind == "link" and row.link_target is not None:
|
|
229
|
+
return f"{row.name} -> {row.link_target}"
|
|
230
|
+
return row.name
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def render_listing(
|
|
234
|
+
rows: Sequence[ListingRow],
|
|
235
|
+
*,
|
|
236
|
+
human_readable: bool = False,
|
|
237
|
+
emphasize_directories: bool = False,
|
|
238
|
+
) -> str:
|
|
239
|
+
"""Render stable shell columns with explicit gaps for unknown metadata.
|
|
240
|
+
|
|
241
|
+
With ``emphasize_directories``, directory names are bold; column widths are
|
|
242
|
+
still measured on the unstyled text.
|
|
243
|
+
"""
|
|
244
|
+
if not rows:
|
|
245
|
+
return ""
|
|
246
|
+
|
|
247
|
+
owners = {
|
|
248
|
+
value: "-" if value is None else owner_name(value) if isinstance(value, int) else value
|
|
249
|
+
for value in {row.owner for row in rows}
|
|
250
|
+
}
|
|
251
|
+
groups = {
|
|
252
|
+
value: "-" if value is None else group_name(value) if isinstance(value, int) else value
|
|
253
|
+
for value in {row.group for row in rows}
|
|
254
|
+
}
|
|
255
|
+
rendered_columns: list[tuple[bool, list[str]]] = [(False, [_type_indicator(row) for row in rows])]
|
|
256
|
+
rendered_columns.extend(
|
|
257
|
+
[
|
|
258
|
+
(True, ["-" if row.nlink is None else str(row.nlink) for row in rows]),
|
|
259
|
+
(
|
|
260
|
+
False,
|
|
261
|
+
[owners[row.owner] for row in rows],
|
|
262
|
+
),
|
|
263
|
+
(
|
|
264
|
+
False,
|
|
265
|
+
[groups[row.group] for row in rows],
|
|
266
|
+
),
|
|
267
|
+
(
|
|
268
|
+
True,
|
|
269
|
+
[format_size(row.size, human_readable=human_readable) for row in rows],
|
|
270
|
+
),
|
|
271
|
+
(False, [_format_mtime(row.mtime) for row in rows]),
|
|
272
|
+
]
|
|
273
|
+
)
|
|
274
|
+
rendered_columns.append((False, [_display_name(row) for row in rows]))
|
|
275
|
+
|
|
276
|
+
widths = [max(len(cell) for cell in column) for _, column in rendered_columns]
|
|
277
|
+
lines = []
|
|
278
|
+
for index in range(len(rows)):
|
|
279
|
+
cells = []
|
|
280
|
+
for column_index, (right_aligned, column) in enumerate(rendered_columns):
|
|
281
|
+
cell = column[index]
|
|
282
|
+
if column_index == len(rendered_columns) - 1:
|
|
283
|
+
cells.append(emphasize(cell) if emphasize_directories and rows[index].kind == "dir" else cell)
|
|
284
|
+
elif right_aligned:
|
|
285
|
+
cells.append(cell.rjust(widths[column_index]))
|
|
286
|
+
else:
|
|
287
|
+
cells.append(cell.ljust(widths[column_index]))
|
|
288
|
+
lines.append(" ".join(cells))
|
|
289
|
+
return "\n".join(lines) + "\n"
|
fss_cli/_logging.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Standard-library transfer records for embedding hosts.
|
|
2
|
+
|
|
3
|
+
Commands log under the ``fss_cli`` logger hierarchy so a host can route
|
|
4
|
+
them through its own handlers; the library itself installs only a
|
|
5
|
+
``NullHandler`` and never prints a record. One INFO record describes each file
|
|
6
|
+
operation; DEBUG records describe content-identity decisions and staging.
|
|
7
|
+
|
|
8
|
+
Records never carry credentials: every logged path or operand passes through
|
|
9
|
+
:func:`_redact`, which drops URL user information, query strings, and
|
|
10
|
+
fragments, and escapes control characters. Storage options are never logged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from ._diagnostics import _render_diagnostic_value
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
_USERINFO = re.compile(r"(?<=//)[^/?#@]*@")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _redact(value: str) -> str:
|
|
28
|
+
"""Return ``value`` safe to log: no userinfo, query, fragment, or controls."""
|
|
29
|
+
value = value.split("?", 1)[0].split("#", 1)[0]
|
|
30
|
+
return _render_diagnostic_value(_USERINFO.sub("", value))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _location(name: str, path: str) -> str:
|
|
34
|
+
"""Render one mapped location the way a user spells it, redacted."""
|
|
35
|
+
return _redact(f"{name}:{path}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _log_file_operation( # noqa: PLR0913 - one structured record per file.
|
|
39
|
+
logger: logging.Logger,
|
|
40
|
+
outcome: str,
|
|
41
|
+
source: str,
|
|
42
|
+
destination: str,
|
|
43
|
+
size: int | None,
|
|
44
|
+
started: float,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Emit the INFO record for one completed file operation.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
logger: The emitting module's logger.
|
|
50
|
+
outcome: ``copied``, ``staged``, ``skipped``, ``moved``, or
|
|
51
|
+
``verified``.
|
|
52
|
+
source: The redacted source location.
|
|
53
|
+
destination: The redacted destination location.
|
|
54
|
+
size: Bytes the operation concerned, when known.
|
|
55
|
+
started: The :func:`time.monotonic` value when the operation began.
|
|
56
|
+
"""
|
|
57
|
+
duration = time.monotonic() - started
|
|
58
|
+
logger.info(
|
|
59
|
+
"%s %s -> %s (%s bytes, %.3fs)",
|
|
60
|
+
outcome,
|
|
61
|
+
source,
|
|
62
|
+
destination,
|
|
63
|
+
"?" if size is None else size,
|
|
64
|
+
duration,
|
|
65
|
+
extra={
|
|
66
|
+
"outcome": outcome,
|
|
67
|
+
"source": source,
|
|
68
|
+
"destination": destination,
|
|
69
|
+
"bytes": size,
|
|
70
|
+
"duration": duration,
|
|
71
|
+
},
|
|
72
|
+
)
|