TUS 1.0.0 resumable upload server - ASGI middleware with filesystem storage
Project description
tussi
A TUS 1.0.0 resumable upload server for Python. ASGI-native, filesystem storage, no framework lock-in.
File uploads break and are a chore to implement. tussi handles the resume. Clients pick up exactly where they left off. Drop it into any ASGI app, point a TUS client at it, done.
Linux only. Tussi uses posix_fallocate for pre-allocation and fcntl.flock for safe worker coordination.
Install
pip install tussi
Core dependencies (anyio, pydantic, starlette) are installed automatically. Optional extras:
| Extra | Installs | When you need it |
|---|---|---|
tussi[cli] |
fastapi, rich, uvicorn, requests |
tussi-server and tussi-upload CLI tools |
tussi[test] |
pytest, httpx, anyio[trio] |
Running the test suite |
Quick start
from pathlib import Path
from tussi import TUSApp, FilesystemStorage
storage = FilesystemStorage(directory=Path('./uploads'))
tus = TUSApp(
storage=storage,
completed_dir=Path('./completed'),
)
tus is a standard ASGI callable. Run it with any ASGI server:
uvicorn myapp:tus
FastAPI integration
tussi does not require FastAPI, but integrates cleanly via get_response:
from pathlib import Path
from fastapi import FastAPI, Request
from starlette.responses import Response
from tussi import TUSApp, FilesystemStorage
storage = FilesystemStorage(directory=Path('./uploads'))
tus = TUSApp(storage=storage, completed_dir=Path('./completed'))
app = FastAPI()
@app.api_route(
'/files/{path:path}',
methods=['HEAD', 'PATCH', 'POST', 'OPTIONS'],
include_in_schema=False,
)
async def tus_handler(request: Request) -> Response:
return await tus.get_response(request.scope, request.receive)
See tussi/_demo_server.py for a full example including auth dependency, lifespan worker, and janitor.
Processing completed uploads
wait_for_file is an async context manager that blocks until a completed upload
is available, claims it with an exclusive lock, and cleans up on exit. Safe to
call from multiple concurrent workers, because each worker claims exactly one file.
# tus = TUSApp(...) - from "Quick start" above
async with tus.wait_for_file(timeout=3600) as upload:
filename = upload.record.metadata.get('filename', upload.name)
upload.save(Path('./dest') / filename)
upload.save_record(Path('./dest') / f'{filename}.meta')
upload.save(dest)moves the upload file todestupload.save_record(dest)moves the.metasidecar file todest; call this if you want to keep the record (fields likefinished_at,duration,metadata) alongside the file- Both raise
RuntimeErrorif called more than once - On context manager exit both files are deleted from
completed_dir, regardless of whethersave/save_recordwere called
To read back a saved record later:
from tussi import UploadRecord
record = UploadRecord.from_file(Path('./dest') / f'{filename}.meta')
print(record.duration, record.metadata)
Raises TimeoutError if no upload is available within timeout seconds.
UploadRecord
upload.record inside wait_for_file is an UploadRecord with these fields:
| Field | Type | Description |
|---|---|---|
metadata |
dict[str, str] |
Key-value pairs decoded from the Upload-Metadata header |
server_metadata |
dict[str, str] |
Key-value pairs returned by the on_create hook (empty if no hook) |
length |
int | None |
Declared upload size in bytes |
offset |
int |
Bytes received |
created_at |
float |
Unix timestamp of upload creation |
last_write |
float |
Unix timestamp of last successful write |
finished_at |
datetime | None |
UTC timestamp set when finalized |
duration |
timedelta | None |
Time from creation to finalization |
Metadata constraints:
- Keys must match
[a-zA-Z0-9_-]+(one or more characters). Pairs with invalid keys are silently ignored - The total header size is limited by
max_metadata_size(default4096bytes) - The
filenamekey, if present, must contain only printable ASCII (0x20-0x7E), otherwise the upload is rejected with400 Bad Request
Tussi never uses filename for storage. Uploads are always stored under a UUID. Path traversal via metadata is not possible.
Event hooks
Pass on_event to react to upload lifecycle events:
from tussi import TUSApp, TUSEvent, UploadCompletedEvent
async def on_event(event: TUSEvent) -> None:
if isinstance(event, UploadCompletedEvent):
print(f'upload complete: {event.upload_info.upload_id}')
tus = TUSApp(
storage=storage,
completed_dir=Path('./completed'),
on_event=on_event,
)
Available events: UploadCreatedEvent, UploadProgressEvent,
UploadCompletedEvent, UploadFailedEvent.
Server-side metadata
Pass on_create to inject server-controlled metadata at upload creation time.
The hook is called on every POST before the record is persisted. It receives
the request headers and the client-provided metadata, and returns a dict that
is stored separately as server_metadata on the record. The client metadata
is never modified.
async def on_create(
headers: dict[str, str], # lowercase header names, decoded values
metadata: dict[str, str], # client-provided Upload-Metadata (already decoded)
) -> dict[str, str]: # stored as record.server_metadata
token = headers.get('authorization', '').removeprefix('Bearer ')
user_id = await resolve_user(token)
return {'uploaded_by': str(user_id)}
tus = TUSApp(
storage=storage,
completed_dir=Path('./completed'),
on_create=on_create,
)
After the upload completes:
async with tus.wait_for_file(timeout=3600) as upload:
user = upload.record.server_metadata.get('uploaded_by')
client_filename = upload.record.metadata.get('filename')
Janitor
Janitor cleans up stale and stuck uploads. Call janitor.run() periodically, e.g. from a background worker.
from tussi import Janitor
# storage and completed_dir are the same instances passed to TUSApp
janitor = Janitor(
storage=storage,
completed_dir=Path('./completed'),
)
| Parameter | Default | Description |
|---|---|---|
storage |
required | Same Storage instance as TUSApp |
completed_dir |
required | Same completed_dir as TUSApp |
stale_upload_age |
86400 |
Delete incomplete uploads with no write activity for this many seconds |
completed_file_age |
604800 |
Delete finalized files from completed_dir older than this many seconds |
Each run() handles four cleanup cases:
| Case | Trigger | Action |
|---|---|---|
| Finalize zombie | offset == length but finalize never ran |
Delete from storage |
| Stale upload | No write for stale_upload_age seconds |
Delete from storage |
| Orphaned meta | .meta in staging with no upload data |
Remove .meta file |
| Old completed file | File in completed_dir older than completed_file_age |
Delete file and .meta |
FastAPI lifespan example with file worker and periodic cleanup:
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from tussi import TUSApp, FilesystemStorage, Janitor
log = logging.getLogger(__name__)
storage = FilesystemStorage(directory=Path('./uploads'))
tus = TUSApp(storage=storage, completed_dir=Path('./completed'))
janitor = Janitor(storage=storage, completed_dir=Path('./completed'))
@asynccontextmanager
async def lifespan(app: FastAPI):
async def file_worker():
while True:
try:
async with tus.wait_for_file(timeout=3600) as upload:
filename = upload.record.metadata.get('filename', upload.name)
upload.save(Path('./dest') / filename)
except TimeoutError:
pass
except Exception:
log.exception('file worker error')
async def cleanup_worker():
while True:
await asyncio.sleep(3600)
await janitor.run()
async with asyncio.TaskGroup() as tg:
tg.create_task(file_worker())
tg.create_task(cleanup_worker())
yield
app = FastAPI(lifespan=lifespan)
Security
Tussi has no built-in authentication. Protect the upload endpoint by placing auth in front of it either as ASGI middleware wrapping the whole app, or as a FastAPI dependency on the route:
async def require_auth(request: Request) -> None:
if request.headers.get('Authorization') != f'Bearer {SECRET}':
raise HTTPException(status_code=401)
@app.api_route('/files/{path:path}', ..., dependencies=[Depends(require_auth)])
async def tus_handler(request: Request) -> Response:
return await tus.get_response(request.scope, request.receive)
Other considerations:
- Set
max_sizeandmax_chunk_sizeto prevent clients from uploading arbitrarily large files - Uploads are stored under UUIDs, never under the client-supplied
filename. Path traversal via metadata is not possible - The uploads and completed directories should not be served as static files
Behind a reverse proxy
When tussi runs behind a reverse proxy (nginx, Caddy, Traefik, etc.), the Location header in 201 Created responses must reflect the public URL, not
the internal one. Pass trusted_proxies to enable this:
tus = TUSApp(
storage=storage,
completed_dir=Path('./completed'),
trusted_proxies=['127.0.0.1', '10.0.0.0/8'],
)
Each entry is an IP address or CIDR range. When the connecting client's IP
matches, tussi reads X-Forwarded-Proto to determine the scheme for the
Location header. Without trusted_proxies, X-Forwarded-Proto is always
ignored. A client cannot spoof the scheme by sending the header directly.
nginx example
location /files/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_set_header Host $host is required so the Location hostname matches
the public domain. X-Forwarded-Proto is only honoured when the proxy IP is
listed in trusted_proxies.
Only X-Forwarded-Proto is used. X-Forwarded-For and X-Forwarded-Host
are not read.
Configuration
TUSApp
| Parameter | Default | Description | Type |
|---|---|---|---|
storage |
required | Storage instance (e.g. FilesystemStorage) |
tussi.storage.Storage |
completed_dir |
required | Directory for finalized uploads | pathlib.Path | str |
on_event |
None |
Async callback for lifecycle events | Callable[[TUSEvent], Awaitable[None]] | None |
on_create |
None |
Async hook called on upload creation receiving request headers and client metadata, returns server_metadata to persist |
Callable[[dict[str, str], dict[str, str]], Awaitable[dict[str, str]]] | None |
max_size |
None |
Max upload size in bytes | int | None |
max_chunk_size |
10485760 |
Max PATCH body size in bytes | int | None |
max_metadata_size |
4096 |
Max Upload-Metadata header size in bytes |
int |
trusted_proxies |
None |
IPs or CIDR ranges whose X-Forwarded-Proto header is trusted for scheme resolution |
list[str] | None |
FilesystemStorage
| Parameter | Default | Description | Type |
|---|---|---|---|
directory |
required | Upload staging directory | pathlib.Path | str |
directory_mode |
0o755 |
Mode for directory creation | int |
fsync |
True |
fsync data to disk before updating offset in meta file. Disable for higher throughput at the cost of durability |
bool |
Janitor
| Parameter | Default | Description | Type |
|---|---|---|---|
storage |
required | Same Storage instance as TUSApp |
tussi.storage.Storage |
completed_dir |
required | Same completed_dir as TUSApp |
pathlib.Path |
stale_upload_age |
86400 |
Seconds of inactivity before an incomplete upload is deleted | float |
completed_file_age |
604800 |
Seconds before a finalized file is deleted from completed_dir |
float |
Storage layout
uploads/ # Storage directory for in-progress uploads
{uuid} # pre-allocated buffer file (posix_fallocate)
{uuid}.meta # upload record (JSON)
completed/ # completed_dir for finalized uploads
{uuid} # completed file (moved atomically from uploads/)
{uuid}.meta # upload record with finished_at and duration (JSON)
Demo server
The tussi-server command starts an interactive server with prompts for upload and destination directories. It can be used for testing.
The optional dependency
cliis required in order for thetussi-servercommand to be registered.
pip install 'tussi[cli]'
tussi-server
Demo upload
The tussi-upload tool provides a CLI for uploading a file to a TUS 1.0.0 instance. Call it with --help for additional params e.g. a file to upload. If no file is submitted, it just creates some random data, stores it in a temp file and uploads it then.
The optional dependency
cliis required in order for thetussi-uploadcommand to be registered.
pip install 'tussi[cli]'
tussi-upload
Protocol
Implements TUS 1.0.0 core + creation extension.
| Method | Path | Description |
|---|---|---|
| OPTIONS | /files/ |
Server capabilities |
| POST | /files/ |
Create upload |
| HEAD | /files/{id} |
Query offset |
| PATCH | /files/{id} |
Send chunk |
Release
# 1. bump version in pyproject.toml
# 2. commit and tag
git commit -am 'release: x.y.z'
git tag vx.y.z
git push && git push --tags
# CI runs tests, builds, and publishes to PyPI automatically
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tussi-3.1.0.tar.gz.
File metadata
- Download URL: tussi-3.1.0.tar.gz
- Upload date:
- Size: 32.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
640094e9a624a639b680eac59e4904b77521726910d369cca52dd04610f5485b
|
|
| MD5 |
db7d92da9894227960a5a298298c7a3d
|
|
| BLAKE2b-256 |
63d401aeec3975c96da5a445ef50e093bf82f0930f5b6cec5e280d49771e6da6
|
Provenance
The following attestation bundles were made for tussi-3.1.0.tar.gz:
Publisher:
publish.yml on bartscherer/tussi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tussi-3.1.0.tar.gz -
Subject digest:
640094e9a624a639b680eac59e4904b77521726910d369cca52dd04610f5485b - Sigstore transparency entry: 1952428267
- Sigstore integration time:
-
Permalink:
bartscherer/tussi@54a9cc3685c1f1c921592ee47d7d2aac1488d38c -
Branch / Tag:
refs/tags/v3.1.0 - Owner: https://github.com/bartscherer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@54a9cc3685c1f1c921592ee47d7d2aac1488d38c -
Trigger Event:
push
-
Statement type:
File details
Details for the file tussi-3.1.0-py3-none-any.whl.
File metadata
- Download URL: tussi-3.1.0-py3-none-any.whl
- Upload date:
- Size: 27.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
121a8e305bc8e8178bacbdfe2fd8b9e27b6b93ed5b774aeadef69a1eaac6ba30
|
|
| MD5 |
bcdc7c05ebf637d4d052e711a859750a
|
|
| BLAKE2b-256 |
724c67f37bb11f1da90c24c6134c948bda3852f7d1742bfad591562b26e926e5
|
Provenance
The following attestation bundles were made for tussi-3.1.0-py3-none-any.whl:
Publisher:
publish.yml on bartscherer/tussi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tussi-3.1.0-py3-none-any.whl -
Subject digest:
121a8e305bc8e8178bacbdfe2fd8b9e27b6b93ed5b774aeadef69a1eaac6ba30 - Sigstore transparency entry: 1952428473
- Sigstore integration time:
-
Permalink:
bartscherer/tussi@54a9cc3685c1f1c921592ee47d7d2aac1488d38c -
Branch / Tag:
refs/tags/v3.1.0 - Owner: https://github.com/bartscherer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@54a9cc3685c1f1c921592ee47d7d2aac1488d38c -
Trigger Event:
push
-
Statement type: