Skip to main content

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

PyPI

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

tus = TUSApp(
    storage=FilesystemStorage(directory=Path('./uploads')),
    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

tus = TUSApp(
    storage=FilesystemStorage(directory=Path('./uploads')),
    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.meta.get('filename', upload.name)
    upload.save(Path('./dest') / filename)
    upload.save_meta(Path('./dest') / f'{filename}.meta')

Raises TimeoutError if no upload is available within timeout seconds.

Metadata

Clients pass metadata via the Upload-Metadata header as a comma-separated list of key base64(value) pairs per the TUS spec. Tussi decodes this into a dict[str, str] available as upload.meta inside wait_for_file.

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 (default 4096 bytes)
  • The filename key, if present, must contain only printable ASCII (0x20-0x7E), otherwise the upload is rejected with 400 Bad Request

How you use the metadata afterwards is up to your application. The snippet above uses filename as the destination filename.

Tussi never uses filename for storage. Uploads are always stored under a UUID. Therefore 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=FilesystemStorage(directory=Path('./uploads')),
    completed_dir=Path('./completed'),
    on_event=on_event,
)

Available events: UploadCreatedEvent, UploadProgressEvent, UploadCompletedEvent, UploadFailedEvent.

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_size and max_chunk_size to 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

Configuration

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
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

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

Storage layout

uploads/          # Storage directory for in-progress uploads
  {uuid}          # pre-allocated buffer file (posix_fallocate)
  {uuid}.meta     # upload metadata (JSON)

completed/        # completed_dir for finalized uploads
  {uuid}          # completed file (moved atomically from uploads/)
  {uuid}.meta     # upload metadata (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 cli is required in order for the tussi-server command 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 cli is required in order for the tussi-upload command 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 'bump version to 0.x.y'
git tag v0.x.y
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

tussi-1.0.0.tar.gz (27.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tussi-1.0.0-py3-none-any.whl (24.7 kB view details)

Uploaded Python 3

File details

Details for the file tussi-1.0.0.tar.gz.

File metadata

  • Download URL: tussi-1.0.0.tar.gz
  • Upload date:
  • Size: 27.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tussi-1.0.0.tar.gz
Algorithm Hash digest
SHA256 fa5631f4af817ed516560c508a6e2f8b180f566fc6157ecff827b888f0a41226
MD5 edc787ea1272ef0e66bf829d092f870c
BLAKE2b-256 7f0fd27dfb97886f50a5125aafb5627bd2299a5382213c5274bc9b2cc606dc06

See more details on using hashes here.

Provenance

The following attestation bundles were made for tussi-1.0.0.tar.gz:

Publisher: publish.yml on bartscherer/tussi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tussi-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: tussi-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 24.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for tussi-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6d4cb0f7cc2d5f2bc30369f8d33841c61dddd2a38a3c414b6dfc5bd8b7e6cad6
MD5 0707f1b20473e343dc51e098b5f7bf25
BLAKE2b-256 22706c387e47c4f16c4abee4afe6d6f5aad1479eb1f1008b1ce6f050a391e468

See more details on using hashes here.

Provenance

The following attestation bundles were made for tussi-1.0.0-py3-none-any.whl:

Publisher: publish.yml on bartscherer/tussi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page