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.
@@ -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,4 @@
1
+ from .app import MfupEngine, create_app
2
+ from .config import MfupConfig
3
+
4
+ __all__ = ["MfupEngine", "MfupConfig", "create_app"]
@@ -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]"]
@@ -1,14 +0,0 @@
1
- node_modules/
2
- dist/
3
- *.egg-info/
4
- __pycache__/
5
- .vite/
6
- uploads/
7
- repomix-output.md
8
- *.url
9
- repomix.sh
10
- # e2e artifacts
11
- e2e/.tmp/
12
- e2e/test-results/
13
- e2e/playwright-report/
14
- e2e/webtop/testdata/