mfup-fastapi 0.2.0__tar.gz → 3.1.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-3.1.0/PKG-INFO +62 -0
- mfup_fastapi-3.1.0/README.md +40 -0
- mfup_fastapi-3.1.0/README_ru.md +27 -0
- mfup_fastapi-3.1.0/mfup_fastapi/__init__.py +4 -0
- mfup_fastapi-3.1.0/mfup_fastapi/__main__.py +18 -0
- mfup_fastapi-3.1.0/mfup_fastapi/app.py +341 -0
- mfup_fastapi-3.1.0/mfup_fastapi/config.py +85 -0
- mfup_fastapi-3.1.0/pyproject.toml +32 -0
- mfup_fastapi-0.2.0/.gitignore +0 -14
- mfup_fastapi-0.2.0/PKG-INFO +0 -92
- mfup_fastapi-0.2.0/README.md +0 -61
- mfup_fastapi-0.2.0/mfup_fastapi/__init__.py +0 -40
- mfup_fastapi-0.2.0/mfup_fastapi/__main__.py +0 -13
- mfup_fastapi-0.2.0/mfup_fastapi/app.py +0 -18
- mfup_fastapi-0.2.0/mfup_fastapi/config.py +0 -78
- mfup_fastapi-0.2.0/mfup_fastapi/engine.py +0 -1118
- mfup_fastapi-0.2.0/pyproject.toml +0 -45
- {mfup_fastapi-0.2.0 → mfup_fastapi-3.1.0}/LICENSE +0 -0
- {mfup_fastapi-0.2.0 → mfup_fastapi-3.1.0}/mfup_fastapi/py.typed +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mfup-fastapi
|
|
3
|
+
Version: 3.1.0
|
|
4
|
+
Summary: MFUP/3 FastAPI adapter with streaming multipart and WebSocket questions
|
|
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
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: anyio>=4
|
|
13
|
+
Requires-Dist: fastapi>=0.115
|
|
14
|
+
Requires-Dist: mfup-core==3.1.0
|
|
15
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# mfup-fastapi
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from mfup_fastapi import MfupConfig, create_app
|
|
27
|
+
|
|
28
|
+
app = create_app(
|
|
29
|
+
MfupConfig(
|
|
30
|
+
base_dir="./state",
|
|
31
|
+
authorize=my_authorize,
|
|
32
|
+
prefix="/api",
|
|
33
|
+
client_publish=False,
|
|
34
|
+
on_committed=my_processor,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The application supplies identity, scopes and authorize. It may return an
|
|
40
|
+
absolute per-session baseDir, targetDir, quotas and context. The core remains
|
|
41
|
+
independent of application scope names. Ordinary multipart carries files;
|
|
42
|
+
WebSocket carries questions and snapshots.
|
|
43
|
+
|
|
44
|
+
For embedding, `MfupEngine(config).router` and `.lifespan` (or startup/shutdown)
|
|
45
|
+
manage routing and cleanup. Use one lifecycle per Engine and one process per
|
|
46
|
+
metadata directory. `create_app` applies config.prefix and owns the lifecycle.
|
|
47
|
+
|
|
48
|
+
map_file runs during manifest reception, before body reads and returns a relative path or None.
|
|
49
|
+
on_committed receives CommitEvent and can return a boolean to override server
|
|
50
|
+
auto_publish (default False). client_publish=False reserves publication for
|
|
51
|
+
the backend. Engine exposes get_session, list_staged, open_staged,
|
|
52
|
+
prepare_publish, publish and retry_committed. Callback failure preserves the
|
|
53
|
+
accepted files and returns a successful commit with processing='failed'.
|
|
54
|
+
|
|
55
|
+
Standalone: `MFUP_AUTHORIZE=myapp.hooks:authorize python -m mfup_fastapi`.
|
|
56
|
+
MfupConfig.from_env supports the same MFUP_* configuration as the Node package;
|
|
57
|
+
Python hooks can be callables or module:attribute strings.
|
|
58
|
+
See docs/EXTENDING.md and the multiuser-scopes example in the repository.
|
|
59
|
+
|
|
60
|
+
[Extension API](https://github.com/n0isy/mfup/blob/main/docs/EXTENDING.md) · [HTTP protocol](https://github.com/n0isy/mfup/blob/main/docs/PROTOCOL.md)
|
|
61
|
+
|
|
62
|
+
[Russian](README_ru.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# mfup-fastapi
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from mfup_fastapi import MfupConfig, create_app
|
|
5
|
+
|
|
6
|
+
app = create_app(
|
|
7
|
+
MfupConfig(
|
|
8
|
+
base_dir="./state",
|
|
9
|
+
authorize=my_authorize,
|
|
10
|
+
prefix="/api",
|
|
11
|
+
client_publish=False,
|
|
12
|
+
on_committed=my_processor,
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The application supplies identity, scopes and authorize. It may return an
|
|
18
|
+
absolute per-session baseDir, targetDir, quotas and context. The core remains
|
|
19
|
+
independent of application scope names. Ordinary multipart carries files;
|
|
20
|
+
WebSocket carries questions and snapshots.
|
|
21
|
+
|
|
22
|
+
For embedding, `MfupEngine(config).router` and `.lifespan` (or startup/shutdown)
|
|
23
|
+
manage routing and cleanup. Use one lifecycle per Engine and one process per
|
|
24
|
+
metadata directory. `create_app` applies config.prefix and owns the lifecycle.
|
|
25
|
+
|
|
26
|
+
map_file runs during manifest reception, before body reads and returns a relative path or None.
|
|
27
|
+
on_committed receives CommitEvent and can return a boolean to override server
|
|
28
|
+
auto_publish (default False). client_publish=False reserves publication for
|
|
29
|
+
the backend. Engine exposes get_session, list_staged, open_staged,
|
|
30
|
+
prepare_publish, publish and retry_committed. Callback failure preserves the
|
|
31
|
+
accepted files and returns a successful commit with processing='failed'.
|
|
32
|
+
|
|
33
|
+
Standalone: `MFUP_AUTHORIZE=myapp.hooks:authorize python -m mfup_fastapi`.
|
|
34
|
+
MfupConfig.from_env supports the same MFUP_* configuration as the Node package;
|
|
35
|
+
Python hooks can be callables or module:attribute strings.
|
|
36
|
+
See docs/EXTENDING.md and the multiuser-scopes example in the repository.
|
|
37
|
+
|
|
38
|
+
[Extension API](https://github.com/n0isy/mfup/blob/main/docs/EXTENDING.md) · [HTTP protocol](https://github.com/n0isy/mfup/blob/main/docs/PROTOCOL.md)
|
|
39
|
+
|
|
40
|
+
[Russian](README_ru.md)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# mfup-fastapi
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from mfup_fastapi import MfupConfig, create_app
|
|
5
|
+
|
|
6
|
+
app = create_app(
|
|
7
|
+
MfupConfig(
|
|
8
|
+
base_dir="./state",
|
|
9
|
+
authorize=my_authorize,
|
|
10
|
+
prefix="/api",
|
|
11
|
+
client_publish=False,
|
|
12
|
+
on_committed=my_processor,
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Приложение предоставляет my_authorize/my_processor, личность и правила доступа. Авторизация может вернуть абсолютный корень сессии, targetDir, квоты и context. Прикладные значения вроде scope передаются через meta. Multipart переносит файлы, WebSocket — вопросы и снимки.
|
|
18
|
+
|
|
19
|
+
Для встраивания MfupEngine(config).router и lifespan (либо startup/shutdown) управляют маршрутами и очисткой. Один lifecycle на Engine и один процесс на каталог метаданных. Create_app применяет config.prefix и управляет lifecycle.
|
|
20
|
+
|
|
21
|
+
Map_file вызывается при получении манифеста до чтения тела и возвращает относительный путь либо None. On_committed получает CommitEvent и возвращает boolean для server auto_publish (default False). Client_publish=False оставляет публикацию backend. Engine предоставляет get_session, list_staged, open_staged, prepare_publish, publish, retry_committed. Ошибка callback сохраняет принятые файлы и успешный commit с processing=failed.
|
|
22
|
+
|
|
23
|
+
Standalone: MFUP_AUTHORIZE=myapp.hooks:authorize python -m mfup_fastapi. MfupConfig.from_env использует те же MFUP_* настройки, что Node; Python hooks — callable или строки module:attribute.
|
|
24
|
+
|
|
25
|
+
[API расширения](https://github.com/n0isy/mfup/blob/main/docs/ru/EXTENDING.md) · [Протокол](https://github.com/n0isy/mfup/blob/main/docs/ru/PROTOCOL.md)
|
|
26
|
+
|
|
27
|
+
[English](README.md)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from . import MfupConfig, create_app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
app = create_app(MfupConfig.from_env())
|
|
10
|
+
uvicorn.run(
|
|
11
|
+
app,
|
|
12
|
+
host=os.environ.get("MFUP_HOST", "127.0.0.1"),
|
|
13
|
+
port=int(os.environ.get("MFUP_PORT", "3001")),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from anyio import CancelScope
|
|
9
|
+
from fastapi import APIRouter, FastAPI, Request, WebSocket
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
from mfup_core import Engine, ProtocolError
|
|
12
|
+
from mfup_core.engine import check, integer, normalize_error
|
|
13
|
+
from python_multipart.multipart import MultipartParser, parse_options_header
|
|
14
|
+
from starlette.requests import ClientDisconnect
|
|
15
|
+
|
|
16
|
+
from .config import MfupConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def json_body(request):
|
|
20
|
+
chunks = bytearray()
|
|
21
|
+
async for chunk in request.stream():
|
|
22
|
+
chunks.extend(chunk)
|
|
23
|
+
check(len(chunks) <= 262144, "body_too_large", 413)
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(chunks or b"{}")
|
|
26
|
+
check(isinstance(data, dict), "bad_json")
|
|
27
|
+
return data
|
|
28
|
+
except (ValueError, TypeError):
|
|
29
|
+
raise ProtocolError(400, "bad_json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MfupEngine:
|
|
33
|
+
def __init__(self, base_dir, authorize=None, **options):
|
|
34
|
+
config = (
|
|
35
|
+
base_dir
|
|
36
|
+
if isinstance(base_dir, MfupConfig)
|
|
37
|
+
else MfupConfig(base_dir=base_dir, authorize=authorize, **options)
|
|
38
|
+
)
|
|
39
|
+
self.config = config
|
|
40
|
+
self.engine = Engine(**config.engine_options())
|
|
41
|
+
self._sweeper = None
|
|
42
|
+
self.router = APIRouter()
|
|
43
|
+
engine = self.engine
|
|
44
|
+
|
|
45
|
+
@self.router.get("/mfup/health")
|
|
46
|
+
async def health():
|
|
47
|
+
return dict(protocol="MFUP/3", backend="python")
|
|
48
|
+
|
|
49
|
+
@self.router.post("/mfup/sessions")
|
|
50
|
+
async def create(request: Request):
|
|
51
|
+
try:
|
|
52
|
+
return JSONResponse(
|
|
53
|
+
await engine.create(
|
|
54
|
+
await json_body(request),
|
|
55
|
+
dict(request.headers),
|
|
56
|
+
{
|
|
57
|
+
"client": f"{request.client.host}:{request.client.port}"
|
|
58
|
+
if request.client
|
|
59
|
+
else "",
|
|
60
|
+
"query": dict(request.query_params),
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
status_code=201,
|
|
64
|
+
)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return self.error(engine.record_failure("", exc, "create"))
|
|
67
|
+
|
|
68
|
+
@self.router.api_route("/mfup/sessions/{sid}", methods=["GET"])
|
|
69
|
+
@self.router.api_route("/mfup/sessions/{sid}/{action}", methods=["GET", "POST"])
|
|
70
|
+
@self.router.api_route("/mfup/sessions/{sid}/{action}/{bid}", methods=["GET", "POST"])
|
|
71
|
+
async def route(request: Request, sid: str, action: str = "", bid: str = ""):
|
|
72
|
+
authenticated = False
|
|
73
|
+
phase = "upload" if action == "batches" else (action or "status")
|
|
74
|
+
try:
|
|
75
|
+
engine.authenticate(
|
|
76
|
+
sid, request.headers.get("authorization", "").removeprefix("Bearer ")
|
|
77
|
+
)
|
|
78
|
+
authenticated = True
|
|
79
|
+
if request.method == "GET":
|
|
80
|
+
if not action:
|
|
81
|
+
return engine.snapshot(sid)
|
|
82
|
+
if action == "published":
|
|
83
|
+
return engine.published_page(sid, request.query_params.get("after", ""))
|
|
84
|
+
if action == "files":
|
|
85
|
+
raw_limit = request.query_params.get("limit", "256")
|
|
86
|
+
check(raw_limit.isdecimal(), "bad_number")
|
|
87
|
+
return engine.resume_page(
|
|
88
|
+
sid, request.query_params.get("after", ""), int(raw_limit)
|
|
89
|
+
)
|
|
90
|
+
if action == "batches" and bid:
|
|
91
|
+
result = engine.receipt(sid, bid)
|
|
92
|
+
check(result is not None, "not_found", 404)
|
|
93
|
+
return result
|
|
94
|
+
if request.method == "POST":
|
|
95
|
+
if action == "batches" and bid:
|
|
96
|
+
return await self.receive(request, sid, bid)
|
|
97
|
+
data = await json_body(request)
|
|
98
|
+
if action == "status":
|
|
99
|
+
return engine.range_status(sid, data)
|
|
100
|
+
if action == "resume":
|
|
101
|
+
return await engine.resume(sid)
|
|
102
|
+
if action == "commit":
|
|
103
|
+
return await engine.commit(sid, data)
|
|
104
|
+
if action == "publish":
|
|
105
|
+
return await engine.publish_from_client(sid)
|
|
106
|
+
if action == "cancel":
|
|
107
|
+
return await engine.cancel(sid)
|
|
108
|
+
if action == "properties":
|
|
109
|
+
return await engine.set_properties(sid, data)
|
|
110
|
+
if action == "answers":
|
|
111
|
+
return await engine.answer(sid, data.get("id"), data.get("choice"))
|
|
112
|
+
raise ProtocolError(404, "not_found")
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
return self.error(
|
|
115
|
+
engine.record_failure(sid, exc, phase)
|
|
116
|
+
if authenticated
|
|
117
|
+
else normalize_error(exc, phase)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@self.router.websocket("/mfup/control")
|
|
121
|
+
async def control(ws: WebSocket):
|
|
122
|
+
await ws.accept()
|
|
123
|
+
unsubscribe = None
|
|
124
|
+
tasks = []
|
|
125
|
+
queue = asyncio.Queue(maxsize=1)
|
|
126
|
+
|
|
127
|
+
def update(state):
|
|
128
|
+
if queue.full():
|
|
129
|
+
queue.get_nowait()
|
|
130
|
+
queue.put_nowait(state)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
message = await asyncio.wait_for(ws.receive_json(), 5)
|
|
134
|
+
check(message.get("type") == "subscribe", "bad_message")
|
|
135
|
+
engine.authenticate(message.get("id"), message.get("token"))
|
|
136
|
+
unsubscribe = engine.subscribe(message["id"], update)
|
|
137
|
+
|
|
138
|
+
async def send():
|
|
139
|
+
while True:
|
|
140
|
+
await ws.send_json(await queue.get())
|
|
141
|
+
|
|
142
|
+
async def receive():
|
|
143
|
+
while True:
|
|
144
|
+
message = await ws.receive()
|
|
145
|
+
if message["type"] == "websocket.disconnect":
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
tasks = [asyncio.create_task(send()), asyncio.create_task(receive())]
|
|
149
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
150
|
+
except Exception:
|
|
151
|
+
with contextlib.suppress(Exception):
|
|
152
|
+
await ws.close(code=1008)
|
|
153
|
+
finally:
|
|
154
|
+
if unsubscribe:
|
|
155
|
+
unsubscribe()
|
|
156
|
+
for task in tasks:
|
|
157
|
+
task.cancel()
|
|
158
|
+
with CancelScope(shield=True):
|
|
159
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
160
|
+
|
|
161
|
+
async def startup(self):
|
|
162
|
+
if self._sweeper is not None or not self.config.sweep_interval_ms:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
async def sweep():
|
|
166
|
+
while True:
|
|
167
|
+
await asyncio.sleep(self.config.sweep_interval_ms / 1000)
|
|
168
|
+
try:
|
|
169
|
+
await self.engine.sweep()
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
self.engine.report("sweep", "", exc)
|
|
172
|
+
|
|
173
|
+
self._sweeper = asyncio.create_task(sweep())
|
|
174
|
+
|
|
175
|
+
async def shutdown(self):
|
|
176
|
+
if self._sweeper:
|
|
177
|
+
self._sweeper.cancel()
|
|
178
|
+
await asyncio.gather(self._sweeper, return_exceptions=True)
|
|
179
|
+
self._sweeper = None
|
|
180
|
+
await self.engine.close()
|
|
181
|
+
|
|
182
|
+
@asynccontextmanager
|
|
183
|
+
async def lifespan(self, app):
|
|
184
|
+
await self.startup()
|
|
185
|
+
try:
|
|
186
|
+
yield
|
|
187
|
+
finally:
|
|
188
|
+
await self.shutdown()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def error(exc):
|
|
192
|
+
return JSONResponse(
|
|
193
|
+
dict(error=exc.code, message=str(exc), retryable=exc.retryable, phase=exc.phase),
|
|
194
|
+
status_code=exc.status,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def receive(self, request, sid, bid):
|
|
198
|
+
epoch = request.headers.get("x-mfup-epoch", "")
|
|
199
|
+
check(epoch.isdecimal(), "bad_epoch")
|
|
200
|
+
task = asyncio.current_task()
|
|
201
|
+
batch = await self.engine.begin(sid, integer(int(epoch)), bid, task.cancel)
|
|
202
|
+
output = None
|
|
203
|
+
failed = False
|
|
204
|
+
try:
|
|
205
|
+
content_type, params = parse_options_header(request.headers.get("content-type", ""))
|
|
206
|
+
check(
|
|
207
|
+
content_type == b"multipart/form-data"
|
|
208
|
+
and 0 < len(params.get(b"boundary", b"")) <= 200,
|
|
209
|
+
"bad_multipart",
|
|
210
|
+
)
|
|
211
|
+
events = []
|
|
212
|
+
callbacks = {}
|
|
213
|
+
for name in ("part_begin", "header_end", "headers_finished", "part_end", "end"):
|
|
214
|
+
callbacks["on_" + name] = lambda name=name: events.append((name,))
|
|
215
|
+
for name in ("header_field", "header_value", "part_data"):
|
|
216
|
+
callbacks["on_" + name] = lambda data, start, end, name=name: events.append(
|
|
217
|
+
(name, data, start, end)
|
|
218
|
+
)
|
|
219
|
+
parser = MultipartParser(params[b"boundary"], callbacks)
|
|
220
|
+
manifest_seen = ended = False
|
|
221
|
+
cached = None
|
|
222
|
+
count = total = length = header_size = 0
|
|
223
|
+
mode = ""
|
|
224
|
+
headers = {}
|
|
225
|
+
field, value, manifest = bytearray(), bytearray(), bytearray()
|
|
226
|
+
expected = 0
|
|
227
|
+
stream = request.stream().__aiter__()
|
|
228
|
+
while True:
|
|
229
|
+
try:
|
|
230
|
+
chunk = await asyncio.wait_for(stream.__anext__(), 30)
|
|
231
|
+
except StopAsyncIteration:
|
|
232
|
+
break
|
|
233
|
+
except asyncio.TimeoutError:
|
|
234
|
+
raise ProtocolError(408, "request_timeout") from None
|
|
235
|
+
total += len(chunk)
|
|
236
|
+
check(
|
|
237
|
+
total <= self.engine.limits["batchBytes"] + 1024 * 1024, "body_too_large", 413
|
|
238
|
+
)
|
|
239
|
+
parser.write(chunk)
|
|
240
|
+
for event in events:
|
|
241
|
+
name = event[0]
|
|
242
|
+
if name == "part_begin":
|
|
243
|
+
headers = {}
|
|
244
|
+
field, value = bytearray(), bytearray()
|
|
245
|
+
header_size = 0
|
|
246
|
+
elif name in ("header_field", "header_value"):
|
|
247
|
+
data = memoryview(event[1])[event[2] : event[3]]
|
|
248
|
+
header_size += len(data)
|
|
249
|
+
check(header_size <= 8192, "headers_too_large", 413)
|
|
250
|
+
(field if name == "header_field" else value).extend(data)
|
|
251
|
+
elif name == "header_end":
|
|
252
|
+
headers[bytes(field).lower()] = bytes(value)
|
|
253
|
+
check(len(headers) <= 32, "headers_too_large", 413)
|
|
254
|
+
field, value = bytearray(), bytearray()
|
|
255
|
+
elif name == "headers_finished":
|
|
256
|
+
_, options = parse_options_header(headers.get(b"content-disposition", b""))
|
|
257
|
+
part_name = options.get(b"name", b"")
|
|
258
|
+
if part_name == b"manifest" and b"filename" not in options:
|
|
259
|
+
check(not manifest_seen and count == 0, "bad_manifest")
|
|
260
|
+
manifest_seen = True
|
|
261
|
+
mode = "manifest"
|
|
262
|
+
else:
|
|
263
|
+
check(
|
|
264
|
+
manifest_seen and "manifest" in batch or cached is not None,
|
|
265
|
+
"bad_manifest",
|
|
266
|
+
)
|
|
267
|
+
check(
|
|
268
|
+
part_name == str(count).encode() and b"filename" in options,
|
|
269
|
+
"bad_part",
|
|
270
|
+
)
|
|
271
|
+
check(count < self.engine.limits["maxParts"], "too_many_parts", 413)
|
|
272
|
+
mode, length = "file", 0
|
|
273
|
+
if cached is None:
|
|
274
|
+
check(count < len(batch["entries"]), "unexpected_part")
|
|
275
|
+
entry = batch["entries"][count]
|
|
276
|
+
expected = entry["item"][4]
|
|
277
|
+
if not entry["skip"]:
|
|
278
|
+
output = open(entry["filePath"], "r+b")
|
|
279
|
+
output.seek(entry["item"][3])
|
|
280
|
+
count += 1
|
|
281
|
+
elif name == "part_data":
|
|
282
|
+
data = memoryview(event[1])[event[2] : event[3]]
|
|
283
|
+
if mode == "manifest":
|
|
284
|
+
manifest.extend(data)
|
|
285
|
+
check(len(manifest) <= 262144, "bad_manifest")
|
|
286
|
+
else:
|
|
287
|
+
length += len(data)
|
|
288
|
+
if cached is None:
|
|
289
|
+
check(length <= expected, "part_size_mismatch")
|
|
290
|
+
if output:
|
|
291
|
+
output.write(data)
|
|
292
|
+
elif name == "part_end":
|
|
293
|
+
if mode == "manifest":
|
|
294
|
+
try:
|
|
295
|
+
raw = json.loads(manifest)
|
|
296
|
+
except ValueError:
|
|
297
|
+
raise ProtocolError(400, "bad_manifest")
|
|
298
|
+
cached = await self.engine.prepare(batch, raw)
|
|
299
|
+
else:
|
|
300
|
+
if output:
|
|
301
|
+
output.close()
|
|
302
|
+
output = None
|
|
303
|
+
if cached is None:
|
|
304
|
+
check(length == expected, "part_size_mismatch")
|
|
305
|
+
elif name == "end":
|
|
306
|
+
ended = True
|
|
307
|
+
events.clear()
|
|
308
|
+
parser.finalize()
|
|
309
|
+
check(ended and manifest_seen, "incomplete_multipart")
|
|
310
|
+
if cached is not None:
|
|
311
|
+
return cached
|
|
312
|
+
check(count == len(batch["manifest"]["files"]), "missing_part")
|
|
313
|
+
return await self.engine.complete(batch)
|
|
314
|
+
except (
|
|
315
|
+
ClientDisconnect,
|
|
316
|
+
asyncio.CancelledError,
|
|
317
|
+
ValueError,
|
|
318
|
+
KeyError,
|
|
319
|
+
):
|
|
320
|
+
failed = True
|
|
321
|
+
raise ProtocolError(400, "incomplete_multipart")
|
|
322
|
+
except BaseException:
|
|
323
|
+
failed = True
|
|
324
|
+
raise
|
|
325
|
+
finally:
|
|
326
|
+
try:
|
|
327
|
+
if output:
|
|
328
|
+
output.close()
|
|
329
|
+
except OSError:
|
|
330
|
+
if not failed:
|
|
331
|
+
raise
|
|
332
|
+
finally:
|
|
333
|
+
self.engine.end(batch)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def create_app(base_dir, authorize=None, **options):
|
|
337
|
+
mfup = MfupEngine(base_dir, authorize, **options)
|
|
338
|
+
app = FastAPI(lifespan=mfup.lifespan)
|
|
339
|
+
app.include_router(mfup.router, prefix=mfup.config.prefix)
|
|
340
|
+
app.state.mfup = mfup
|
|
341
|
+
return app
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mfup_core.contracts import Authorize, MapFile, OnCommitted
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_hook(value):
|
|
14
|
+
if value is None or callable(value):
|
|
15
|
+
return value
|
|
16
|
+
module, name = value.rsplit(":", 1)
|
|
17
|
+
hook = getattr(importlib.import_module(module), name)
|
|
18
|
+
if not callable(hook):
|
|
19
|
+
raise ValueError(f"Hook is not callable: {value}")
|
|
20
|
+
return hook
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MfupConfig:
|
|
25
|
+
base_dir: str | Path
|
|
26
|
+
authorize: Authorize | str
|
|
27
|
+
map_file: MapFile | str | None = None
|
|
28
|
+
on_committed: OnCommitted | str | None = None
|
|
29
|
+
limits: dict[str, int] | None = None
|
|
30
|
+
ttl_ms: int = 86400000
|
|
31
|
+
sweep_interval_ms: int = 60000
|
|
32
|
+
max_meta_bytes: int = 16384
|
|
33
|
+
max_context_bytes: int = 65536
|
|
34
|
+
auto_publish: bool = False
|
|
35
|
+
client_publish: bool = True
|
|
36
|
+
prefix: str = ""
|
|
37
|
+
on_error: Any = None
|
|
38
|
+
|
|
39
|
+
def engine_options(self):
|
|
40
|
+
self.prefix = self.prefix.rstrip("/")
|
|
41
|
+
if self.prefix and not re.fullmatch(r"/(?:[A-Za-z0-9_-]+/?)+", self.prefix):
|
|
42
|
+
raise ValueError("Invalid prefix")
|
|
43
|
+
result = {
|
|
44
|
+
f.name: getattr(self, f.name)
|
|
45
|
+
for f in fields(self)
|
|
46
|
+
if f.name not in ("prefix", "sweep_interval_ms")
|
|
47
|
+
}
|
|
48
|
+
for name in ("authorize", "map_file", "on_committed"):
|
|
49
|
+
result[name] = resolve_hook(result[name])
|
|
50
|
+
if not callable(result["authorize"]):
|
|
51
|
+
raise ValueError("MFUP_AUTHORIZE is required")
|
|
52
|
+
if type(self.sweep_interval_ms) is not int or self.sweep_interval_ms < 0:
|
|
53
|
+
raise ValueError("Invalid sweep_interval_ms")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls, env=None):
|
|
58
|
+
env = os.environ if env is None else env
|
|
59
|
+
values = dict(
|
|
60
|
+
base_dir=env.get("MFUP_BASE_DIR", "./data"), authorize=env.get("MFUP_AUTHORIZE", "")
|
|
61
|
+
)
|
|
62
|
+
for name in ("map_file", "on_committed", "prefix"):
|
|
63
|
+
if "MFUP_" + name.upper() in env:
|
|
64
|
+
values[name] = env["MFUP_" + name.upper()]
|
|
65
|
+
for name in ("ttl_ms", "sweep_interval_ms", "max_meta_bytes", "max_context_bytes"):
|
|
66
|
+
if "MFUP_" + name.upper() in env:
|
|
67
|
+
values[name] = int(env["MFUP_" + name.upper()])
|
|
68
|
+
for name in ("auto_publish", "client_publish"):
|
|
69
|
+
raw = env.get("MFUP_" + name.upper())
|
|
70
|
+
if raw is not None:
|
|
71
|
+
if raw.lower() not in ("true", "false", "1", "0"):
|
|
72
|
+
raise ValueError(f"Invalid boolean: {name}")
|
|
73
|
+
values[name] = raw.lower() in ("true", "1")
|
|
74
|
+
limits = {}
|
|
75
|
+
for field, variable in dict(
|
|
76
|
+
concurrency="CONCURRENCY",
|
|
77
|
+
maxParts="MAX_PARTS",
|
|
78
|
+
batchBytes="BATCH_BYTES",
|
|
79
|
+
partBytes="PART_BYTES",
|
|
80
|
+
).items():
|
|
81
|
+
if "MFUP_" + variable in env:
|
|
82
|
+
limits[field] = int(env["MFUP_" + variable])
|
|
83
|
+
if limits:
|
|
84
|
+
values["limits"] = limits
|
|
85
|
+
return cls(**values)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
[project]
|
|
5
|
+
name = "mfup-fastapi"
|
|
6
|
+
version = "3.1.0"
|
|
7
|
+
description = "MFUP/3 FastAPI adapter with streaming multipart and WebSocket questions"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
authors = [{ name = "n0isy" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["mfup-core==3.1.0", "fastapi>=0.115", "anyio>=4", "uvicorn[standard]>=0.30", "python-multipart>=0.0.20"]
|
|
14
|
+
[project.scripts]
|
|
15
|
+
mfup-server = "mfup_fastapi.__main__:main"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "httpx>=0.28"]
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["mfup_fastapi"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/n0isy/mfup"
|
|
24
|
+
Repository = "https://github.com/n0isy/mfup"
|
|
25
|
+
Issues = "https://github.com/n0isy/mfup/issues"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.sdist]
|
|
28
|
+
include = ["/mfup_fastapi", "/pyproject.toml", "/README.md", "/README_ru.md", "/LICENSE"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build]
|
|
31
|
+
ignore-vcs = true
|
|
32
|
+
exclude = ["**/__pycache__/**", "**/*.py[cod]"]
|