Compare commits

..

No commits in common. "9840804e40494ac7bad2dd0ab3cc4873f0bc1d0d" and "4c8f121f5829d846eaa37019c59ae6b2e9fc1d92" have entirely different histories.

6 changed files with 44 additions and 397 deletions

View File

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

192
README.md
View File

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

111
main.py
View File

@ -1,4 +1,3 @@
from typing import Annotated
import argparse
import hashlib
import mimetypes
@ -14,12 +13,7 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.responses import (
FileResponse,
HTMLResponse,
RedirectResponse,
StreamingResponse,
)
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse
app = FastAPI()
file_mapping = {}
@ -29,9 +23,7 @@ security = HTTPBasic()
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"""
if expected_password is not None and credentials.password != expected_password:
raise HTTPException(
@ -189,9 +181,7 @@ async def root(username: str = Depends(get_current_username)):
@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"""
random_hash = _get_random_hash()
return RedirectResponse(
@ -199,27 +189,13 @@ async def order_delay(
)
def _get_navigation_data(file_hash: str, order: str | None = None):
"""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.
"""
def _get_navigation_data(file_hash: str):
"""Get navigation data for a file hash"""
keys = list(file_mapping.keys())
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)]
prev_hash = keys[idx - 1] if idx > 0 else keys[-1]
next_random_hash = _get_random_hash()
indexer = _find_indexer_for_hash(file_hash)
filename = indexer.get_filename_by_hash(file_hash) if indexer else ""
@ -227,47 +203,25 @@ def _get_navigation_data(file_hash: str, order: str | None = None):
"file_hash": file_hash,
"next_hash": next_hash,
"prev_hash": prev_hash,
"next_random_hash": next_random_hash,
"filename": filename,
}
def _render_page(
navigation_data: dict,
extra_meta: str = "",
image_click_url: str = "",
play_button: str = "",
current_order: str | None = None,
current_delay: int | None = None,
navigation_data: dict, extra_meta: str = "", image_click_url: str = "", play_button: str = ""
) -> HTMLResponse:
"""Render the frontend page with navigation data"""
with open("frontend.html", "r") as f:
content = f.read()
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(
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
image_click_url=image_click_url or _get_random_hash(),
next_url=next_url,
prev_url=prev_url,
image_click_url=image_click_url
or "/{next_random_hash}".format(next_random_hash=navigation_data["next_random_hash"]),
next_url="/{next_hash}".format(next_hash=navigation_data["next_hash"]),
prev_url="/{prev_hash}".format(prev_hash=navigation_data["prev_hash"]),
filename=navigation_data["filename"],
extra_meta=extra_meta,
play_button=play_button,
@ -282,36 +236,32 @@ async def hash_page(file_hash: str, username: str = Depends(get_current_username
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
navigation_data = _get_navigation_data(file_hash, order=None)
navigation_data = _get_navigation_data(file_hash)
play_button = '<a href="/next/5/{file_hash}" class="play-btn" title="Play next 5">⏵</a>'.format(
file_hash=file_hash
)
return _render_page(
navigation_data, play_button=play_button, current_order=None, current_delay=None
)
return _render_page(navigation_data, play_button=play_button)
@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"""
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
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, order=order)
navigation_data = _get_navigation_data(file_hash)
if order == "next":
refresh_url = "/{order}/{delay}/{next_hash}".format(
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}">'
image_click_url = "/{file_hash}".format(file_hash=file_hash)
@ -321,14 +271,7 @@ async def hash_page_with_refresh(
file_hash=file_hash
)
return _render_page(
navigation_data,
refresh_meta,
image_click_url,
play_button=pause_button,
current_order=order,
current_delay=delay,
)
return _render_page(navigation_data, refresh_meta, image_click_url, play_button=pause_button)
def _find_indexer_for_hash(file_hash: str):
@ -356,12 +299,8 @@ if __name__ == "__main__":
)
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(
"--salt", type=str, default=None, help="Salt for hashing file paths"
)
parser.add_argument(
"--password", type=str, default=None, help="Password for Basic Authentication"
)
parser.add_argument("--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()
initialize_server(args)

View File

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

89
uv.lock generated
View File

@ -32,33 +32,6 @@ 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" },
]
[[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]]
name = "click"
version = "8.3.1"
@ -100,7 +73,6 @@ name = "file-server"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "black" },
{ name = "fastapi" },
{ name = "uvicorn" },
]
@ -117,7 +89,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "black", specifier = ">=26.3.1" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" },
{ name = "uvicorn", specifier = ">=0.40.0" },
@ -145,42 +116,6 @@ 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" },
]
[[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]]
name = "pydantic"
version = "2.12.5"
@ -249,30 +184,6 @@ 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" },
]
[[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]]
name = "ruff"
version = "0.15.5"