mfup-core 0.2.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.
mfup_core/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """mfup-core — the MFUP/2 upload engine, framework-free.
2
+
3
+ Protocol codec, per-session SQLite staging, the session state machine,
4
+ Redis expiry index, publish/mapping, and the consumer hook contracts.
5
+ HTTP/WebSocket wiring lives in the companion package ``mfup-fastapi``.
6
+ """
7
+
8
+ from .protocol import (
9
+ CRC32C_IMPL,
10
+ PROTOCOL_VERSION,
11
+ FrameReader,
12
+ SessionState,
13
+ )
14
+ from .hooks import (
15
+ AuthRequest,
16
+ AuthResult,
17
+ AuthorizeHook,
18
+ CommitEvent,
19
+ FileMapRequest,
20
+ MapFileHook,
21
+ OnCommittedHook,
22
+ load_authorize_hook,
23
+ load_hook,
24
+ load_map_file_hook,
25
+ load_on_committed_hook,
26
+ resolve_hook,
27
+ )
28
+ from .session_manager import LiveSession, SessionRegistry
29
+ from .redis_index import SessionIndex
30
+ from .publish import (
31
+ ConflictError,
32
+ MappingError,
33
+ list_payload_files,
34
+ publish_session,
35
+ publish_session_mapped,
36
+ )
37
+ from .storage import staging_dir, validate_node_name
38
+
39
+ __all__ = [
40
+ "AuthRequest",
41
+ "AuthResult",
42
+ "AuthorizeHook",
43
+ "CommitEvent",
44
+ "ConflictError",
45
+ "CRC32C_IMPL",
46
+ "FileMapRequest",
47
+ "FrameReader",
48
+ "list_payload_files",
49
+ "LiveSession",
50
+ "load_authorize_hook",
51
+ "load_hook",
52
+ "load_map_file_hook",
53
+ "load_on_committed_hook",
54
+ "MapFileHook",
55
+ "MappingError",
56
+ "OnCommittedHook",
57
+ "PROTOCOL_VERSION",
58
+ "publish_session",
59
+ "publish_session_mapped",
60
+ "resolve_hook",
61
+ "SessionIndex",
62
+ "SessionRegistry",
63
+ "SessionState",
64
+ "staging_dir",
65
+ "validate_node_name",
66
+ ]
mfup_core/hooks.py ADDED
@@ -0,0 +1,203 @@
1
+ """MFUP/2 extension hooks — config-driven, no library packaging required.
2
+
3
+ A consumer plugs their code in via environment variables holding dotted
4
+ paths, e.g.:
5
+
6
+ MFUP_AUTHORIZE=myapp.uploads:authorize
7
+
8
+ The referenced module just has to be importable (on PYTHONPATH / mounted
9
+ into the container). The full contract lives in docs/EXTENDING.md.
10
+
11
+ Authorize contract
12
+ ------------------
13
+
14
+ async def authorize(req: AuthRequest) -> AuthResult | None:
15
+ ...
16
+
17
+ Called once per HELLO, before the session is created. Return:
18
+ - ``AuthResult(...)`` — allow, optionally constraining the session;
19
+ - ``None`` — deny → the client gets SESSION_ABORT(auth_failed).
20
+
21
+ Raising is treated as a deny (logged server-side, generic reason sent).
22
+
23
+ If ``MFUP_AUTHORIZE`` is unset the server runs **allow-all** and logs a
24
+ warning at startup — acceptable for development, not for production.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ import logging
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Awaitable, Callable, Mapping, Optional
33
+
34
+ logger = logging.getLogger("mfup.hooks")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class AuthRequest:
39
+ """Everything the server knows about an upload attempt at HELLO time."""
40
+
41
+ session_id: str
42
+ target_dir: str
43
+ #: HTTP headers of the WebSocket handshake (cookies, Authorization, …).
44
+ headers: Mapping[str, str]
45
+ #: Client address as reported by the ASGI server ("ip:port" or "").
46
+ client: str
47
+ #: Query parameters of the WebSocket URL.
48
+ query: Mapping[str, str]
49
+ #: Arbitrary JSON the CONSUMER'S FRONTEND attached to the session
50
+ #: (MfupSessionConfig.meta → HELLO.meta). Untrusted client input — the
51
+ #: hook validates it. Typical use: upload scope/purpose ("avatars",
52
+ #: {"album_id": 123}) that authorization and per-file mapping key on.
53
+ meta: Any = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AuthResult:
58
+ """Permission plus per-session constraints. All limits optional."""
59
+
60
+ #: Cap on total accepted payload bytes; exceeding aborts the session
61
+ #: with SESSION_ABORT(quota_exceeded). None = unlimited.
62
+ max_total_bytes: Optional[int] = None
63
+ #: Cap on the number of file nodes; exceeding aborts likewise.
64
+ max_files: Optional[int] = None
65
+ #: Per-session BASE directory (absolute path) — e.g. the user's home.
66
+ #: Overrides MFUP_BASE_DIR for this session: the staging dir is created
67
+ #: inside it (publish stays a same-filesystem rename even when homes live
68
+ #: on their own mount), relative target_dir resolves against it, and the
69
+ #: containment check confines the session to it. Created if missing.
70
+ #: None = the global MFUP_BASE_DIR.
71
+ base_dir: Optional[str] = None
72
+ #: Optional override of the client-requested target_dir (e.g. force
73
+ #: uploads into a fixed subdirectory, or a MAPPING of the client's
74
+ #: request — the hook receives req.target_dir and may prefix/rewrite it:
75
+ #: target_dir=f"incoming/{req.target_dir}"
76
+ #: Escapes are impossible regardless: the resolved target must stay
77
+ #: within the session's base_dir or HELLO is refused (bad_target_dir).
78
+ target_dir: Optional[str] = None
79
+ #: Free-form bag the consumer may use to correlate sessions with users;
80
+ #: stored in memory on the LiveSession, never persisted or sent to the
81
+ #: client.
82
+ context: dict[str, Any] = field(default_factory=dict)
83
+
84
+
85
+ AuthorizeHook = Callable[[AuthRequest], Awaitable[Optional[AuthResult]]]
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class FileMapRequest:
90
+ """One file about to be published — input to the map_file hook."""
91
+
92
+ session_id: str
93
+ #: Path of the file inside the uploaded tree, "/"-separated, as the
94
+ #: client sent it (e.g. "photos/2024/img_001.jpg").
95
+ path: str
96
+ #: Basename convenience (last segment of `path`).
97
+ name: str
98
+ #: Actual size on disk, bytes.
99
+ size: int
100
+ #: The session's target_dir (already authorized/mapped at HELLO).
101
+ target_dir: str
102
+ #: Client-attached session meta (see AuthRequest.meta).
103
+ meta: Any
104
+ #: AuthResult.context from the authorize hook.
105
+ context: Mapping[str, Any]
106
+
107
+
108
+ #: async (FileMapRequest) -> str | None
109
+ #: str — new path RELATIVE to target_dir (e.g. "media/img_001.jpg");
110
+ #: None — keep the client's layout for this file.
111
+ #: Two files mapping to the same destination is a consumer bug → publish
112
+ #: fails with mapping_conflict. Escaping segments ("..", absolute, "\\")
113
+ #: fail publish likewise. The hook runs once per file at PUBLISH time, so
114
+ #: it does not need to be deterministic across retries of the transfer.
115
+ MapFileHook = Callable[[FileMapRequest], Awaitable[Optional[str]]]
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CommitEvent:
120
+ """A session just committed — input to the on_committed hook."""
121
+
122
+ session_id: str
123
+ #: The session's (authorized/mapped) target_dir.
124
+ target_dir: str
125
+ #: The session's base directory (per-user home or the global base).
126
+ base_dir: str
127
+ #: Absolute staging directory holding the committed payload.
128
+ staging_dir: str
129
+ #: Committed file count / total payload bytes (as sent in COMMIT_OK).
130
+ files: int
131
+ bytes: int
132
+ #: Client-attached session meta (see AuthRequest.meta). Untrusted.
133
+ meta: Any
134
+ #: AuthResult.context from the authorize hook.
135
+ context: Mapping[str, Any]
136
+
137
+
138
+ #: async (CommitEvent) -> str | None
139
+ #: "publish" — the server publishes immediately (server-side decision:
140
+ #: scan passed, billing ok, …). The browser's own publish call,
141
+ #: if any, will find the session gone (404) — harmless.
142
+ #: None — do nothing; publish stays client-driven (or the consumer
143
+ #: backend calls MfupEngine.publish() later).
144
+ #: Raising is logged and treated as None — a broken consumer hook must not
145
+ #: strand committed sessions.
146
+ OnCommittedHook = Callable[[CommitEvent], Awaitable[Optional[str]]]
147
+
148
+
149
+ def load_hook(dotted: str) -> Callable[..., Any]:
150
+ """Import ``pkg.module:attr`` and return the attribute.
151
+
152
+ Raises ImportError/AttributeError loudly — a misconfigured hook must
153
+ fail at startup, not silently run allow-all.
154
+ """
155
+ module_path, sep, attr = dotted.partition(":")
156
+ if not sep or not module_path or not attr:
157
+ raise ImportError(
158
+ f"invalid hook path {dotted!r}: expected 'package.module:callable'"
159
+ )
160
+ module = importlib.import_module(module_path)
161
+ return getattr(module, attr)
162
+
163
+
164
+ def load_authorize_hook(dotted: str | None) -> AuthorizeHook | None:
165
+ """Resolve MFUP_AUTHORIZE. None (unset) → allow-all with a warning."""
166
+ if not dotted:
167
+ logger.warning(
168
+ "MFUP_AUTHORIZE is not set — running WITHOUT authorization "
169
+ "(allow-all). Do not do this in production."
170
+ )
171
+ return None
172
+ hook = load_hook(dotted)
173
+ logger.info("Authorize hook loaded: %s", dotted)
174
+ return hook # type: ignore[return-value]
175
+
176
+
177
+ def load_map_file_hook(dotted: str | None) -> MapFileHook | None:
178
+ """Resolve MFUP_MAP_FILE. None (unset) → identity layout."""
179
+ if not dotted:
180
+ return None
181
+ hook = load_hook(dotted)
182
+ logger.info("map_file hook loaded: %s", dotted)
183
+ return hook # type: ignore[return-value]
184
+
185
+
186
+ def load_on_committed_hook(dotted: str | None) -> OnCommittedHook | None:
187
+ """Resolve MFUP_ON_COMMITTED. None (unset) → client-driven publish."""
188
+ if not dotted:
189
+ return None
190
+ hook = load_hook(dotted)
191
+ logger.info("on_committed hook loaded: %s", dotted)
192
+ return hook # type: ignore[return-value]
193
+
194
+
195
+ def resolve_hook(ref: Callable[..., Any] | str | None) -> Callable[..., Any] | None:
196
+ """Accept a hook given either as the callable itself (library embedding:
197
+ ``MfupConfig(authorize=my_func)``) or as a dotted path (env-driven:
198
+ ``"pkg.module:callable"``). None passes through."""
199
+ if ref is None:
200
+ return None
201
+ if callable(ref):
202
+ return ref
203
+ return load_hook(ref)
mfup_core/protocol.py ADDED
@@ -0,0 +1,347 @@
1
+ """MFUP/2 protocol types and binary frame decoder (server-side)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import struct
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+
11
+ PROTOCOL_VERSION = "MFUP/2"
12
+ ROOT_NODE_ID = 0
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Frame tags
17
+ # ---------------------------------------------------------------------------
18
+ class FrameTag(enum.IntEnum):
19
+ NODE = 0x01
20
+ SUMMARY = 0x02
21
+ FILE_OPEN = 0x03
22
+ FILE_CHUNK = 0x04
23
+ FILE_CLOSE = 0x05
24
+ DIR_CLOSE = 0x06
25
+ SESSION_END = 0x07
26
+ CLIENT_ABORT = 0x08
27
+
28
+
29
+ class NodeKind(enum.IntEnum):
30
+ DIR = 0x00
31
+ FILE = 0x01
32
+
33
+
34
+ class ChecksumKind(enum.IntEnum):
35
+ CRC32C = 0x01
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Decoded frame dataclasses
40
+ # ---------------------------------------------------------------------------
41
+ @dataclass(slots=True)
42
+ class NodeFrame:
43
+ tag: FrameTag = FrameTag.NODE
44
+ node_id: int = 0
45
+ parent_id: int = 0
46
+ kind: NodeKind = NodeKind.FILE
47
+ name: str = ""
48
+ size_hint: Optional[int] = None
49
+ mtime_ms: Optional[int] = None
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class SummaryFrame:
54
+ tag: FrameTag = FrameTag.SUMMARY
55
+ node_id: int = 0
56
+ scan_done_units: int = 0
57
+ scan_est_units: int = 0
58
+ body_done_bytes: int = 0
59
+ body_est_bytes: int = 0
60
+ sealed: bool = False
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class FileOpenFrame:
65
+ tag: FrameTag = FrameTag.FILE_OPEN
66
+ node_id: int = 0
67
+ size: int = 0
68
+ mtime_ms: Optional[int] = None
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class FileChunkFrame:
73
+ tag: FrameTag = FrameTag.FILE_CHUNK
74
+ node_id: int = 0
75
+ offset: int = 0
76
+ length: int = 0
77
+ checksum_kind: ChecksumKind = ChecksumKind.CRC32C
78
+ checksum: int = 0
79
+ payload: bytes = b""
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class FileCloseFrame:
84
+ tag: FrameTag = FrameTag.FILE_CLOSE
85
+ node_id: int = 0
86
+ size_sent: int = 0
87
+
88
+
89
+ @dataclass(slots=True)
90
+ class DirCloseFrame:
91
+ tag: FrameTag = FrameTag.DIR_CLOSE
92
+ node_id: int = 0
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class SessionEndFrame:
97
+ tag: FrameTag = FrameTag.SESSION_END
98
+ scan_done_units: int = 0
99
+ scan_est_units: int = 0
100
+ body_done_bytes: int = 0
101
+ body_est_bytes: int = 0
102
+ sealed: bool = True
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class ClientAbortFrame:
107
+ tag: FrameTag = FrameTag.CLIENT_ABORT
108
+ code: str = ""
109
+ reason: str = ""
110
+
111
+
112
+ Frame = (
113
+ NodeFrame
114
+ | SummaryFrame
115
+ | FileOpenFrame
116
+ | FileChunkFrame
117
+ | FileCloseFrame
118
+ | DirCloseFrame
119
+ | SessionEndFrame
120
+ | ClientAbortFrame
121
+ )
122
+
123
+
124
+ # Server → Client control message: {"t": "PROBE_ACK"}
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Session states
129
+ # ---------------------------------------------------------------------------
130
+ class SessionState(str, enum.Enum):
131
+ ACTIVE = "active"
132
+ PAUSED_BY_SERVER = "paused_by_server"
133
+ WAITING_RESUME = "waiting_resume"
134
+ COMMITTING = "committing"
135
+ COMMITTED = "committed"
136
+ ABORTED = "aborted"
137
+ EXPIRED = "expired"
138
+ FAILED = "failed"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Node statuses (in DB)
143
+ # ---------------------------------------------------------------------------
144
+ class NodeStatus(str, enum.Enum):
145
+ OPEN = "open"
146
+ CLOSED = "closed"
147
+ REJECTED = "rejected"
148
+ PRUNED = "pruned"
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Binary frame decoder
153
+ #
154
+ # Wire format: [4-byte big-endian length][1-byte tag][payload]
155
+ # The length covers tag + payload.
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _read_u8(data: memoryview, off: int) -> tuple[int, int]:
159
+ return data[off], off + 1
160
+
161
+
162
+ def _read_u16(data: memoryview, off: int) -> tuple[int, int]:
163
+ return struct.unpack_from("!H", data, off)[0], off + 2
164
+
165
+
166
+ def _read_u32(data: memoryview, off: int) -> tuple[int, int]:
167
+ return struct.unpack_from("!I", data, off)[0], off + 4
168
+
169
+
170
+ def _read_u64(data: memoryview, off: int) -> tuple[int, int]:
171
+ return struct.unpack_from("!Q", data, off)[0], off + 8
172
+
173
+
174
+ def _read_string(data: memoryview, off: int) -> tuple[str, int]:
175
+ slen, off = _read_u16(data, off)
176
+ s = bytes(data[off : off + slen]).decode("utf-8")
177
+ return s, off + slen
178
+
179
+
180
+ def decode_frame_payload(tag: int, payload: memoryview) -> Frame:
181
+ """Decode the payload bytes (after the tag byte) into a Frame dataclass."""
182
+ off = 0
183
+
184
+ if tag == FrameTag.NODE:
185
+ node_id, off = _read_u32(payload, off)
186
+ parent_id, off = _read_u32(payload, off)
187
+ kind_val, off = _read_u8(payload, off)
188
+ name, off = _read_string(payload, off)
189
+ has_size, off = _read_u8(payload, off)
190
+ size_hint = None
191
+ if has_size:
192
+ size_hint, off = _read_u64(payload, off)
193
+ has_mtime, off = _read_u8(payload, off)
194
+ mtime_ms = None
195
+ if has_mtime:
196
+ mtime_ms, off = _read_u64(payload, off)
197
+ return NodeFrame(
198
+ node_id=node_id,
199
+ parent_id=parent_id,
200
+ kind=NodeKind(kind_val),
201
+ name=name,
202
+ size_hint=size_hint,
203
+ mtime_ms=mtime_ms,
204
+ )
205
+
206
+ elif tag == FrameTag.SUMMARY:
207
+ node_id, off = _read_u32(payload, off)
208
+ scan_done, off = _read_u64(payload, off)
209
+ scan_est, off = _read_u64(payload, off)
210
+ body_done, off = _read_u64(payload, off)
211
+ body_est, off = _read_u64(payload, off)
212
+ sealed_val, off = _read_u8(payload, off)
213
+ return SummaryFrame(
214
+ node_id=node_id,
215
+ scan_done_units=scan_done,
216
+ scan_est_units=scan_est,
217
+ body_done_bytes=body_done,
218
+ body_est_bytes=body_est,
219
+ sealed=bool(sealed_val),
220
+ )
221
+
222
+ elif tag == FrameTag.FILE_OPEN:
223
+ node_id, off = _read_u32(payload, off)
224
+ size, off = _read_u64(payload, off)
225
+ has_mtime, off = _read_u8(payload, off)
226
+ mtime_ms = None
227
+ if has_mtime:
228
+ mtime_ms, off = _read_u64(payload, off)
229
+ return FileOpenFrame(node_id=node_id, size=size, mtime_ms=mtime_ms)
230
+
231
+ elif tag == FrameTag.FILE_CHUNK:
232
+ node_id, off = _read_u32(payload, off)
233
+ offset_val, off = _read_u64(payload, off)
234
+ length, off = _read_u32(payload, off)
235
+ ck_kind, off = _read_u8(payload, off)
236
+ checksum, off = _read_u32(payload, off)
237
+ data = bytes(payload[off : off + length])
238
+ return FileChunkFrame(
239
+ node_id=node_id,
240
+ offset=offset_val,
241
+ length=length,
242
+ checksum_kind=ChecksumKind(ck_kind),
243
+ checksum=checksum,
244
+ payload=data,
245
+ )
246
+
247
+ elif tag == FrameTag.FILE_CLOSE:
248
+ node_id, off = _read_u32(payload, off)
249
+ size_sent, off = _read_u64(payload, off)
250
+ return FileCloseFrame(node_id=node_id, size_sent=size_sent)
251
+
252
+ elif tag == FrameTag.DIR_CLOSE:
253
+ node_id, off = _read_u32(payload, off)
254
+ return DirCloseFrame(node_id=node_id)
255
+
256
+ elif tag == FrameTag.SESSION_END:
257
+ scan_done, off = _read_u64(payload, off)
258
+ scan_est, off = _read_u64(payload, off)
259
+ body_done, off = _read_u64(payload, off)
260
+ body_est, off = _read_u64(payload, off)
261
+ sealed_val, off = _read_u8(payload, off)
262
+ return SessionEndFrame(
263
+ scan_done_units=scan_done,
264
+ scan_est_units=scan_est,
265
+ body_done_bytes=body_done,
266
+ body_est_bytes=body_est,
267
+ sealed=bool(sealed_val),
268
+ )
269
+
270
+ elif tag == FrameTag.CLIENT_ABORT:
271
+ code, off = _read_string(payload, off)
272
+ reason, off = _read_string(payload, off)
273
+ return ClientAbortFrame(code=code, reason=reason)
274
+
275
+ else:
276
+ raise ValueError(f"unknown frame tag: {tag:#04x}")
277
+
278
+
279
+ class FrameReader:
280
+ """Incremental frame reader that buffers partial data from a streaming body.
281
+
282
+ Feed chunks via `feed(data)` and iterate decoded frames via `drain()`.
283
+
284
+ `max_frame_len` bounds the declared frame length: without it a corrupt or
285
+ malicious 4-byte prefix claiming a multi-gigabyte frame would grow the
286
+ buffer without limit while the reader waits for it to "complete".
287
+ """
288
+
289
+ # 1 MiB default: comfortably above MAX_CHUNK_BYTES (256 KiB) + headers
290
+ # and any NODE frame with a long UTF-8 name.
291
+ DEFAULT_MAX_FRAME_LEN = 1024 * 1024
292
+
293
+ def __init__(self, max_frame_len: int = DEFAULT_MAX_FRAME_LEN) -> None:
294
+ self._buf = bytearray()
295
+ self._max_frame_len = max_frame_len
296
+
297
+ def feed(self, data: bytes) -> None:
298
+ self._buf.extend(data)
299
+
300
+ def drain(self) -> list[Frame]:
301
+ frames: list[Frame] = []
302
+ pos = 0
303
+ total = len(self._buf)
304
+
305
+ while pos + 4 <= total:
306
+ frame_len = struct.unpack_from("!I", self._buf, pos)[0]
307
+ if frame_len > self._max_frame_len:
308
+ raise ValueError(
309
+ f"frame length {frame_len} exceeds limit {self._max_frame_len}"
310
+ )
311
+ if pos + 4 + frame_len > total:
312
+ break # incomplete frame
313
+ tag = self._buf[pos + 4]
314
+ # Copy payload to a bytes object so no memoryview holds the buffer
315
+ payload_bytes = bytes(self._buf[pos + 5 : pos + 4 + frame_len])
316
+ frames.append(decode_frame_payload(tag, memoryview(payload_bytes)))
317
+ pos += 4 + frame_len
318
+
319
+ if pos > 0:
320
+ del self._buf[:pos]
321
+
322
+ return frames
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # CRC-32C (Castagnoli) for checksum verification
327
+ #
328
+ # The C implementation from the `crc32c` package (SSE4.2/ARMv8 accelerated,
329
+ # ~GB/s) is a HARD dependency. A pure-Python fallback used to exist and was
330
+ # removed deliberately: it measured ~6 MB/s and blocked the event loop
331
+ # ~10 ms per 64 KiB chunk, silently throttling the whole server. Failing
332
+ # loudly at import beats degrading quietly in production.
333
+ # ---------------------------------------------------------------------------
334
+
335
+ try:
336
+ import crc32c as _crc32c_native # type: ignore
337
+ except ImportError as exc: # pragma: no cover
338
+ raise ImportError(
339
+ "MFUP/2 requires the C-accelerated 'crc32c' package: pip install crc32c"
340
+ ) from exc
341
+
342
+
343
+ def crc32c(data: bytes | memoryview, initial: int = 0) -> int:
344
+ return _crc32c_native.crc32c(data, initial)
345
+
346
+
347
+ CRC32C_IMPL = "native"