xrdclient 0.1.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.
- xrdclient/__init__.py +249 -0
- xrdclient/_compat.py +86 -0
- xrdclient/_log.py +96 -0
- xrdclient/aio.py +1040 -0
- xrdclient/auth/__init__.py +399 -0
- xrdclient/auth/base.py +107 -0
- xrdclient/auth/gsi.py +678 -0
- xrdclient/auth/kerberos/__init__.py +22 -0
- xrdclient/auth/kerberos/asn1.py +524 -0
- xrdclient/auth/kerberos/caches.py +99 -0
- xrdclient/auth/kerberos/ccache.py +303 -0
- xrdclient/auth/kerberos/kcm.py +254 -0
- xrdclient/auth/kerberos/kdc.py +152 -0
- xrdclient/auth/kerberos/keyring.py +334 -0
- xrdclient/auth/kerberos/model.py +119 -0
- xrdclient/auth/kerberos/profile.py +256 -0
- xrdclient/auth/kerberos/tgs.py +234 -0
- xrdclient/auth/krb5.py +272 -0
- xrdclient/auth/prompt.py +196 -0
- xrdclient/auth/simple.py +51 -0
- xrdclient/auth/sss.py +251 -0
- xrdclient/auth/ztn.py +206 -0
- xrdclient/cli/__init__.py +214 -0
- xrdclient/cli/cp.py +598 -0
- xrdclient/cli/fs.py +721 -0
- xrdclient/client/__init__.py +9 -0
- xrdclient/client/_fattr.py +70 -0
- xrdclient/client/_zip.py +203 -0
- xrdclient/client/bulk.py +494 -0
- xrdclient/client/file.py +1461 -0
- xrdclient/client/filesystem.py +1252 -0
- xrdclient/compat/__init__.py +59 -0
- xrdclient/compat/client/__init__.py +86 -0
- xrdclient/compat/client/_args.py +67 -0
- xrdclient/compat/client/_channels.py +54 -0
- xrdclient/compat/client/_convert.py +275 -0
- xrdclient/compat/client/_dispatch.py +217 -0
- xrdclient/compat/client/_status.py +171 -0
- xrdclient/compat/client/_version.py +7 -0
- xrdclient/compat/client/copyprocess.py +489 -0
- xrdclient/compat/client/env.py +455 -0
- xrdclient/compat/client/file.py +557 -0
- xrdclient/compat/client/filesystem.py +599 -0
- xrdclient/compat/client/finalize.py +30 -0
- xrdclient/compat/client/flags.py +174 -0
- xrdclient/compat/client/glob_funcs.py +89 -0
- xrdclient/compat/client/responses.py +458 -0
- xrdclient/compat/client/tape.py +393 -0
- xrdclient/compat/client/url.py +114 -0
- xrdclient/compat/client/utils.py +81 -0
- xrdclient/compat/client/xattr.py +53 -0
- xrdclient/config.py +538 -0
- xrdclient/copy/__init__.py +28 -0
- xrdclient/copy/engine.py +1300 -0
- xrdclient/copy/limits.py +149 -0
- xrdclient/copy/replicas.py +270 -0
- xrdclient/copy/tpc.py +290 -0
- xrdclient/crypto/__init__.py +124 -0
- xrdclient/crypto/aes.py +238 -0
- xrdclient/crypto/blowfish.py +188 -0
- xrdclient/crypto/checksum.py +122 -0
- xrdclient/crypto/crc32c.py +136 -0
- xrdclient/crypto/crc64.py +58 -0
- xrdclient/crypto/delegation.py +281 -0
- xrdclient/crypto/der.py +234 -0
- xrdclient/crypto/rfc3961.py +340 -0
- xrdclient/crypto/rsa.py +412 -0
- xrdclient/crypto/sigver.py +191 -0
- xrdclient/crypto/trust.py +139 -0
- xrdclient/crypto/x509.py +431 -0
- xrdclient/doctor.py +416 -0
- xrdclient/easy.py +391 -0
- xrdclient/errors.py +366 -0
- xrdclient/flags.py +453 -0
- xrdclient/fsspec_impl.py +499 -0
- xrdclient/http/__init__.py +35 -0
- xrdclient/http/client.py +654 -0
- xrdclient/http/dav.py +744 -0
- xrdclient/http/file.py +493 -0
- xrdclient/http/tape.py +256 -0
- xrdclient/http/tpc.py +341 -0
- xrdclient/io/__init__.py +207 -0
- xrdclient/io/raw.py +222 -0
- xrdclient/path.py +303 -0
- xrdclient/proto/__init__.py +25 -0
- xrdclient/proto/buffer.py +174 -0
- xrdclient/proto/constants.py +320 -0
- xrdclient/proto/frames.py +164 -0
- xrdclient/proto/machine.py +945 -0
- xrdclient/proto/requests.py +1011 -0
- xrdclient/proto/responses.py +675 -0
- xrdclient/py.typed +0 -0
- xrdclient/s3/__init__.py +34 -0
- xrdclient/s3/fs.py +837 -0
- xrdclient/s3/sigv4.py +219 -0
- xrdclient/session/__init__.py +9 -0
- xrdclient/session/bulk.py +639 -0
- xrdclient/session/deadline.py +93 -0
- xrdclient/session/pool.py +345 -0
- xrdclient/session/router.py +732 -0
- xrdclient/session/sync.py +800 -0
- xrdclient/testing/__init__.py +35 -0
- xrdclient/testing/__main__.py +13 -0
- xrdclient/testing/faults.py +363 -0
- xrdclient/testing/http.py +724 -0
- xrdclient/testing/s3.py +522 -0
- xrdclient/testing/server.py +1482 -0
- xrdclient/transport/__init__.py +27 -0
- xrdclient/transport/base.py +84 -0
- xrdclient/transport/memory.py +79 -0
- xrdclient/transport/sync.py +95 -0
- xrdclient/types.py +425 -0
- xrdclient/url.py +363 -0
- xrdclient-0.1.0.dist-info/METADATA +387 -0
- xrdclient-0.1.0.dist-info/RECORD +119 -0
- xrdclient-0.1.0.dist-info/WHEEL +4 -0
- xrdclient-0.1.0.dist-info/entry_points.txt +11 -0
- xrdclient-0.1.0.dist-info/licenses/COPYING +674 -0
- xrdclient-0.1.0.dist-info/licenses/LICENSE +165 -0
xrdclient/__init__.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""A pure-Python XRootD client.
|
|
2
|
+
|
|
3
|
+
Speaks the native ``root://`` binary protocol and HTTP/WebDAV, with no
|
|
4
|
+
compiled extension and no XRootD installation required.
|
|
5
|
+
|
|
6
|
+
>>> import xrdclient
|
|
7
|
+
>>> with xrdclient.open("root://eos.example.org//store/data.root") as f:
|
|
8
|
+
... header = f.read(1024)
|
|
9
|
+
|
|
10
|
+
The five levels of the API, from most to least convenient:
|
|
11
|
+
|
|
12
|
+
:mod:`xrdclient.easy`
|
|
13
|
+
One-line verbs on a URL - ``xrdclient.ls``, ``xrdclient.size``, ``xrdclient.read_text`` -
|
|
14
|
+
for when there is one question to ask and no reason to build anything.
|
|
15
|
+
``xrdclient.open`` / :class:`~xrdclient.path.XRootDPath`
|
|
16
|
+
File objects and ``pathlib`` semantics.
|
|
17
|
+
:class:`~xrdclient.client.FileSystem` / :class:`~xrdclient.client.File`
|
|
18
|
+
Explicit per-operation control, sync or async.
|
|
19
|
+
:class:`~xrdclient.session.Session`
|
|
20
|
+
A single authenticated connection.
|
|
21
|
+
:mod:`xrdclient.proto`
|
|
22
|
+
The sans-io protocol machinery.
|
|
23
|
+
|
|
24
|
+
Three packages build on this one and are installed separately:
|
|
25
|
+
`xrdroot <https://github.com/rob-c/xrdroot>`_ reads and writes the ROOT file
|
|
26
|
+
format over any URL here, `xrdml <https://github.com/rob-c/xrdml>`_ hands a
|
|
27
|
+
file of rows to PyTorch a minibatch at a time, and
|
|
28
|
+
`xrddatasets <https://github.com/rob-c/xrddatasets>`_ converts open data into
|
|
29
|
+
ROOT files and publishes the catalogue they are served from.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from .client import Checkpoint, File, FileSystem
|
|
37
|
+
from .config import Config, configure, current, find_config_file, override
|
|
38
|
+
from .copy import CopyResult, SyncMode, copy, copy_tree, third_party
|
|
39
|
+
from .easy import (
|
|
40
|
+
checksum,
|
|
41
|
+
exists,
|
|
42
|
+
glob,
|
|
43
|
+
is_online,
|
|
44
|
+
ls,
|
|
45
|
+
mkdir,
|
|
46
|
+
move,
|
|
47
|
+
read_bytes,
|
|
48
|
+
read_text,
|
|
49
|
+
remove,
|
|
50
|
+
size,
|
|
51
|
+
stage,
|
|
52
|
+
stat,
|
|
53
|
+
write_bytes,
|
|
54
|
+
write_text,
|
|
55
|
+
)
|
|
56
|
+
from .errors import (
|
|
57
|
+
AttrNotFoundError,
|
|
58
|
+
AuthenticationError,
|
|
59
|
+
BusyError,
|
|
60
|
+
ChecksumMismatchError,
|
|
61
|
+
ConnectionError,
|
|
62
|
+
CredentialError,
|
|
63
|
+
InvalidArgumentError,
|
|
64
|
+
NoMechanismError,
|
|
65
|
+
NoSpaceError,
|
|
66
|
+
NotFoundError,
|
|
67
|
+
PageIntegrityError,
|
|
68
|
+
ProtocolError,
|
|
69
|
+
QuotaError,
|
|
70
|
+
ReadOnlyError,
|
|
71
|
+
RedirectLimitError,
|
|
72
|
+
ServerError,
|
|
73
|
+
ServerTimeoutError,
|
|
74
|
+
TimeoutError,
|
|
75
|
+
TLSRequiredError,
|
|
76
|
+
TokenExpiredError,
|
|
77
|
+
TooLargeError,
|
|
78
|
+
TransientError,
|
|
79
|
+
UnsupportedError,
|
|
80
|
+
WaitLimitError,
|
|
81
|
+
XRootDError,
|
|
82
|
+
)
|
|
83
|
+
from .flags import (
|
|
84
|
+
Access,
|
|
85
|
+
DirListFlags,
|
|
86
|
+
LocateFlags,
|
|
87
|
+
MkDirFlags,
|
|
88
|
+
OpenFlags,
|
|
89
|
+
PrepareFlags,
|
|
90
|
+
QueryCode,
|
|
91
|
+
StatInfoFlags,
|
|
92
|
+
)
|
|
93
|
+
from .io import open_url as open
|
|
94
|
+
from .path import XRootDPath
|
|
95
|
+
from .path import XRootDPath as Path # ``xrdclient.Path`` reads the way pathlib does
|
|
96
|
+
from .session.deadline import OperationExpiredError, deadline
|
|
97
|
+
from .types import (
|
|
98
|
+
CheckpointInfo,
|
|
99
|
+
ChecksumInfo,
|
|
100
|
+
CloneRange,
|
|
101
|
+
DirEntry,
|
|
102
|
+
LocationInfo,
|
|
103
|
+
PageResult,
|
|
104
|
+
PrepareStatus,
|
|
105
|
+
ProtocolInfo,
|
|
106
|
+
ReadRange,
|
|
107
|
+
SpaceInfo,
|
|
108
|
+
StatInfo,
|
|
109
|
+
VFSInfo,
|
|
110
|
+
WriteChunk,
|
|
111
|
+
human_bytes,
|
|
112
|
+
)
|
|
113
|
+
from .url import XRootDURL, parse
|
|
114
|
+
|
|
115
|
+
if TYPE_CHECKING: # bound at runtime by ``__getattr__`` below, named here so
|
|
116
|
+
# that a type checker and the documentation can both see what they are
|
|
117
|
+
from .doctor import Check, Report, diagnose
|
|
118
|
+
del TYPE_CHECKING # imported for the block above, not part of the package
|
|
119
|
+
|
|
120
|
+
__version__ = "0.1.0"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Names that live in a submodule nobody should pay to import. Each is bound
|
|
124
|
+
#: on first use by :func:`__getattr__`; the value is the module it comes from.
|
|
125
|
+
_LAZY = {"Check": "doctor", "Report": "doctor", "diagnose": "doctor"}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def __getattr__(name: str) -> object:
|
|
129
|
+
"""Expose ``xrdclient.aio`` and the diagnostics lazily.
|
|
130
|
+
|
|
131
|
+
Each is one attribute access away and neither costs anything until it is
|
|
132
|
+
asked for: nothing under ``xrd`` imports :mod:`asyncio` until someone
|
|
133
|
+
reaches for ``xrdclient.aio``, or the environment checks until someone runs
|
|
134
|
+
:func:`~xrdclient.doctor.diagnose`. A transfer is the common case and pays for
|
|
135
|
+
neither.
|
|
136
|
+
"""
|
|
137
|
+
import importlib
|
|
138
|
+
|
|
139
|
+
if name in ("aio", "doctor"):
|
|
140
|
+
# Not ``from . import aio``: the import system resolves that by asking
|
|
141
|
+
# this very function for the attribute, and the recursion is infinite.
|
|
142
|
+
return importlib.import_module(f"{__name__}.{name}")
|
|
143
|
+
if name in _LAZY:
|
|
144
|
+
value = getattr(importlib.import_module(f"{__name__}.{_LAZY[name]}"), name)
|
|
145
|
+
globals()[name] = value # bound once; later lookups skip this function
|
|
146
|
+
return value
|
|
147
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = [
|
|
151
|
+
"__version__",
|
|
152
|
+
# the facades that are imported on first use
|
|
153
|
+
"aio",
|
|
154
|
+
# configuration
|
|
155
|
+
"Config",
|
|
156
|
+
"configure",
|
|
157
|
+
"current",
|
|
158
|
+
"override",
|
|
159
|
+
"find_config_file",
|
|
160
|
+
# a time limit on whatever runs inside a block
|
|
161
|
+
"deadline",
|
|
162
|
+
# copying
|
|
163
|
+
"copy",
|
|
164
|
+
"copy_tree",
|
|
165
|
+
"third_party",
|
|
166
|
+
"CopyResult",
|
|
167
|
+
"SyncMode",
|
|
168
|
+
# diagnosing
|
|
169
|
+
"diagnose",
|
|
170
|
+
"Check",
|
|
171
|
+
"Report",
|
|
172
|
+
# files and paths
|
|
173
|
+
"open",
|
|
174
|
+
"XRootDPath",
|
|
175
|
+
"Path",
|
|
176
|
+
"FileSystem",
|
|
177
|
+
"File",
|
|
178
|
+
"Checkpoint",
|
|
179
|
+
# one-line verbs, for when a URL is all you have
|
|
180
|
+
"ls",
|
|
181
|
+
"glob",
|
|
182
|
+
"stat",
|
|
183
|
+
"exists",
|
|
184
|
+
"size",
|
|
185
|
+
"checksum",
|
|
186
|
+
"read_bytes",
|
|
187
|
+
"read_text",
|
|
188
|
+
"write_bytes",
|
|
189
|
+
"write_text",
|
|
190
|
+
"mkdir",
|
|
191
|
+
"remove",
|
|
192
|
+
"move",
|
|
193
|
+
"stage",
|
|
194
|
+
"is_online",
|
|
195
|
+
"human_bytes",
|
|
196
|
+
# urls
|
|
197
|
+
"XRootDURL",
|
|
198
|
+
"parse",
|
|
199
|
+
# values
|
|
200
|
+
"CheckpointInfo",
|
|
201
|
+
"ChecksumInfo",
|
|
202
|
+
"CloneRange",
|
|
203
|
+
"DirEntry",
|
|
204
|
+
"LocationInfo",
|
|
205
|
+
"PageResult",
|
|
206
|
+
"ProtocolInfo",
|
|
207
|
+
"ReadRange",
|
|
208
|
+
"StatInfo",
|
|
209
|
+
"SpaceInfo",
|
|
210
|
+
"PrepareStatus",
|
|
211
|
+
"VFSInfo",
|
|
212
|
+
"WriteChunk",
|
|
213
|
+
# flags
|
|
214
|
+
"Access",
|
|
215
|
+
"DirListFlags",
|
|
216
|
+
"LocateFlags",
|
|
217
|
+
"MkDirFlags",
|
|
218
|
+
"OpenFlags",
|
|
219
|
+
"PrepareFlags",
|
|
220
|
+
"QueryCode",
|
|
221
|
+
"StatInfoFlags",
|
|
222
|
+
# errors
|
|
223
|
+
"XRootDError",
|
|
224
|
+
"ProtocolError",
|
|
225
|
+
"ConnectionError",
|
|
226
|
+
"TimeoutError",
|
|
227
|
+
"OperationExpiredError",
|
|
228
|
+
"TransientError",
|
|
229
|
+
"AuthenticationError",
|
|
230
|
+
"NoMechanismError",
|
|
231
|
+
"CredentialError",
|
|
232
|
+
"TokenExpiredError",
|
|
233
|
+
"RedirectLimitError",
|
|
234
|
+
"WaitLimitError",
|
|
235
|
+
"ChecksumMismatchError",
|
|
236
|
+
"PageIntegrityError",
|
|
237
|
+
"ServerError",
|
|
238
|
+
"NotFoundError",
|
|
239
|
+
"NoSpaceError",
|
|
240
|
+
"UnsupportedError",
|
|
241
|
+
"ReadOnlyError",
|
|
242
|
+
"QuotaError",
|
|
243
|
+
"AttrNotFoundError",
|
|
244
|
+
"BusyError",
|
|
245
|
+
"InvalidArgumentError",
|
|
246
|
+
"ServerTimeoutError",
|
|
247
|
+
"TLSRequiredError",
|
|
248
|
+
"TooLargeError",
|
|
249
|
+
]
|
xrdclient/_compat.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""What the oldest Python this library runs on has not got.
|
|
2
|
+
|
|
3
|
+
That is 3.9, because it is what RHEL 9 and AlmaLinux 9 ship, and a physicist
|
|
4
|
+
on a login node at a site that runs one of those cannot choose otherwise. The
|
|
5
|
+
handful of things 3.10 added that this library would use anyway are written
|
|
6
|
+
out here rather than scattered through it, and where the interpreter has the
|
|
7
|
+
real thing, the real thing is what runs.
|
|
8
|
+
|
|
9
|
+
Nothing else in this package may name a version. If a module needs something
|
|
10
|
+
newer than the floor, it belongs here, with the fallback beside it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import socket
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Iterable, Iterator
|
|
19
|
+
from typing import Any, TypeVar, overload
|
|
20
|
+
|
|
21
|
+
__all__ = ["SLOTS", "TIMEOUTS", "flag_members", "zip_strict"]
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
U = TypeVar("U")
|
|
25
|
+
|
|
26
|
+
#: ``slots=True`` for a dataclass, where the interpreter takes it. Slots make
|
|
27
|
+
#: a record smaller and its attributes quicker to reach; 3.9 has no such
|
|
28
|
+
#: argument, and the only thing missing there is that saving.
|
|
29
|
+
SLOTS: dict[str, bool] = {"slots": True} if sys.version_info >= (3, 10) else {}
|
|
30
|
+
|
|
31
|
+
#: What a socket raises when it runs out of patience. 3.10 made
|
|
32
|
+
#: :exc:`socket.timeout` another name for :exc:`TimeoutError`; before that they
|
|
33
|
+
#: were two exceptions and only the first was ever raised, so every ``except``
|
|
34
|
+
#: that means "the far end went quiet" names both.
|
|
35
|
+
TIMEOUTS: tuple[type[BaseException], ...] = (socket.timeout, TimeoutError)
|
|
36
|
+
|
|
37
|
+
#: Whether :func:`zip` can do the length check itself, in C.
|
|
38
|
+
_NATIVE_STRICT_ZIP = sys.version_info >= (3, 10)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@overload
|
|
42
|
+
def zip_strict(a: Iterable[T], b: Iterable[U], /) -> Iterator[tuple[T, U]]: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def zip_strict(*iterables: Iterable[Any]) -> Iterator[tuple[Any, ...]]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def zip_strict(*iterables: Iterable[Any]) -> Iterator[tuple[Any, ...]]:
|
|
50
|
+
""":func:`zip`, refusing iterables that turn out to be different lengths.
|
|
51
|
+
|
|
52
|
+
``zip(..., strict=True)`` where there is one, and the same check written
|
|
53
|
+
out where there is not. The check is the whole point: everything paired
|
|
54
|
+
this way in this library is two halves of one thing - a flag per path, a
|
|
55
|
+
byte per byte of the block before it - and a bug that makes them different
|
|
56
|
+
lengths is invisible if the shorter one simply ends the loop.
|
|
57
|
+
"""
|
|
58
|
+
if _NATIVE_STRICT_ZIP:
|
|
59
|
+
return zip(*iterables, strict=True)
|
|
60
|
+
return _paired(*iterables)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def flag_members(flag: Any) -> list[Any]:
|
|
64
|
+
"""The single bits a :class:`~enum.Flag` is made of, in declaration order.
|
|
65
|
+
|
|
66
|
+
3.11 taught a flag member to iterate into its own parts. Before that a
|
|
67
|
+
flag was not iterable at all, so the parts are picked out of the class
|
|
68
|
+
here: the same members, in the same order, whatever the interpreter. A bit
|
|
69
|
+
nobody named is dropped, as 3.11 drops it, and a member standing for more
|
|
70
|
+
than one bit is not a part of anything.
|
|
71
|
+
"""
|
|
72
|
+
bits = int(flag)
|
|
73
|
+
return [
|
|
74
|
+
member
|
|
75
|
+
for member in type(flag)
|
|
76
|
+
if member.value and not member.value & (member.value - 1) and member.value & bits
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _paired(*iterables: Iterable[Any]) -> Iterator[tuple[Any, ...]]:
|
|
81
|
+
"""3.9's :func:`zip`, with the length check 3.10 does for itself."""
|
|
82
|
+
ended = object()
|
|
83
|
+
for values in itertools.zip_longest(*iterables, fillvalue=ended):
|
|
84
|
+
if any(value is ended for value in values):
|
|
85
|
+
raise ValueError("zip_strict() was given iterables of different lengths")
|
|
86
|
+
yield values
|
xrdclient/_log.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Logging helpers.
|
|
2
|
+
|
|
3
|
+
Everything logs under the ``xrdclient.`` hierarchy through a filter that redacts
|
|
4
|
+
credential material, so enabling DEBUG never leaks a token into a log file.
|
|
5
|
+
|
|
6
|
+
The same filter can mute parts of the hierarchy at some levels and not
|
|
7
|
+
others (:func:`mute`) - what XrdCl's per-level topic masks do, and what the
|
|
8
|
+
compatibility layer's ``SetLogMask`` is built on.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
|
|
17
|
+
__all__ = ["get_logger", "mute", "redact"]
|
|
18
|
+
|
|
19
|
+
_PATTERNS = (
|
|
20
|
+
re.compile(r"(authz=)[^&\s'\"]+", re.I),
|
|
21
|
+
re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/-]+=*", re.I),
|
|
22
|
+
re.compile(r"(eyJ[A-Za-z0-9_-]{4,})\.[A-Za-z0-9._-]+"),
|
|
23
|
+
re.compile(r"((?:token|password|secret|keytab|cred)['\"]?\s*[=:]\s*)\S+", re.I),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def redact(text: str) -> str:
|
|
28
|
+
"""Replace credential material in ``text`` with ``<redacted>``."""
|
|
29
|
+
for pat in _PATTERNS:
|
|
30
|
+
text = pat.sub(r"\1<redacted>", text)
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _RedactingFilter(logging.Filter):
|
|
35
|
+
"""Interpolate first, then redact the result.
|
|
36
|
+
|
|
37
|
+
Redacting the format string and the arguments separately misses anything
|
|
38
|
+
that only looks like a credential once they are joined - ``"%s=%s"`` with
|
|
39
|
+
``("token", secret)`` sails straight through - and it can corrupt the
|
|
40
|
+
format string itself, since ``"keytab: %s"`` is exactly the shape of a
|
|
41
|
+
secret assignment. Formatting up front has neither problem, and handlers
|
|
42
|
+
call :meth:`~logging.LogRecord.getMessage` anyway.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
46
|
+
if _muted and _is_muted(record):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
message = record.getMessage()
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return True # a broken format string is logging's problem, not ours
|
|
52
|
+
cleaned = redact(message)
|
|
53
|
+
if cleaned != message or record.args:
|
|
54
|
+
record.msg = cleaned
|
|
55
|
+
record.args = ()
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: ``(lowest level, level above the highest) -> logger prefixes`` whose
|
|
60
|
+
#: records in that band of levels are dropped. Replaced, never mutated, so
|
|
61
|
+
#: the filter reads it without a lock.
|
|
62
|
+
_muted: dict[tuple[int, int], tuple[str, ...]] = {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mute(low: int, high: int, loggers: Iterable[str]) -> None:
|
|
66
|
+
"""Drop records from ``loggers`` with a level from ``low`` up to ``high``.
|
|
67
|
+
|
|
68
|
+
Each name covers its logger and every logger below it, and replaces what
|
|
69
|
+
was muted in that band before; no names un-mutes it.
|
|
70
|
+
"""
|
|
71
|
+
global _muted
|
|
72
|
+
prefixes = tuple(f"{name}." for name in loggers)
|
|
73
|
+
changed = {band: names for band, names in _muted.items() if band != (low, high)}
|
|
74
|
+
if prefixes:
|
|
75
|
+
changed[(low, high)] = prefixes
|
|
76
|
+
_muted = changed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_muted(record: logging.LogRecord) -> bool:
|
|
80
|
+
name = f"{record.name}."
|
|
81
|
+
return any(
|
|
82
|
+
low <= record.levelno < high and name.startswith(prefixes)
|
|
83
|
+
for (low, high), prefixes in _muted.items()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_filter = _RedactingFilter()
|
|
88
|
+
_root = logging.getLogger("xrdclient")
|
|
89
|
+
_root.addFilter(_filter)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_logger(name: str) -> logging.Logger:
|
|
93
|
+
"""A logger under ``xrdclient.`` with redaction applied."""
|
|
94
|
+
log = logging.getLogger(name if name.startswith("xrdclient") else f"xrdclient.{name}")
|
|
95
|
+
log.addFilter(_filter)
|
|
96
|
+
return log
|