Metadata-Version: 2.4
Name: filestore
Version: 2.0.0
Summary: Production-ready file upload dependency and storage toolkit for FastAPI
Project-URL: Changelog, https://github.com/Ichinga-Samuel/faststore/blob/master/CHANGELOG.md
Project-URL: Documentation, https://ichinga-samuel.github.io/faststore/
Project-URL: Homepage, https://ichinga-samuel.github.io/faststore/
Project-URL: Repository, https://github.com/Ichinga-Samuel/faststore
Project-URL: Issues, https://github.com/Ichinga-Samuel/faststore/issues
Author-email: Ichinga Samuel <ichingasamuel@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: azure,fastapi,gcs,multipart,s3,storage,upload
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: fastapi<1.0.0,>=0.120.0
Requires-Dist: pydantic>=2.7
Requires-Dist: python-multipart>=0.0.20
Provides-Extra: all
Requires-Dist: azure-identity>=1.17.0; extra == 'all'
Requires-Dist: azure-storage-blob>=12.22.0; extra == 'all'
Requires-Dist: boto3>=1.40.59; extra == 'all'
Requires-Dist: google-cloud-storage>=2.18.0; extra == 'all'
Provides-Extra: azure
Requires-Dist: azure-identity>=1.17.0; extra == 'azure'
Requires-Dist: azure-storage-blob>=12.22.0; extra == 'azure'
Provides-Extra: gcp
Requires-Dist: google-cloud-storage>=2.18.0; extra == 'gcp'
Provides-Extra: s3
Requires-Dist: boto3>=1.40.59; extra == 's3'
Description-Content-Type: text/markdown

# filestore

[![PyPI version](https://img.shields.io/pypi/v/filestore.svg)](https://pypi.org/project/filestore/)
[![Python versions](https://img.shields.io/pypi/pyversions/filestore.svg)](https://pypi.org/project/filestore/)
[![Package status](https://img.shields.io/pypi/status/filestore.svg)](https://pypi.org/project/filestore/)
[![CI](https://github.com/Ichinga-Samuel/faststore/actions/workflows/action.yaml/badge.svg)](https://github.com/Ichinga-Samuel/faststore/actions/workflows/action.yaml)
[![Docs](https://github.com/Ichinga-Samuel/faststore/actions/workflows/docs.yml/badge.svg)](https://github.com/Ichinga-Samuel/faststore/actions/workflows/docs.yml)
[![License](https://img.shields.io/pypi/l/filestore.svg)](LICENSE)
[![Ruff](https://img.shields.io/badge/lint-ruff-46a3ff.svg)](https://docs.astral.sh/ruff/)
[![Typed](https://img.shields.io/badge/typed-py.typed-success.svg)](https://peps.python.org/pep-0561/)

`filestore` is a file upload dependency and storage toolkit for FastAPI with a dependency-based API and production-grade defaults.

- Pluggable storage engines: local disk, in-memory, S3, Google Cloud Storage, Azure Blob Storage
- **Presigned upload/download URLs** for every cloud backend — let clients talk to storage directly
- Safe local file writes (atomic rename, collision handling, path sanitization)
- Multi-field uploads with per-field configuration and per-field engines
- Sync or async callbacks for filenames, destinations, filters, and metadata
- Validation for size, extension, and content type
- Pydantic result models — return the `Store` straight from your route
- Optional cloud SDKs that never break the base install

Read the [documentation](https://ichinga-samuel.github.io/faststore/), follow the
[2.0 migration guide](https://ichinga-samuel.github.io/faststore/getting-started/migration/),
or review the [changelog](CHANGELOG.md) before upgrading.

## Installation

```bash
pip install filestore              # base install (local + memory engines)
pip install "filestore[s3]"        # Amazon S3 / S3-compatible
pip install "filestore[gcp]"       # Google Cloud Storage
pip install "filestore[azure]"     # Azure Blob Storage
pip install "filestore[all]"       # everything
```

## Quick Start

```python
from fastapi import Depends, FastAPI
from filestore import FileStore, LocalEngine, Store

app = FastAPI()

storage = FileStore(
    "file",
    required=True,
    engine=LocalEngine(base_dir="uploads", base_url="/media"),
)


@app.post("/upload")
async def upload(store: Store = Depends(storage)) -> Store:
    return store  # Store is a Pydantic model — FastAPI serializes it directly
```

## Core API

### Engines are instances

Configure an engine once and share it between stores; credentials and clients are cached on the engine.

```python
from filestore import FileStore, LocalEngine, MemoryEngine, S3Engine

local = LocalEngine(base_dir="uploads", base_url="/media")
s3 = S3Engine(bucket="my-bucket", region="us-east-1")

storage = FileStore("avatar", engine=s3)
```

Available engines:

| Engine | Extra | Presigned URLs | Delete |
| --- | --- | --- | --- |
| `LocalEngine(base_dir=, base_url=)` | — | ✗ | ✓ |
| `MemoryEngine()` | — | ✗ | ✗ |
| `S3Engine(bucket=, region=, endpoint_url=, client=)` | `s3` | ✓ | ✓ |
| `GCSEngine(bucket=, project=, credentials=, endpoint_url=, client=)` | `gcp` | ✓ | ✓ |
| `AzureBlobEngine(container=, connection_string=, account_url=, credential=, client=)` | `azure` | ✓ | ✓ |

Every cloud option also falls back to the usual environment variables
(`AWS_BUCKET_NAME`, `AWS_DEFAULT_REGION`, `GCP_BUCKET_NAME`, `GCP_PROJECT`,
`AZURE_STORAGE_CONTAINER`, `AZURE_STORAGE_CONNECTION_STRING`, `AZURE_STORAGE_ACCOUNT_URL`).

### Single field

```python
storage = FileStore("avatar", count=1, required=True, engine=MemoryEngine())
```

### Multiple fields

Field-level `config` overrides store-level `config`; a field can also use its own engine.

```python
from filestore import FileField, FileStore, StoreConfig

storage = FileStore(
    fields=[
        FileField(name="avatar", required=True, config={"destination": "avatars"}),
        FileField(name="resume", max_count=3, engine=S3Engine(bucket="cvs")),
    ],
    engine=local,
    config=StoreConfig(max_file_size=10 * 1024 * 1024),
)
```

### Reading results

The dependency returns a `Store` (a Pydantic model):

```python
store.status           # UploadStatus: "completed" | "partial" | "failed" | "empty"
bool(store)            # True only when status is "completed"
store.files            # dict[str, list[FileData]]
store.flat_files       # all files in one list
store.successful_files # only successful files
store.failed_files     # only failed files
store.total_files      # total count (successful + failed)
store.total_size       # sum of sizes for successful files
store.first("avatar")  # first FileData for a field, or None
store.message          # summary message
store.error            # first error, or None
store.errors           # all errors
```

Each `FileData` carries `field_name`, `filename`, `original_filename`, `content_type`,
`size`, `path` (local), `url`, `key` (object key / relative path), `status`, `error`,
`storage`, `metadata`, and `file` (raw bytes, memory engine only — excluded from JSON).

## Presigned URLs

Cloud engines can mint presigned URLs so clients upload or download directly, without
the payload passing through your server:

```python
from fastapi import FastAPI
from filestore import PresignedURL, S3Engine

app = FastAPI()
s3 = S3Engine(bucket="my-bucket", region="us-east-1")


@app.post("/uploads/presign")
async def presign_upload(filename: str, content_type: str) -> PresignedURL:
    return await s3.presign_upload(f"uploads/{filename}", expires_in=600, content_type=content_type)


@app.get("/downloads/presign")
async def presign_download(key: str) -> PresignedURL:
    return await s3.presign_download(key, expires_in=600)
```

The client then sends the file with the returned `method`, `url`, and `headers`:

```javascript
const presigned = await (await fetch("/uploads/presign?filename=a.png&content_type=image/png", {method: "POST"})).json();
await fetch(presigned.url, {method: presigned.method, headers: presigned.headers, body: file});
```

Notes per backend:

- **S3** — works with any credentials; SigV4 is used automatically. Works with MinIO/LocalStack via `endpoint_url`.
- **GCS** — V4 signed URLs require credentials with a private key (e.g. a service account).
- **Azure** — SAS URLs require an account key (connection-string or shared-key auth); upload URLs include the `x-ms-blob-type` header clients must send.

Engines also support `await engine.delete(key)` for removing stored objects.

## Validation and Callbacks

All callbacks receive a single `UploadContext` with `.request`, `.form`, `.field_name`, and `.file`.
They may be sync or async.

### Validation

```python
storage = FileStore(
    "image",
    config={
        "allowed_extensions": [".jpg", ".png"],
        "allowed_content_types": ["image/jpeg", "image/png"],
        "max_file_size": 5 * 1024 * 1024,
    },
)
```

### Dynamic destination

```python
async def destination(ctx):
    user_id = ctx.request.headers.get("X-User-ID", "anonymous")
    return f"uploads/{user_id}"

storage = FileStore("file", config={"destination": destination})
```

### Dynamic filename

The `filename` callback can return a string/path or an `UploadFile` whose `filename` has been updated.

```python
import uuid
from pathlib import Path

def unique_name(ctx):
    return f"reports/{uuid.uuid4()}{Path(ctx.file.filename or '').suffix}"

storage = FileStore("report", config={"filename": unique_name})
```

### Filters

Return `True` to accept, `False` to reject, or a string to reject with a custom message.
Store-level and field-level filters are concatenated (store first).

```python
async def allow_text(ctx):
    return ctx.file.content_type == "text/plain" or "Only plain text files are allowed"

storage = FileStore("notes", engine=MemoryEngine(), config={"filters": [allow_text]})
```

### Metadata

```python
def extra_metadata(ctx):
    return {"request_id": ctx.request.headers.get("X-Request-ID")}

storage = FileStore("file", config={"metadata": extra_metadata})
```

## Configuration Reference

`StoreConfig` (a dict works everywhere a `StoreConfig` is accepted):

- `destination`: upload directory or cloud key prefix; value or callback.
- `filename`: override the stored filename; value or callback.
- `filters`: one filter callback or a list.
- `metadata`: extra per-file metadata; dict or callback.
- `allowed_extensions`: allowlist of extensions (leading dot optional, case-insensitive).
- `allowed_content_types`: allowlist of MIME types (case-insensitive).
- `max_file_size` / `min_file_size`: size bounds in bytes.
- `max_files` / `max_fields` / `max_part_size`: multipart parsing limits (store level).
- `chunk_size`: local write chunk size.
- `overwrite`: allow overwriting existing objects (local + cloud).
- `sanitize_filename`: normalize names and strip unsafe path segments (default `True`).
- `base_url`: public URL prefix for local files.
- `extra_args`: extra keyword arguments passed through to the backend upload call.

Engine-specific settings (buckets, containers, credentials, endpoints) live on the
engine constructor, not in `StoreConfig`.

## Custom Engines

Subclass `StorageEngine` and implement `save()`; optionally override `delete()`,
`presign_upload()`, and `presign_download()`:

```python
from filestore import FileData, StorageEngine, StoreConfig
from starlette.datastructures import UploadFile


class MyEngine(StorageEngine):
    name = "custom"

    async def save(self, *, file: UploadFile, filename: str, destination: str | None,
                   config: StoreConfig, field_name: str = "") -> FileData:
        ...
        return FileData(filename=filename, size=..., key=..., storage=self.name)
```

## Migrating from 1.x

Version 2.0 is a redesign. The main breaking changes are summarized below; the
[complete migration guide](https://ichinga-samuel.github.io/faststore/getting-started/migration/)
includes side-by-side examples and a release checklist.

- `LocalStorage` / `MemoryStorage` / `S3Storage` / `GCSStorage` / `AzureStorage` are replaced by
  `FileStore(engine=...)` with engine instances (`LocalEngine`, `MemoryEngine`, `S3Engine`, `GCSEngine`, `AzureBlobEngine`).
- Backend settings (`AWS_BUCKET_NAME`, `GCP_*`, `AZURE_*`, `endpoint_url`) moved from `Config` to engine constructors.
- Callbacks take a single `UploadContext` instead of `(request, form, field_name, file)`.
- `FileData`, `Store`, `FileField`, and `StoreConfig` are Pydantic models; use `model_dump()` instead of `to_dict()`.
- `FileData.message` was removed (use `error` / `Store.message`); `FileData.key` was added.
- `Store.status` is an `UploadStatus` string enum (`"completed"`, `"partial"`, `"failed"`, `"empty"`)
  instead of a bool; use `if store:` / `bool(store)` for the old boolean meaning.
- `FileStore` with no fields now raises `ConfigurationError` at startup instead of failing per request.

## Development

```bash
uv sync --locked --all-extras --dev
uv run ruff format --check .
uv run ruff check .
uv run coverage run -m pytest
uv run coverage report
```

Build and validate the package metadata before publishing:

```bash
uv build
uv run twine check dist/*
```

Releases are published from GitHub Releases through the Trusted Publishing workflow in `.github/workflows/publish.yml`.

## License

MIT
