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 +87 -0
- xrdcore/_version.py +3 -0
- xrdcore/cancel.py +242 -0
- xrdcore/checksum.py +195 -0
- xrdcore/cli.py +234 -0
- xrdcore/crc32c.py +116 -0
- xrdcore/crc64.py +95 -0
- xrdcore/errors.py +153 -0
- xrdcore/fanout.py +254 -0
- xrdcore/log.py +299 -0
- xrdcore/pipeline.py +273 -0
- xrdcore/py.typed +0 -0
- xrdcore/report.py +748 -0
- xrdcore/retry.py +178 -0
- xrdcore/safexml.py +81 -0
- xrdcore/sockets.py +47 -0
- xrdcore-0.4.0.dist-info/METADATA +95 -0
- xrdcore-0.4.0.dist-info/RECORD +21 -0
- xrdcore-0.4.0.dist-info/WHEEL +4 -0
- xrdcore-0.4.0.dist-info/licenses/COPYING +674 -0
- xrdcore-0.4.0.dist-info/licenses/LICENSE +165 -0
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
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()
|