mfup-core 0.2.0__tar.gz
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-0.2.0/.gitignore +14 -0
- mfup_core-0.2.0/LICENSE +21 -0
- mfup_core-0.2.0/PKG-INFO +54 -0
- mfup_core-0.2.0/README.md +30 -0
- mfup_core-0.2.0/mfup_core/__init__.py +66 -0
- mfup_core-0.2.0/mfup_core/hooks.py +203 -0
- mfup_core-0.2.0/mfup_core/protocol.py +347 -0
- mfup_core-0.2.0/mfup_core/publish.py +219 -0
- mfup_core-0.2.0/mfup_core/py.typed +0 -0
- mfup_core-0.2.0/mfup_core/redis_index.py +97 -0
- mfup_core-0.2.0/mfup_core/session_manager.py +974 -0
- mfup_core-0.2.0/mfup_core/storage.py +435 -0
- mfup_core-0.2.0/pyproject.toml +36 -0
mfup_core-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 n0isy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mfup_core-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mfup-core
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MFUP/2 resumable multi-file upload engine: protocol codec, session state machine, SQLite staging, Redis expiry index, publish/mapping. Framework-free core.
|
|
5
|
+
Project-URL: Homepage, https://github.com/n0isy/mfup
|
|
6
|
+
Project-URL: Repository, https://github.com/n0isy/mfup
|
|
7
|
+
Project-URL: Issues, https://github.com/n0isy/mfup/issues
|
|
8
|
+
Author: n0isy
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: mfup,multi-file,protocol,resumable,upload
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: crc32c>=2.7
|
|
22
|
+
Requires-Dist: redis>=5.0.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mfup-core
|
|
26
|
+
|
|
27
|
+
The MFUP/2 resumable multi-file upload **engine** — framework-free.
|
|
28
|
+
|
|
29
|
+
MFUP/2 moves whole directory trees (think `node_modules`-scale: tens of
|
|
30
|
+
thousands of small files) from a browser to a server over one WebSocket
|
|
31
|
+
control channel (JSON) plus HTTP data legs (binary frames), with:
|
|
32
|
+
|
|
33
|
+
- **resume** across page reloads, network drops and server restarts
|
|
34
|
+
(per-session SQLite journal in a staging directory, epoch/leg fencing);
|
|
35
|
+
- **interactive transfers** — the server can ASK the user mid-flight
|
|
36
|
+
(overwrite? cancel?) without stopping the stream;
|
|
37
|
+
- **integrity** — CRC-32C per chunk (C-accelerated, hard dependency),
|
|
38
|
+
commit invariants that catch lost metadata;
|
|
39
|
+
- **atomic publish** — staged files move into the target directory with
|
|
40
|
+
`rename()`, optionally re-laid-out per file by a consumer hook;
|
|
41
|
+
- **retention** — Redis expiry index plus a filesystem reconciliation
|
|
42
|
+
safety net; failed/cancelled sessions leave no garbage.
|
|
43
|
+
|
|
44
|
+
This package contains the protocol codec, the session state machine,
|
|
45
|
+
storage/publish, the Redis index, and the consumer hook contracts
|
|
46
|
+
(`AuthRequest/AuthResult`, `FileMapRequest`, `CommitEvent`). It does **not**
|
|
47
|
+
speak HTTP: pair it with [`mfup-fastapi`](https://pypi.org/project/mfup-fastapi/)
|
|
48
|
+
(or write your own transport shell against these primitives).
|
|
49
|
+
|
|
50
|
+
The browser side lives on npm: `@mfup/client` (TypeScript SDK) and
|
|
51
|
+
`@mfup/react` (hooks).
|
|
52
|
+
|
|
53
|
+
Docs and source: <https://github.com/n0isy/mfup> — see `docs/EXTENDING.md`
|
|
54
|
+
for the integration contract and `docs/FULL.md` for the protocol.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# mfup-core
|
|
2
|
+
|
|
3
|
+
The MFUP/2 resumable multi-file upload **engine** — framework-free.
|
|
4
|
+
|
|
5
|
+
MFUP/2 moves whole directory trees (think `node_modules`-scale: tens of
|
|
6
|
+
thousands of small files) from a browser to a server over one WebSocket
|
|
7
|
+
control channel (JSON) plus HTTP data legs (binary frames), with:
|
|
8
|
+
|
|
9
|
+
- **resume** across page reloads, network drops and server restarts
|
|
10
|
+
(per-session SQLite journal in a staging directory, epoch/leg fencing);
|
|
11
|
+
- **interactive transfers** — the server can ASK the user mid-flight
|
|
12
|
+
(overwrite? cancel?) without stopping the stream;
|
|
13
|
+
- **integrity** — CRC-32C per chunk (C-accelerated, hard dependency),
|
|
14
|
+
commit invariants that catch lost metadata;
|
|
15
|
+
- **atomic publish** — staged files move into the target directory with
|
|
16
|
+
`rename()`, optionally re-laid-out per file by a consumer hook;
|
|
17
|
+
- **retention** — Redis expiry index plus a filesystem reconciliation
|
|
18
|
+
safety net; failed/cancelled sessions leave no garbage.
|
|
19
|
+
|
|
20
|
+
This package contains the protocol codec, the session state machine,
|
|
21
|
+
storage/publish, the Redis index, and the consumer hook contracts
|
|
22
|
+
(`AuthRequest/AuthResult`, `FileMapRequest`, `CommitEvent`). It does **not**
|
|
23
|
+
speak HTTP: pair it with [`mfup-fastapi`](https://pypi.org/project/mfup-fastapi/)
|
|
24
|
+
(or write your own transport shell against these primitives).
|
|
25
|
+
|
|
26
|
+
The browser side lives on npm: `@mfup/client` (TypeScript SDK) and
|
|
27
|
+
`@mfup/react` (hooks).
|
|
28
|
+
|
|
29
|
+
Docs and source: <https://github.com/n0isy/mfup> — see `docs/EXTENDING.md`
|
|
30
|
+
for the integration contract and `docs/FULL.md` for the protocol.
|
|
@@ -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
|
+
]
|
|
@@ -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)
|