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