Compare commits

..

7 Commits

6 changed files with 397 additions and 44 deletions

View File

@ -16,10 +16,10 @@ This is a FastAPI-based file server that serves files from a directory or ZIP ar
```bash ```bash
# Run with a directory # Run with a directory
python main.py /path/to/files uv run main.py /path/to/files
# Run with a ZIP archive # Run with a ZIP archive
python main.py /path/to/archive.zip uv run main.py /path/to/archive.zip
``` ```
### Testing ### Testing
@ -46,7 +46,7 @@ pytest -k "test_name_pattern"
No linting tool is currently configured. Recommended tools: No linting tool is currently configured. Recommended tools:
```bash ```bash
# Install ruff (fast linter/formatter) # Install ruff (fast linter)
uv add ruff uv add ruff
# Run linting # Run linting
@ -54,9 +54,18 @@ ruff check .
# Auto-fix issues # Auto-fix issues
ruff check --fix . ruff check --fix .
```
# Format code ### Formatting
ruff format .
Always format code with black after implementing every feature:
```bash
# Install black
uv add black
# Format all code
uv run black .
``` ```
### Type Checking ### Type Checking
@ -66,7 +75,7 @@ ruff format .
uv add mypy uv add mypy
# Run type checking # Run type checking
mypy main.py uv run mypy main.py
``` ```
## Code Style Guidelines ## Code Style Guidelines
@ -88,12 +97,13 @@ mypy main.py
from mymodule import MyClass from mymodule import MyClass
``` ```
### Formatting ### Formatting Style
- Use 4 spaces for indentation (no tabs) - Use 4 spaces for indentation (no tabs)
- Maximum line length: 100 characters - Maximum line length: 88 characters (black default)
- Use blank lines to separate logical sections (2 blank lines between top-level definitions) - Use blank lines to separate logical sections (2 blank lines between top-level definitions)
- One blank line between method definitions within a class - One blank line between method definitions within a class
- **Always run `black .` after completing every feature**
### Types ### Types
@ -196,5 +206,5 @@ mypy main.py
### Running the Server in Development ### Running the Server in Development
```bash ```bash
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 uv run main ./*.zip
``` ```

192
README.md
View File

@ -0,0 +1,192 @@
# Image Server
A FastAPI-based image server that serves files from directories or ZIP archives using hashed paths for secure, randomized access. The server provides a beautiful full-screen image viewing experience with navigation controls and optional auto-advance features.
## Features
- **Secure File Access**: Files are accessed via SHA-256 hashes of their paths (with salt) rather than direct file paths
- **Multiple Sources**: Supports serving from directories, individual ZIP files, or glob patterns (e.g., `*.zip`)
- **Beautiful UI**: Full-screen responsive image viewer with keyboard navigation
- **Navigation Controls**:
- Previous/Next buttons (clickable chevrons or arrow keys)
- Random access mode
- Ordered sequential access with configurable delay
- Play/pause toggle for auto-advance
- **Keyboard Shortcuts**:
- Left/Right Arrow or H/L: Navigate previous/next
- Space: Toggle play/pause
- +/= or J: Increase slide delay
- - or K: Decrease slide delay
- O: Toggle between ordered and random modes
- **MIME Type Detection**: Automatically sets correct content types for served files
- **Health Check Endpoint**: Monitor server status and indexed file count
## Installation
### Prerequisites
- Python 3.13 or higher
- UV package manager (recommended) or pip
### Using UV (Recommended)
```bash
# Clone the repository
git clone <repository-url>
cd image_server
# Install dependencies
uv sync
# Activate virtual environment
source .venv/bin/activate
```
### Using pip
```bash
pip install fastapi uvicorn
```
## Usage
### Basic Usage
```bash
# Serve files from a directory
python main.py /path/to/your/images
# Serve files from a ZIP archive
python main.py /path/to/archive.zip
# Serve files matching a glob pattern
python main.py "path/to/images/*.zip"
```
### Server Options
```bash
python main.py [SOURCE] [OPTIONS]
Arguments:
SOURCE Path to directory, ZIP archive, or glob pattern (e.g., *.zip, path/to/zips/*.zip)
Options:
--host TEXT Host to bind to (default: 0.0.0.0)
--port INTEGER Port to bind to (default: 8000)
--salt TEXT Salt for hashing file paths (default: random)
-h, --help Show help message and exit
```
### Examples
```bash
# Start server on default port 8000
python main.py ./photos
# Start server on custom host and port
python main.py ./photos --host 127.0.0.1 --port 3000
# Start server with custom salt for consistent hashing
python main.py ./photos --salt mysecretkey123
# Start server serving from ZIP files
python main.py ./archives/*.zip
```
## API Endpoints
### File Access
- `GET /api/{file_hash}/data` - Retrieve file data by hash
- Returns: File content with appropriate Content-Type header
- Response: StreamingResponse of the file data
### Navigation Pages
- `GET /{file_hash}` - View file with navigation controls
- Displays image with previous/next navigation
- `GET /{order}/{delay}/{file_hash}` - View file with auto-refresh
- order: "next" (sequential) or "random"
- delay: Seconds before auto-advancing
- file_hash: Starting file hash
### Special Endpoints
- `GET /` - Redirect to a random file
- `GET /{order}/{delay}` - Redirect to random file with order/delay settings
- `GET /api/health` - Health check endpoint
- Returns: JSON with status and file count
## How It Works
### File Indexing
When started, the server indexes all files from the provided source(s):
- For directories: Walks the directory tree and indexes all files
- For ZIP archives: Indexes all files within the archive
- For glob patterns: Processes each matching file
Each file is assigned a unique SHA-256 hash based on:
- File path (relative to source or within ZIP)
- Salt value (random or user-provided)
### Security
- Direct file path access is prevented
- Files can only be accessed via their cryptographic hashes
- Salt prevents hash prediction attacks
- No filesystem traversal vulnerabilities
### User Interface
The server serves a single-page application (`frontend.html`) that provides:
- Full-screen image display
- Click-to-navigate on image
- Chevron buttons for previous/next navigation
- Play/pause button for auto-advance control
- Responsive design that works on mobile and desktop
## Development
### Running in Development Mode
```bash
# With auto-reload for development
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### Code Structure
- `main.py` - Contains all server logic, routing, and file indexing
- `frontend.html` - HTML template for the user interface
- `FileIndexer` - Base class for indexing files from directories
- `ZipFileIndexer` - Extension for indexing files from ZIP archives
- Global `file_mapping` dictionary - Maps hashes to file paths/filenames
### Adding New Features
1. **New Indexer Types**: Create a class inheriting from `FileIndexer` and override the `_index()` method
2. **New Routes**: Add functions decorated with appropriate HTTP method decorators (`@app.get`, etc.)
3. **UI Changes**: Modify `frontend.html` template and update template substitution in `_render_page()`
## Dependencies
- **FastAPI** - Modern web framework for building APIs
- **Uvicorn** - ASGI server for running the application
- **Python Standard Library** - hashlib, mimetypes, secrets, zipfile, etc.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Open a pull request
Please ensure your code follows the existing style and includes appropriate tests.

View File

@ -81,14 +81,14 @@
if (e.code === 'Space') { if (e.code === 'Space') {
var playBtn = document.querySelector('.play-btn'); var playBtn = document.querySelector('.play-btn');
if (playBtn) playBtn.click(); if (playBtn) playBtn.click();
} else if (e.code === 'ArrowLeft') { } else if (e.code === 'ArrowLeft' || e.key.toLowerCase() === 'h') {
document.getElementById('prev-btn').click(); document.getElementById('prev-btn').click();
} else if (e.code === 'ArrowRight') { } else if (e.code === 'ArrowRight' || e.key.toLowerCase() === 'l') {
document.getElementById('next-btn').click(); document.getElementById('next-btn').click();
} else if (e.code === 'Equal') { } else if (e.code === 'Equal' || e.key.toLowerCase() === 'j') {
var params = getRefreshParams(); var params = getRefreshParams();
if (params) window.location.href = '/' + params.order + '/' + (params.delay + 1) + '/' + params.hash; if (params) window.location.href = '/' + params.order + '/' + (params.delay + 1) + '/' + params.hash;
} else if (e.code === 'Minus') { } else if (e.code === 'Minus' || e.key.toLowerCase() === 'k') {
var params = getRefreshParams(); var params = getRefreshParams();
if (params && params.delay > 1) window.location.href = '/' + params.order + '/' + (params.delay - 1) + '/' + params.hash; if (params && params.delay > 1) window.location.href = '/' + params.order + '/' + (params.delay - 1) + '/' + params.hash;
} else if (e.key.toLowerCase() === 'o') { } else if (e.key.toLowerCase() === 'o') {

111
main.py
View File

@ -1,3 +1,4 @@
from typing import Annotated
import argparse import argparse
import hashlib import hashlib
import mimetypes import mimetypes
@ -13,7 +14,12 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse from fastapi.responses import (
FileResponse,
HTMLResponse,
RedirectResponse,
StreamingResponse,
)
app = FastAPI() app = FastAPI()
file_mapping = {} file_mapping = {}
@ -23,7 +29,9 @@ security = HTTPBasic()
expected_password: str | None = None expected_password: str | None = None
async def get_current_username(credentials: Annotated[HTTPBasicCredentials, Depends(security)]) -> str: async def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
) -> str:
"""Verify Basic Authentication credentials""" """Verify Basic Authentication credentials"""
if expected_password is not None and credentials.password != expected_password: if expected_password is not None and credentials.password != expected_password:
raise HTTPException( raise HTTPException(
@ -181,7 +189,9 @@ async def root(username: str = Depends(get_current_username)):
@app.get("/{order}/{delay}") @app.get("/{order}/{delay}")
async def order_delay(order: str, delay: int, username: str = Depends(get_current_username)): async def order_delay(
order: str, delay: int, username: str = Depends(get_current_username)
):
"""Redirect to random file with order and delay""" """Redirect to random file with order and delay"""
random_hash = _get_random_hash() random_hash = _get_random_hash()
return RedirectResponse( return RedirectResponse(
@ -189,13 +199,27 @@ async def order_delay(order: str, delay: int, username: str = Depends(get_curren
) )
def _get_navigation_data(file_hash: str): def _get_navigation_data(file_hash: str, order: str | None = None):
"""Get navigation data for a file hash""" """Get navigation data for a file hash.
Args:
file_hash: The current file's hash.
order: Navigation order - 'next' for sequential, 'random' for random,
or None for default browse mode.
Returns:
Dictionary with navigation hashes and filename.
"""
keys = list(file_mapping.keys()) keys = list(file_mapping.keys())
idx = keys.index(file_hash) idx = keys.index(file_hash)
if order == "random":
next_hash = _get_random_hash()
prev_hash = _get_random_hash()
else:
next_hash = keys[(idx + 1) % len(keys)] next_hash = keys[(idx + 1) % len(keys)]
prev_hash = keys[idx - 1] if idx > 0 else keys[-1] prev_hash = keys[idx - 1] if idx > 0 else keys[-1]
next_random_hash = _get_random_hash()
indexer = _find_indexer_for_hash(file_hash) indexer = _find_indexer_for_hash(file_hash)
filename = indexer.get_filename_by_hash(file_hash) if indexer else "" filename = indexer.get_filename_by_hash(file_hash) if indexer else ""
@ -203,25 +227,47 @@ def _get_navigation_data(file_hash: str):
"file_hash": file_hash, "file_hash": file_hash,
"next_hash": next_hash, "next_hash": next_hash,
"prev_hash": prev_hash, "prev_hash": prev_hash,
"next_random_hash": next_random_hash,
"filename": filename, "filename": filename,
} }
def _render_page( def _render_page(
navigation_data: dict, extra_meta: str = "", image_click_url: str = "", play_button: str = "" navigation_data: dict,
extra_meta: str = "",
image_click_url: str = "",
play_button: str = "",
current_order: str | None = None,
current_delay: int | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
"""Render the frontend page with navigation data""" """Render the frontend page with navigation data"""
with open("frontend.html", "r") as f: with open("frontend.html", "r") as f:
content = f.read() content = f.read()
template = string.Template(content) template = string.Template(content)
# Generate navigation URLs based on current mode
if current_order is not None:
# Timer mode: preserve current order and delay
next_url = "/{order}/{delay}/{next_hash}".format(
order=current_order,
delay=current_delay,
next_hash=navigation_data["next_hash"],
)
prev_url = "/{order}/{delay}/{prev_hash}".format(
order=current_order,
delay=current_delay,
prev_hash=navigation_data["prev_hash"],
)
else:
# Browse mode: generate browse mode URLs
next_url = "/{next_hash}".format(next_hash=navigation_data["next_hash"])
prev_url = "/{prev_hash}".format(prev_hash=navigation_data["prev_hash"])
content = template.substitute( content = template.substitute(
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]), img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
image_click_url=image_click_url image_click_url=image_click_url or _get_random_hash(),
or "/{next_random_hash}".format(next_random_hash=navigation_data["next_random_hash"]), next_url=next_url,
next_url="/{next_hash}".format(next_hash=navigation_data["next_hash"]), prev_url=prev_url,
prev_url="/{prev_hash}".format(prev_hash=navigation_data["prev_hash"]),
filename=navigation_data["filename"], filename=navigation_data["filename"],
extra_meta=extra_meta, extra_meta=extra_meta,
play_button=play_button, play_button=play_button,
@ -236,32 +282,36 @@ async def hash_page(file_hash: str, username: str = Depends(get_current_username
if file_hash not in file_mapping: if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
navigation_data = _get_navigation_data(file_hash) navigation_data = _get_navigation_data(file_hash, order=None)
play_button = '<a href="/next/5/{file_hash}" class="play-btn" title="Play next 5">⏵</a>'.format( play_button = '<a href="/next/5/{file_hash}" class="play-btn" title="Play next 5">⏵</a>'.format(
file_hash=file_hash file_hash=file_hash
) )
return _render_page(navigation_data, play_button=play_button) return _render_page(
navigation_data, play_button=play_button, current_order=None, current_delay=None
)
@app.get("/{order}/{delay}/{file_hash}") @app.get("/{order}/{delay}/{file_hash}")
async def hash_page_with_refresh(order: str, delay: int, file_hash: str, username: str = Depends(get_current_username)): async def hash_page_with_refresh(
order: str,
delay: int,
file_hash: str,
username: str = Depends(get_current_username),
):
"""Serve a page for a specific file hash with auto-refresh navigation""" """Serve a page for a specific file hash with auto-refresh navigation"""
if file_hash not in file_mapping: if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if order not in ("next", "random"): if order not in ("next", "random"):
raise HTTPException(status_code=400, detail="Invalid order. Must be 'next' or 'random'") raise HTTPException(
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
)
navigation_data = _get_navigation_data(file_hash) navigation_data = _get_navigation_data(file_hash, order=order)
if order == "next":
refresh_url = "/{order}/{delay}/{next_hash}".format( refresh_url = "/{order}/{delay}/{next_hash}".format(
order=order, delay=delay, next_hash=navigation_data["next_hash"] order=order, delay=delay, next_hash=navigation_data["next_hash"]
) )
else:
refresh_url = "/{order}/{delay}/{next_random_hash}".format(
order=order, delay=delay, next_random_hash=navigation_data["next_random_hash"]
)
refresh_meta = f'<meta http-equiv="refresh" content="{delay};url={refresh_url}">' refresh_meta = f'<meta http-equiv="refresh" content="{delay};url={refresh_url}">'
image_click_url = "/{file_hash}".format(file_hash=file_hash) image_click_url = "/{file_hash}".format(file_hash=file_hash)
@ -271,7 +321,14 @@ async def hash_page_with_refresh(order: str, delay: int, file_hash: str, usernam
file_hash=file_hash file_hash=file_hash
) )
return _render_page(navigation_data, refresh_meta, image_click_url, play_button=pause_button) return _render_page(
navigation_data,
refresh_meta,
image_click_url,
play_button=pause_button,
current_order=order,
current_delay=delay,
)
def _find_indexer_for_hash(file_hash: str): def _find_indexer_for_hash(file_hash: str):
@ -299,8 +356,12 @@ if __name__ == "__main__":
) )
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to") parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to bind to") parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
parser.add_argument("--salt", type=str, default=None, help="Salt for hashing file paths") parser.add_argument(
parser.add_argument("--password", type=str, default=None, help="Password for Basic Authentication") "--salt", type=str, default=None, help="Salt for hashing file paths"
)
parser.add_argument(
"--password", type=str, default=None, help="Password for Basic Authentication"
)
args = parser.parse_args() args = parser.parse_args()
initialize_server(args) initialize_server(args)

View File

@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"black>=26.3.1",
"fastapi>=0.128.0", "fastapi>=0.128.0",
"uvicorn>=0.40.0", "uvicorn>=0.40.0",
] ]

89
uv.lock generated
View File

@ -32,6 +32,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "black"
version = "26.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" },
{ url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" },
{ url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },
{ url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },
{ url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },
{ url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@ -73,6 +100,7 @@ name = "file-server"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "black" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@ -89,6 +117,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "black", specifier = ">=26.3.1" },
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" },
{ name = "uvicorn", specifier = ">=0.40.0" }, { name = "uvicorn", specifier = ">=0.40.0" },
@ -116,6 +145,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "pathspec"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@ -184,6 +249,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
] ]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.5" version = "0.15.5"