xrdcore 0.4.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.
xrdcore/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """xrdcore: the small, dependency-free core every grid storage client shares.
2
+
3
+ What lives here is what xrdclient, xgfalclient and xrdarc each needed and
4
+ none of them owns - nothing protocol-specific, nothing that needs a third-party
5
+ package to import:
6
+
7
+ * :mod:`.safexml` - XML parsing that refuses every ``<!DOCTYPE``, so no
8
+ entity can be declared, expanded or fetched;
9
+ * :mod:`.checksum`, :mod:`.crc32c`, :mod:`.crc64` - the checksum algorithms
10
+ storage servers report, behind one incremental, ``hashlib``-shaped surface;
11
+ * :mod:`.log` - credential redaction for logs and terminals, per-level
12
+ muting, and a command-line tool's ``-v`` handler;
13
+ * :mod:`.cancel` - cancel scopes and cancel hooks, so every layer looks at
14
+ the same "has the caller given up?";
15
+ * :mod:`.pipeline` - the bounded, read-ahead byte pump every streamed copy
16
+ ends in;
17
+ * :mod:`.retry` - the one exponential, capped, full-jitter backoff, and
18
+ HTTP ``Retry-After``;
19
+ * :mod:`.report` - the versioned ``storage-client-report`` JSON/XML document
20
+ every tool's ``--json``/``--xml`` writes;
21
+ * :mod:`.errors` - the exception vocabulary xrdcore, xrdsec and xrdhttp raise;
22
+ * :mod:`.fanout` - jobs run side by side on threads, the first failure
23
+ stopping the rest and raised as itself;
24
+ * :mod:`.sockets` - exactly-N-bytes reads from a framed stream socket;
25
+ * :mod:`.cli` - the exit statuses every command-line tool shares, the
26
+ wrapper that turns Ctrl-C and a closed pipe into them, and the one-line
27
+ handler for every other failure.
28
+
29
+ Importing this package imports none of them: each is loaded when first
30
+ named, so ``import xrdcore`` costs nothing a ``--help`` would notice.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib
36
+ from types import ModuleType
37
+ from typing import TYPE_CHECKING
38
+
39
+ from ._version import __version__
40
+
41
+ if TYPE_CHECKING:
42
+ from . import (
43
+ cancel,
44
+ checksum,
45
+ cli,
46
+ crc32c,
47
+ crc64,
48
+ errors,
49
+ fanout,
50
+ log,
51
+ pipeline,
52
+ report,
53
+ retry,
54
+ safexml,
55
+ sockets,
56
+ )
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ "cancel",
61
+ "checksum",
62
+ "cli",
63
+ "crc32c",
64
+ "crc64",
65
+ "errors",
66
+ "fanout",
67
+ "log",
68
+ "pipeline",
69
+ "report",
70
+ "retry",
71
+ "safexml",
72
+ "sockets",
73
+ ]
74
+
75
+ _SUBMODULES = frozenset(__all__) - {"__version__"}
76
+
77
+
78
+ def __getattr__(name: str) -> ModuleType:
79
+ """Import a submodule the first time it is named as an attribute."""
80
+ if name in _SUBMODULES:
81
+ return importlib.import_module(f"{__name__}.{name}")
82
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
83
+
84
+
85
+ def __dir__() -> list[str]:
86
+ """The public names, including submodules not yet imported."""
87
+ return sorted(__all__)
xrdcore/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The xrdcore distribution version, shared with the build backend."""
2
+
3
+ __version__ = "0.4.0"
xrdcore/cancel.py ADDED
@@ -0,0 +1,242 @@
1
+ """Cancellation: how a call learns that whoever started it has given up.
2
+
3
+ There are two ways in, for two kinds of caller, and they meet here so that a
4
+ maintainer has one place to read - and so that every library on the stack
5
+ (the XRootD session, the HTTP connections, the copy pipeline) looks at the
6
+ *same* scope, whichever client started the call.
7
+
8
+ * **Scopes** - :class:`CancelScope`, below - are how an asyncio front end
9
+ cancels the worker thread behind a cancelled coroutine. Nothing is torn
10
+ down: the request is abandoned where it waits, and the connection stays in
11
+ use.
12
+ * **Hooks** - a ``cancel=`` argument on a long transfer - are how a
13
+ synchronous caller stops it from another thread (gfal2's ``cancel()``, a
14
+ GUI button, a signal handler). A hook is asked between pieces of work, and
15
+ a watcher thread (:func:`watching`) aborts the handles in flight the moment
16
+ it fires, so a worker blocked on the network wakes at once. That costs the
17
+ connections, which is the point: the transfer is over, and so is anything
18
+ it had outstanding.
19
+
20
+ Scopes
21
+ ------
22
+
23
+ A scope reaches a request already waiting on the wire.
24
+
25
+ A blocking call cannot be interrupted from outside its thread. What can be
26
+ done is to tell the worker that nobody is waiting for the answer any more,
27
+ and have every place the worker might wait look for that. This module is the
28
+ telling::
29
+
30
+ scope = CancelScope()
31
+ worker = pool.submit(contextvars.copy_context().run, within, scope, call)
32
+ ...
33
+ scope.cancel() # from any thread - the event loop's, typically
34
+
35
+ A :class:`CancelScope` travels in a :mod:`contextvars` variable, so it reaches
36
+ whatever the call runs - including work started on threads of its own (a
37
+ fan-out, the copy read-ahead in :mod:`xrdcore.pipeline`) that copy the context
38
+ they were started from.
39
+
40
+ Once a scope is cancelled it stays cancelled (*level*-triggered, as Trio's
41
+ scopes are, not edge-triggered as :mod:`asyncio`'s are): every wait inside it
42
+ raises :class:`Cancelled` from then on, not only the first. That is what makes
43
+ a cancelled worker come back promptly whatever it was doing - a copy that
44
+ caught the first one in a retry, or a fan-out whose other jobs were mid-read,
45
+ would otherwise carry on regardless. The price is that a cancelled call's own
46
+ clean-up round trips (a checkpoint rollback, removing a partial remote target)
47
+ are skipped too; they would be waits on the very server that was too slow.
48
+
49
+ What looks at the scope is the transport's business: a session before it
50
+ sends a request and while it waits for one, an HTTP connection before it
51
+ sends. Waiting is done in slices of :data:`POLL` seconds while a scope is in
52
+ force (:func:`bounded`), which is how a waiter notices a cancellation without
53
+ anyone having to wake it: the canceller may be an event loop that must never
54
+ block on a session's lock, and a socket read cannot be woken without shutting
55
+ the socket down.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import contextvars
61
+ import threading
62
+ from collections.abc import Callable, Iterator
63
+ from contextlib import contextmanager
64
+ from typing import TypeVar
65
+
66
+ __all__ = [
67
+ "POLL",
68
+ "Cancel",
69
+ "CancelScope",
70
+ "Cancelled",
71
+ "as_check",
72
+ "bounded",
73
+ "cancelled",
74
+ "check",
75
+ "current",
76
+ "watching",
77
+ "within",
78
+ ]
79
+
80
+ T = TypeVar("T")
81
+
82
+ #: The longest a wait inside a scope goes without looking at it, in seconds.
83
+ #: Short enough that a cancelled worker is back well within half a second,
84
+ #: long enough that a parked waiter costs a few wake-ups a second and no more.
85
+ POLL = 0.05
86
+
87
+
88
+ class Cancelled(BaseException):
89
+ """The call was cancelled; the request it was waiting on has been abandoned.
90
+
91
+ A :class:`BaseException`, like :class:`asyncio.CancelledError` and for the
92
+ same reason: it has to travel out of the worker through every
93
+ ``except Exception`` and every client's own error base on the way - a
94
+ retry, a fall-back to another replica, a re-open after a lost server -
95
+ without any of them taking it for a failure worth recovering from. An
96
+ asyncio caller never sees it: the coroutine that was cancelled has raised
97
+ its own :class:`asyncio.CancelledError` already, and this ends the
98
+ worker's call.
99
+ """
100
+
101
+
102
+ class CancelScope:
103
+ """A flag any thread may set, telling the calls inside the scope to stop.
104
+
105
+ ``parent`` is the scope in force where this one was made: a scope nested
106
+ in a cancelled one is cancelled too, so an inner scope never shields a
107
+ call from an outer cancellation.
108
+ """
109
+
110
+ __slots__ = ("_event", "_parent")
111
+
112
+ def __init__(self, parent: CancelScope | None = None) -> None:
113
+ """A live scope; ``parent`` is normally :func:`current` where it is made."""
114
+ self._event = threading.Event()
115
+ self._parent = parent
116
+
117
+ def cancel(self) -> None:
118
+ """Cancel every call inside this scope. Idempotent, and never blocks."""
119
+ self._event.set()
120
+
121
+ @property
122
+ def cancelled(self) -> bool:
123
+ """Whether this scope, or any scope it is nested in, has been cancelled.
124
+
125
+ Walks the parent chain on every read instead of copying the parent's
126
+ state in, so cancelling an outer scope reaches inner scopes made
127
+ before it was cancelled. Lock-free: ``Event.is_set`` is thread-safe.
128
+ """
129
+ if self._event.is_set():
130
+ return True
131
+ return self._parent is not None and self._parent.cancelled
132
+
133
+ def __repr__(self) -> str:
134
+ """``CancelScope(live)`` or ``CancelScope(cancelled)``, for debugging."""
135
+ return f"CancelScope({'cancelled' if self.cancelled else 'live'})"
136
+
137
+
138
+ #: The innermost scope in force here, or ``None`` outside every one.
139
+ _scope: contextvars.ContextVar[CancelScope | None] = contextvars.ContextVar(
140
+ "xrdcore_cancel", default=None
141
+ )
142
+
143
+
144
+ def current() -> CancelScope | None:
145
+ """The scope in force in this thread or task, if any."""
146
+ return _scope.get()
147
+
148
+
149
+ def within(scope: CancelScope, call: Callable[[], T]) -> T:
150
+ """Run ``call`` with ``scope`` in force, then put the old one back."""
151
+ token = _scope.set(scope)
152
+ try:
153
+ return call()
154
+ finally:
155
+ _scope.reset(token)
156
+
157
+
158
+ def cancelled() -> bool:
159
+ """Whether the scope in force has been cancelled."""
160
+ scope = _scope.get()
161
+ return scope is not None and scope.cancelled
162
+
163
+
164
+ def check(what: str = "the request") -> None:
165
+ """Raise :class:`Cancelled` if the scope in force has been cancelled."""
166
+ if cancelled():
167
+ raise Cancelled(f"{what} was cancelled")
168
+
169
+
170
+ def bounded(timeout: float | None) -> float | None:
171
+ """``timeout``, cut to :data:`POLL` while a scope is in force.
172
+
173
+ For a wait that must notice a cancellation: inside a scope it wakes at
174
+ least that often to look, and outside one it waits as long as it was
175
+ always going to.
176
+ """
177
+ if _scope.get() is None:
178
+ return timeout
179
+ return POLL if timeout is None else min(timeout, POLL)
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Hooks: ``cancel=`` on a long transfer
184
+ # ---------------------------------------------------------------------------
185
+
186
+ #: What a ``cancel=`` argument may be: a :class:`threading.Event` - set it to
187
+ #: cancel - or a callable answering "should this stop now?", which is what
188
+ #: gfal2's and XrdCl's progress handlers' ``ShouldCancel`` is. A callable is
189
+ #: asked from a worker thread, so it must be thread-safe and quick.
190
+ Cancel = Callable[[], bool] | threading.Event
191
+
192
+
193
+ def as_check(cancel: Cancel | None) -> Callable[[], bool] | None:
194
+ """``cancel`` as a callable, or ``None`` for no hook at all."""
195
+ if cancel is None:
196
+ return None
197
+ if isinstance(cancel, threading.Event):
198
+ return cancel.is_set
199
+ return cancel
200
+
201
+
202
+ @contextmanager
203
+ def watching(
204
+ check: Callable[[], bool] | None,
205
+ on_cancel: Callable[[], None],
206
+ *,
207
+ interval: float = POLL,
208
+ name: str = "xrd-cancel",
209
+ ) -> Iterator[None]:
210
+ """While the block runs, call ``on_cancel`` once if ``check`` says to stop.
211
+
212
+ ``check`` is asked every ``interval`` seconds on a thread of its own, which
213
+ is what reaches a worker blocked between pieces - asking only between
214
+ them would leave a stalled read to its timeout. ``on_cancel`` runs on that
215
+ thread and should do no more than wake the workers (abort their handles,
216
+ set their stop flag); the watcher is gone by the time the block exits, so
217
+ it never runs after. A hook that raises counts as a cancel: a callback
218
+ that cannot answer is no reason to keep going a transfer nobody can stop.
219
+ """
220
+ if check is None:
221
+ yield
222
+ return
223
+ done = threading.Event()
224
+
225
+ def watch() -> None:
226
+ """Poll ``check`` until the block ends or it says stop, then fire once."""
227
+ while not done.wait(interval):
228
+ try:
229
+ stop = check()
230
+ except Exception:
231
+ stop = True
232
+ if stop:
233
+ on_cancel()
234
+ return
235
+
236
+ watcher = threading.Thread(target=watch, name=name, daemon=True)
237
+ watcher.start()
238
+ try:
239
+ yield
240
+ finally:
241
+ done.set()
242
+ watcher.join()
xrdcore/checksum.py ADDED
@@ -0,0 +1,195 @@
1
+ """Checksum algorithms, by the names storage servers use.
2
+
3
+ Every algorithm exposes the incremental ``hashlib``-ish shape so a copy can
4
+ digest as it streams:
5
+
6
+ >>> h = new("adler32")
7
+ >>> h.update(b"hello")
8
+ >>> h.hexdigest()
9
+ '062c0215'
10
+
11
+ Two families sit behind one surface. The CRCs and Adler-32 are "native":
12
+ a step function folds each chunk into an integer register, and the hex form
13
+ is that integer zero-padded to the algorithm's width - the spelling
14
+ ``kXR_Qcksum`` answers with and ``xrdadler32`` prints (``adler32`` of an empty
15
+ file is ``00000001``, not ``1``). The cryptographic hashes are
16
+ :mod:`hashlib`'s, wrapped to look the same. Callers never need to know which
17
+ they hold.
18
+
19
+ Names are XRootD's (``adler32``, ``crc32``, ``crc32c``, ``crc64``, ``md5``...)
20
+ because the same string goes on the wire in ``kXR_query`` checksum requests
21
+ and is compared with what servers answer; HTTP's spellings (RFC 3230 /
22
+ RFC 9530: ``sha-256``, ``adler``) are mapped onto these by
23
+ :mod:`xrdhttp.webdav`. A client with a naming policy of its own - gfal2's
24
+ narrower algorithm set, its ``UNIXcksum`` alias - layers it over :func:`new`
25
+ rather than forking the digests, so every tool computes the same function.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import zlib
32
+ from collections.abc import Callable, Iterable
33
+ from dataclasses import dataclass
34
+
35
+ from .crc32c import crc32c
36
+ from .crc64 import crc64, crc64nvme
37
+
38
+ __all__ = [
39
+ "Checksum",
40
+ "ChecksumInfo",
41
+ "Digest",
42
+ "algorithms",
43
+ "checksum_bytes",
44
+ "checksum_file",
45
+ "new",
46
+ ]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ChecksumInfo:
51
+ """A finished checksum as a client reports it: an algorithm name and its hex value.
52
+
53
+ One type for every source - a ``root://`` checksum query, a WebDAV
54
+ ``Digest`` header, a Metalink ``<hash>`` - so checksums from different
55
+ protocols compare equal when they describe the same bytes. It lives here,
56
+ below xrdhttp and the clients, because each of them reports checksums and
57
+ two look-alike classes would never compare equal to each other.
58
+ """
59
+
60
+ algorithm: str
61
+ value: str
62
+
63
+ def __str__(self) -> str:
64
+ """``algorithm:value``, the form ``xrdcp --cksum`` and gfal2 accept and print."""
65
+ return f"{self.algorithm}:{self.value}"
66
+
67
+
68
+ #: One chunk folded into a running value - the shape every algorithm here has.
69
+ Step = Callable[["bytes | bytearray | memoryview", int], int]
70
+
71
+
72
+ class Checksum:
73
+ """Incremental digest with a stable lowercase hex representation."""
74
+
75
+ __slots__ = ("_step", "_value", "_width", "name")
76
+
77
+ def __init__(self, name: str, step: Step, seed: int, width: int) -> None:
78
+ """Start a digest at ``seed`` that ``step`` advances; ``width`` is in hex digits.
79
+
80
+ The seed is the algorithm's own initial register (``1`` for
81
+ Adler-32, ``0`` for the CRCs, whose pre- and post-inversion live
82
+ inside ``step``). Use :func:`new` rather than building one by hand.
83
+ """
84
+ self.name = name
85
+ self._step = step
86
+ self._value = seed
87
+ self._width = width
88
+
89
+ def update(self, data: bytes | bytearray | memoryview) -> None:
90
+ """Fold ``data`` into the running value; chunking never changes the result."""
91
+ self._value = self._step(data, self._value)
92
+
93
+ @property
94
+ def value(self) -> int:
95
+ """The running value as an integer, as ``zlib.adler32`` would return it."""
96
+ return self._value
97
+
98
+ def hexdigest(self) -> str:
99
+ """Lower-case hex, zero-padded to the algorithm's full width."""
100
+ return f"{self._value:0{self._width}x}"
101
+
102
+ def digest(self) -> bytes:
103
+ """The value as big-endian bytes - the order the hex form is written in."""
104
+ return self._value.to_bytes(self._width // 2, "big")
105
+
106
+ def __repr__(self) -> str:
107
+ """Name and current hex value."""
108
+ return f"Checksum({self.name!r}, {self.hexdigest()})"
109
+
110
+
111
+ class _HashlibChecksum:
112
+ """Adapter giving a ``hashlib`` object the same surface as `Checksum`."""
113
+
114
+ __slots__ = ("_h", "name")
115
+
116
+ def __init__(self, name: str) -> None:
117
+ """Wrap a fresh :func:`hashlib.new` object for ``name``."""
118
+ self.name = name
119
+ self._h = hashlib.new(name)
120
+
121
+ def update(self, data: bytes | bytearray | memoryview) -> None:
122
+ """Feed ``data`` to the hash."""
123
+ self._h.update(data)
124
+
125
+ @property
126
+ def value(self) -> int:
127
+ """The digest so far as a big-endian integer, for parity with :class:`Checksum`."""
128
+ return int.from_bytes(self._h.digest(), "big")
129
+
130
+ def hexdigest(self) -> str:
131
+ """:mod:`hashlib`'s lower-case hex digest; already full width."""
132
+ return self._h.hexdigest()
133
+
134
+ def digest(self) -> bytes:
135
+ """:mod:`hashlib`'s raw digest bytes."""
136
+ return self._h.digest()
137
+
138
+ def __repr__(self) -> str:
139
+ """Name and current hex value, spelt as :class:`Checksum` spells it."""
140
+ return f"Checksum({self.name!r}, {self.hexdigest()})"
141
+
142
+
143
+ #: Either kind of digest :func:`new` returns.
144
+ Digest = Checksum | _HashlibChecksum
145
+
146
+ #: ``name -> (step, seed, width in hex digits)`` for the non-hashlib family.
147
+ _NATIVE: dict[str, tuple[Callable[..., int], int, int]] = {
148
+ "adler32": (zlib.adler32, 1, 8),
149
+ "crc32": (zlib.crc32, 0, 8),
150
+ "crc32c": (crc32c, 0, 8),
151
+ "crc64": (crc64, 0, 16),
152
+ "crc64nvme": (crc64nvme, 0, 16),
153
+ }
154
+ #: The one alias a server registers: CRC-64/XZ answers to both names.
155
+ _ALIASES = {"crc64xz": "crc64"}
156
+ #: The :mod:`hashlib` names offered: all in ``hashlib.algorithms_guaranteed``,
157
+ #: so :func:`algorithms` is the same on every platform.
158
+ _HASHLIB = ("md5", "sha1", "sha224", "sha256", "sha384", "sha512")
159
+
160
+
161
+ def algorithms() -> tuple[str, ...]:
162
+ """Every algorithm this build can compute."""
163
+ return tuple(sorted(_NATIVE)) + _HASHLIB
164
+
165
+
166
+ def new(name: str) -> Digest:
167
+ """A fresh incremental digest for ``name`` (case-insensitive).
168
+
169
+ Dashes are ignored, so HTTP's ``SHA-256`` and XRootD's ``sha256`` are
170
+ one algorithm. Raises :class:`ValueError` naming what is available for
171
+ any other name - a typo must not silently pick a different algorithm.
172
+ """
173
+ key = name.lower().replace("-", "")
174
+ key = _ALIASES.get(key, key)
175
+ if key in _NATIVE:
176
+ step, seed, width = _NATIVE[key]
177
+ return Checksum(key, step, seed, width)
178
+ if key in _HASHLIB:
179
+ return _HashlibChecksum(key)
180
+ raise ValueError(f"unknown checksum algorithm {name!r}; have {algorithms()}")
181
+
182
+
183
+ def checksum_bytes(name: str, data: bytes) -> str:
184
+ """Hex digest of ``data``."""
185
+ h = new(name)
186
+ h.update(data)
187
+ return h.hexdigest()
188
+
189
+
190
+ def checksum_file(name: str, chunks: Iterable[bytes]) -> str:
191
+ """Hex digest of a stream of chunks."""
192
+ h = new(name)
193
+ for chunk in chunks:
194
+ h.update(chunk)
195
+ return h.hexdigest()