mfup-fastapi 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_fastapi-0.2.0/.gitignore +14 -0
- mfup_fastapi-0.2.0/LICENSE +21 -0
- mfup_fastapi-0.2.0/PKG-INFO +92 -0
- mfup_fastapi-0.2.0/README.md +61 -0
- mfup_fastapi-0.2.0/mfup_fastapi/__init__.py +40 -0
- mfup_fastapi-0.2.0/mfup_fastapi/__main__.py +13 -0
- mfup_fastapi-0.2.0/mfup_fastapi/app.py +18 -0
- mfup_fastapi-0.2.0/mfup_fastapi/config.py +78 -0
- mfup_fastapi-0.2.0/mfup_fastapi/engine.py +1118 -0
- mfup_fastapi-0.2.0/mfup_fastapi/py.typed +0 -0
- mfup_fastapi-0.2.0/pyproject.toml +45 -0
|
@@ -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.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mfup-fastapi
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: FastAPI integration for the MFUP/2 upload engine: MfupEngine + APIRouter you mount into your own app, or a ready-to-run standalone server.
|
|
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: fastapi,mfup,multi-file,resumable,upload
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: fastapi>=0.115.0
|
|
23
|
+
Requires-Dist: mfup-core==0.2.0
|
|
24
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
25
|
+
Requires-Dist: websockets>=13.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# mfup-fastapi
|
|
33
|
+
|
|
34
|
+
FastAPI integration for the MFUP/2 resumable multi-file upload engine
|
|
35
|
+
([`mfup-core`](https://pypi.org/project/mfup-core/)).
|
|
36
|
+
|
|
37
|
+
## Embed into your app
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from fastapi import FastAPI
|
|
42
|
+
from mfup_fastapi import MfupConfig, MfupEngine
|
|
43
|
+
from mfup_core import AuthRequest, AuthResult
|
|
44
|
+
|
|
45
|
+
async def authorize(req: AuthRequest) -> AuthResult | None:
|
|
46
|
+
user = await my_auth(req.headers) # cookies / Authorization
|
|
47
|
+
if user is None:
|
|
48
|
+
return None # → SESSION_ABORT(auth_failed)
|
|
49
|
+
return AuthResult(
|
|
50
|
+
base_dir=f"/srv/homes/{user.id}", # per-user home
|
|
51
|
+
max_total_bytes=10 * 2**30, # 10 GiB quota
|
|
52
|
+
context={"user_id": user.id},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
engine = MfupEngine(MfupConfig(
|
|
56
|
+
base_dir=Path("/srv/uploads"),
|
|
57
|
+
redis_url="redis://localhost:6379/0",
|
|
58
|
+
authorize=authorize, # a callable — or "pkg.mod:func"
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
app = FastAPI(lifespan=engine.lifespan)
|
|
62
|
+
app.include_router(engine.router, prefix="/api/uploads")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Point the browser SDK (`@mfup/client` on npm) at the same prefix:
|
|
66
|
+
`new MfupSession({ serverUrl: "https://host/api/uploads" })`.
|
|
67
|
+
|
|
68
|
+
## Run standalone
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install mfup-fastapi
|
|
72
|
+
MFUP_BASE_DIR=/srv/uploads REDIS_URL=redis://localhost:6379/0 \
|
|
73
|
+
python -m mfup_fastapi
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
All `MfupConfig` fields map 1:1 to `MFUP_*` environment variables
|
|
77
|
+
(`MfupConfig.from_env()`); hooks are dotted paths there
|
|
78
|
+
(`MFUP_AUTHORIZE=myapp.uploads:authorize`).
|
|
79
|
+
|
|
80
|
+
## Hooks
|
|
81
|
+
|
|
82
|
+
| Hook | When | Controls |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `authorize` | HELLO, before anything is created | allow/deny, per-user `base_dir`, target mapping, byte/file quotas, `context` |
|
|
85
|
+
| `map_file` | publish, per file | final layout (by type/scope/anything) |
|
|
86
|
+
| `on_committed` | after COMMIT_OK | notification; return `"publish"` to publish server-side (scan/moderation/billing flows). Or call `engine.publish(session_id)` yourself later. |
|
|
87
|
+
|
|
88
|
+
Requires Redis (expiry index) and a POSIX filesystem. One worker per engine
|
|
89
|
+
instance; resume across workers is handled via lazy recovery from the Redis
|
|
90
|
+
index. Full contract: `docs/EXTENDING.md` in the repository.
|
|
91
|
+
|
|
92
|
+
Docs and source: <https://github.com/n0isy/mfup>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# mfup-fastapi
|
|
2
|
+
|
|
3
|
+
FastAPI integration for the MFUP/2 resumable multi-file upload engine
|
|
4
|
+
([`mfup-core`](https://pypi.org/project/mfup-core/)).
|
|
5
|
+
|
|
6
|
+
## Embed into your app
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from mfup_fastapi import MfupConfig, MfupEngine
|
|
12
|
+
from mfup_core import AuthRequest, AuthResult
|
|
13
|
+
|
|
14
|
+
async def authorize(req: AuthRequest) -> AuthResult | None:
|
|
15
|
+
user = await my_auth(req.headers) # cookies / Authorization
|
|
16
|
+
if user is None:
|
|
17
|
+
return None # → SESSION_ABORT(auth_failed)
|
|
18
|
+
return AuthResult(
|
|
19
|
+
base_dir=f"/srv/homes/{user.id}", # per-user home
|
|
20
|
+
max_total_bytes=10 * 2**30, # 10 GiB quota
|
|
21
|
+
context={"user_id": user.id},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
engine = MfupEngine(MfupConfig(
|
|
25
|
+
base_dir=Path("/srv/uploads"),
|
|
26
|
+
redis_url="redis://localhost:6379/0",
|
|
27
|
+
authorize=authorize, # a callable — or "pkg.mod:func"
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
app = FastAPI(lifespan=engine.lifespan)
|
|
31
|
+
app.include_router(engine.router, prefix="/api/uploads")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Point the browser SDK (`@mfup/client` on npm) at the same prefix:
|
|
35
|
+
`new MfupSession({ serverUrl: "https://host/api/uploads" })`.
|
|
36
|
+
|
|
37
|
+
## Run standalone
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install mfup-fastapi
|
|
41
|
+
MFUP_BASE_DIR=/srv/uploads REDIS_URL=redis://localhost:6379/0 \
|
|
42
|
+
python -m mfup_fastapi
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
All `MfupConfig` fields map 1:1 to `MFUP_*` environment variables
|
|
46
|
+
(`MfupConfig.from_env()`); hooks are dotted paths there
|
|
47
|
+
(`MFUP_AUTHORIZE=myapp.uploads:authorize`).
|
|
48
|
+
|
|
49
|
+
## Hooks
|
|
50
|
+
|
|
51
|
+
| Hook | When | Controls |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `authorize` | HELLO, before anything is created | allow/deny, per-user `base_dir`, target mapping, byte/file quotas, `context` |
|
|
54
|
+
| `map_file` | publish, per file | final layout (by type/scope/anything) |
|
|
55
|
+
| `on_committed` | after COMMIT_OK | notification; return `"publish"` to publish server-side (scan/moderation/billing flows). Or call `engine.publish(session_id)` yourself later. |
|
|
56
|
+
|
|
57
|
+
Requires Redis (expiry index) and a POSIX filesystem. One worker per engine
|
|
58
|
+
instance; resume across workers is handled via lazy recovery from the Redis
|
|
59
|
+
index. Full contract: `docs/EXTENDING.md` in the repository.
|
|
60
|
+
|
|
61
|
+
Docs and source: <https://github.com/n0isy/mfup>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""mfup-fastapi — FastAPI integration for the MFUP/2 upload engine.
|
|
2
|
+
|
|
3
|
+
from mfup_fastapi import MfupConfig, MfupEngine, create_app
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .config import MfupConfig
|
|
7
|
+
from .engine import (
|
|
8
|
+
MapFileHookError,
|
|
9
|
+
MfupEngine,
|
|
10
|
+
NotCommitted,
|
|
11
|
+
PublishError,
|
|
12
|
+
SessionNotFound,
|
|
13
|
+
TargetEscapes,
|
|
14
|
+
reconcile_orphans,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_app(config: "MfupConfig | None" = None):
|
|
19
|
+
"""Lazy re-export: builds the standalone app without importing
|
|
20
|
+
mfup_fastapi.app (which reads the environment at import time)."""
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
|
|
23
|
+
engine = MfupEngine(config or MfupConfig.from_env())
|
|
24
|
+
app = FastAPI(title="MFUP/2 Server", lifespan=engine.lifespan)
|
|
25
|
+
app.include_router(engine.router)
|
|
26
|
+
app.state.mfup_engine = engine
|
|
27
|
+
return app
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"create_app",
|
|
32
|
+
"MapFileHookError",
|
|
33
|
+
"MfupConfig",
|
|
34
|
+
"MfupEngine",
|
|
35
|
+
"NotCommitted",
|
|
36
|
+
"PublishError",
|
|
37
|
+
"reconcile_orphans",
|
|
38
|
+
"SessionNotFound",
|
|
39
|
+
"TargetEscapes",
|
|
40
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Run the standalone MFUP/2 server: python -m mfup_fastapi"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
|
|
7
|
+
if __name__ == "__main__":
|
|
8
|
+
uvicorn.run(
|
|
9
|
+
"mfup_fastapi.app:app",
|
|
10
|
+
host=os.environ.get("MFUP_HOST", "0.0.0.0"),
|
|
11
|
+
port=int(os.environ.get("MFUP_PORT", "8070")),
|
|
12
|
+
log_level="info",
|
|
13
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Standalone MFUP/2 server — config from environment.
|
|
2
|
+
|
|
3
|
+
uvicorn mfup_fastapi.app:app
|
|
4
|
+
|
|
5
|
+
This module is the ONLY place that builds an app at import time
|
|
6
|
+
(``app = create_app()`` for uvicorn's module:attr convention). Library
|
|
7
|
+
consumers use MfupEngine/MfupConfig directly and never import this module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from . import create_app
|
|
15
|
+
|
|
16
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
17
|
+
|
|
18
|
+
app = create_app()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""MfupConfig — every knob of the FastAPI integration in one dataclass.
|
|
2
|
+
|
|
3
|
+
Two construction paths:
|
|
4
|
+
- ``MfupConfig(...)`` — library embedding, explicit values,
|
|
5
|
+
hooks as CALLABLES;
|
|
6
|
+
- ``MfupConfig.from_env()`` — standalone/container deployment, values
|
|
7
|
+
from MFUP_* env vars, hooks as dotted
|
|
8
|
+
paths ("pkg.module:callable").
|
|
9
|
+
|
|
10
|
+
No environment variable is read at import time anywhere in this package —
|
|
11
|
+
only from_env() touches os.environ.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
20
|
+
|
|
21
|
+
#: A hook is either the callable itself or a dotted import path.
|
|
22
|
+
HookRef = Union[str, Callable[..., Any]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MfupConfig:
|
|
27
|
+
#: Global base directory for staging + publish (per-session base_dir from
|
|
28
|
+
#: the authorize hook overrides it per session).
|
|
29
|
+
base_dir: Path = Path("/tmp/mfup-uploads")
|
|
30
|
+
redis_url: str = "redis://redis:6379/0"
|
|
31
|
+
session_resume_ttl: int = 3600
|
|
32
|
+
leg_idle_timeout: int = 60
|
|
33
|
+
max_chunk_bytes: int = 262144
|
|
34
|
+
max_open_files: int = 1
|
|
35
|
+
max_pending_files: int = 64
|
|
36
|
+
sweep_interval: int = 300
|
|
37
|
+
staging_prefix: str = ".incoming"
|
|
38
|
+
#: Run the filesystem-orphan reconciliation every Nth sweep.
|
|
39
|
+
reconcile_every: int = 4
|
|
40
|
+
#: Minimum age before a staging dir may be reconciled as an orphan.
|
|
41
|
+
orphan_grace_seconds: int = 600
|
|
42
|
+
#: Buffered-body threshold for atomic batch POSTs.
|
|
43
|
+
max_buffered_body: int = 16 * 1024 * 1024
|
|
44
|
+
#: Cap on the JSON size of HELLO.meta.
|
|
45
|
+
max_meta_bytes: int = 16384
|
|
46
|
+
#: Bearer token for admin/debug routes. Empty → routes disabled.
|
|
47
|
+
admin_token: str = ""
|
|
48
|
+
#: Consumer hooks — callables or dotted paths (see mfup_core.hooks).
|
|
49
|
+
authorize: Optional[HookRef] = None
|
|
50
|
+
map_file: Optional[HookRef] = None
|
|
51
|
+
on_committed: Optional[HookRef] = None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "MfupConfig":
|
|
55
|
+
e: Mapping[str, str] = os.environ if env is None else env
|
|
56
|
+
|
|
57
|
+
def _int(name: str, default: int) -> int:
|
|
58
|
+
return int(e.get(name, str(default)))
|
|
59
|
+
|
|
60
|
+
return cls(
|
|
61
|
+
base_dir=Path(e.get("MFUP_BASE_DIR", "/tmp/mfup-uploads")),
|
|
62
|
+
redis_url=e.get("REDIS_URL", "redis://redis:6379/0"),
|
|
63
|
+
session_resume_ttl=_int("MFUP_SESSION_RESUME_TTL", 3600),
|
|
64
|
+
leg_idle_timeout=_int("MFUP_LEG_IDLE_TIMEOUT", 60),
|
|
65
|
+
max_chunk_bytes=_int("MFUP_MAX_CHUNK_BYTES", 262144),
|
|
66
|
+
max_open_files=_int("MFUP_MAX_OPEN_FILES", 1),
|
|
67
|
+
max_pending_files=_int("MFUP_MAX_PENDING_FILES", 64),
|
|
68
|
+
sweep_interval=_int("MFUP_SWEEP_INTERVAL", 300),
|
|
69
|
+
staging_prefix=e.get("MFUP_STAGING_PREFIX", ".incoming"),
|
|
70
|
+
reconcile_every=_int("MFUP_RECONCILE_EVERY", 4),
|
|
71
|
+
orphan_grace_seconds=_int("MFUP_ORPHAN_GRACE", 600),
|
|
72
|
+
max_buffered_body=_int("MFUP_MAX_BUFFERED_BODY", 16 * 1024 * 1024),
|
|
73
|
+
max_meta_bytes=_int("MFUP_MAX_META_BYTES", 16384),
|
|
74
|
+
admin_token=e.get("MFUP_ADMIN_TOKEN", ""),
|
|
75
|
+
authorize=e.get("MFUP_AUTHORIZE") or None,
|
|
76
|
+
map_file=e.get("MFUP_MAP_FILE") or None,
|
|
77
|
+
on_committed=e.get("MFUP_ON_COMMITTED") or None,
|
|
78
|
+
)
|