Compare commits

..

No commits in common. "b57ca2c47ab0df49c8b2cfbc4cf6803d44e90c26" and "e11a45ab7093e45939957681c93479e280adce78" have entirely different histories.

6 changed files with 82 additions and 308 deletions

14
.vscode/launch.json vendored
View File

@ -1,14 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File with Arguments",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"args": "${command:pickArgs}"
}
]
}

View File

@ -27,7 +27,7 @@ python main.py /path/to/archive.zip
No test framework is currently configured. To add tests, install pytest: No test framework is currently configured. To add tests, install pytest:
```bash ```bash
uv add pytest pip install pytest
``` ```
Run all tests: Run all tests:
@ -47,7 +47,7 @@ No linting tool is currently configured. Recommended tools:
```bash ```bash
# Install ruff (fast linter/formatter) # Install ruff (fast linter/formatter)
uv add ruff pip install ruff
# Run linting # Run linting
ruff check . ruff check .
@ -59,11 +59,17 @@ ruff check --fix .
ruff format . ruff format .
``` ```
Alternatively, using pylint:
```bash
pip install pylint
pylint main.py
```
### Type Checking ### Type Checking
```bash ```bash
# Install mypy # Install mypy
uv add mypy pip install mypy
# Run type checking # Run type checking
mypy main.py mypy main.py

View File

@ -1,104 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>File Server</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #1a1a1a; }
#container {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
img { max-width: 100%; max-height: 100%; cursor: pointer; }
.chevron {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 48px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 20px;
user-select: none;
transition: color 0.2s;
text-decoration: none;
}
.chevron:hover { color: rgba(255, 255, 255, 0.9); }
.chevron.left { left: 10px; }
.chevron.right { right: 10px; }
</style>
</head>
<body>
<div id="container">
<a href="#" id="img-link"><img id="img" alt="Random image"></a>
<a href="#" class="chevron left" id="prev-btn">&#8249;</a>
<a href="#" class="chevron right" id="next-btn">&#8250;</a>
</div>
<script>
let currentData = null;
function loadImageSrc(hash) {
document.getElementById('img').src = '/' + hash + '/data';
document.getElementById('img-link').href = '#';
history.replaceState(null, '', '#' + hash);
}
async function loadInfo(hash) {
const response = await fetch('/' + hash);
if (!response.ok) {
loadRandom();
return;
}
currentData = await response.json();
loadImageSrc(currentData.img);
document.getElementById('img').title = currentData.filename || '';
document.getElementById('prev-btn').href = '#' + currentData.previous;
document.getElementById('next-btn').href = '#' + currentData.next;
}
async function loadRandom() {
const response = await fetch('/random?' + Date.now());
const data = await response.json();
await loadInfo(data.img);
}
window.addEventListener('hashchange', function() {
const hash = window.location.hash.slice(1);
if (hash) {
loadInfo(hash);
} else {
loadRandom();
}
});
window.addEventListener('hashchange', function() {
const hash = window.location.hash.slice(1);
if (hash) loadInfo(hash);
});
document.addEventListener('keydown', function(e) {
e.preventDefault();
if (e.code === 'Space') {
window.location.hash = '';
} else if (e.code === 'ArrowLeft') {
document.getElementById('prev-btn').click();
} else if (e.code === 'ArrowRight') {
document.getElementById('next-btn').click();
} else {
return;
}
});
const hash = window.location.hash.slice(1);
if (hash) {
loadInfo(hash);
} else {
loadRandom();
}
</script>
</body>
</html>

197
main.py
View File

@ -1,32 +1,31 @@
import argparse from io import BytesIO
import hashlib
import mimetypes
import os import os
import random import random
import sys
import hashlib
import argparse
import secrets import secrets
import zipfile import zipfile
from glob import glob
from io import BytesIO
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
import mimetypes
app = FastAPI() app = FastAPI()
file_mapping = {} indexer = None
indexers = []
class FileIndexer: class FileIndexer:
def __init__(self, path: str, salt: str | None = None): def __init__(self, path: str):
self.path = Path(path) self.path = Path(path)
self._salt = salt self.file_mapping = {}
self._file_mapping = self._index() self._salt = None
self._index()
@property @property
def salt(self) -> str: def salt(self) -> str:
"""Generate a random salt for hashing""" """Generate a random salt for hashing"""
if self._salt is None: if not self._salt:
self._salt = secrets.token_hex(16) self._salt = secrets.token_hex(16)
return self._salt return self._salt
@ -34,184 +33,132 @@ class FileIndexer:
"""Generate a salted hash of the file path""" """Generate a salted hash of the file path"""
return hashlib.sha256((filepath + self.salt).encode()).hexdigest() return hashlib.sha256((filepath + self.salt).encode()).hexdigest()
def _index(self) -> dict[str, str]: def _index(self):
"""Index all files in the directory""" """Index all files in the zip file"""
mapping = {}
for root, _, files in os.walk(self.path): for root, _, files in os.walk(self.path):
for file in files: for file in files:
filepath = os.path.join(root, file) filepath = os.path.join(root, file)
# Generate hash for the file path
file_hash = self._hash_path(filepath) file_hash = self._hash_path(filepath)
mapping[file_hash] = filepath # Store mapping
return mapping self.file_mapping[file_hash] = filepath
def get_file_by_hash(self, file_hash: str): def get_file_by_hash(self, file_hash: str):
"""Get file content by hash""" """Get file content by hash"""
if file_hash not in self._file_mapping: if file_hash not in self.file_mapping:
return None return None
file_path = self._file_mapping[file_hash] file_path = self.file_mapping[file_hash]
with open(file_path, "rb") as f: with open(file_path, 'rb') as f:
yield from f yield from f
def get_filename_by_hash(self, file_hash: str) -> str | None: def get_filename_by_hash(self, file_hash: str) -> str:
"""Get filename by hash""" """Get filename by hash"""
if file_hash not in self._file_mapping: if file_hash not in self.file_mapping:
return None return None
return self._file_mapping[file_hash] return self.file_mapping[file_hash]
class ZipFileIndexer(FileIndexer): class ZipFileIndexer(FileIndexer):
def _index(self) -> dict[str, str]: def _index(self):
"""Index all files in the zip file""" """Index all files in the zip file"""
mapping = {} with zipfile.ZipFile(self.path, 'r') as zip_file:
with zipfile.ZipFile(self.path, "r") as zip_file: self.file_mapping = {
for file_info in zip_file.infolist(): self._hash_path(file_info.filename): file_info
if not file_info.is_dir(): for file_info in zip_file.infolist()
file_hash = self._hash_path(file_info.filename) if not file_info.is_dir()
mapping[file_hash] = file_info.filename }
return mapping
def get_file_by_hash(self, file_hash: str): def get_file_by_hash(self, file_hash: str):
"""Get file content by hash""" """Get file content by hash"""
if file_hash not in self._file_mapping: if file_hash not in self.file_mapping:
return None return None
filename = self._file_mapping[file_hash] file_info = self.file_mapping[file_hash]
with zipfile.ZipFile(self.path, "r") as zip_file: with zipfile.ZipFile(self.path, 'r') as zip_file:
yield from BytesIO(zip_file.read(filename)) yield from BytesIO(zip_file.read(file_info.filename))
def get_filename_by_hash(self, file_hash: str) -> str | None: def get_filename_by_hash(self, file_hash: str) -> str:
"""Get filename by hash""" """Get filename by hash"""
if file_hash not in self._file_mapping: if file_hash not in self.file_mapping:
return None return None
return self._file_mapping[file_hash] return self.file_mapping[file_hash].filename
INDEXER_MAP = {
INDEXER_MAP = {".zip": ZipFileIndexer} ".zip": ZipFileIndexer
}
def initialize_server(args: argparse.Namespace): def initialize_server(args: argparse.Namespace):
"""Initialize the server with directory or glob indexing""" """Initialize the server with directory indexing"""
global file_mapping, indexers global indexer
src_path = Path(args.source) src_path = Path(args.source)
if not src_path.exists():
shared_salt = args.salt raise SystemExit(f"Source path {src_path} does not exist")
if shared_salt is None:
shared_salt = secrets.token_hex(16)
if src_path.is_dir(): if src_path.is_dir():
indexer = FileIndexer(str(src_path), shared_salt) indexer = FileIndexer(src_path)
indexers.append(indexer)
file_mapping.update(indexer._file_mapping)
else: else:
pattern = args.source indexer = INDEXER_MAP[src_path.suffix](src_path)
matching_files = glob(pattern)
if not matching_files:
raise SystemExit(f"No files match pattern {pattern}")
for file_path in matching_files:
file_ext = Path(file_path).suffix
if file_ext in INDEXER_MAP:
indexer = INDEXER_MAP[file_ext](file_path, shared_salt)
indexers.append(indexer)
file_mapping.update(indexer._file_mapping)
print(f"Indexed {len(file_mapping)} files from {len(indexers)} source(s)")
print(f"Indexed {len(indexer.file_mapping)} files")
@app.get("/") @app.get("/")
async def root():
"""Serve the Frontend app"""
return FileResponse("frontend.html")
@app.get("/random")
async def get_random_file(): async def get_random_file():
"""Get random file hashes from the mapping""" """Serve a random file from the mapping"""
if not file_mapping: if not indexer.file_mapping:
raise HTTPException(status_code=404, detail="No files indexed") raise HTTPException(status_code=404, detail="No files indexed")
keys = list(file_mapping.keys()) random_hash = random.choice(list(indexer.file_mapping.keys()))
random_idx = random.randint(0, len(keys) - 1)
current = keys[random_idx]
next_hash = keys[(random_idx + 1) % len(keys)]
prev_hash = keys[random_idx - 1] if random_idx > 0 else keys[-1]
return {"img": current, "next": next_hash, "previous": prev_hash}
filename = indexer.get_filename_by_hash(random_hash)
def _find_indexer_for_hash(file_hash: str): content_type, _ = mimetypes.guess_type(filename)
"""Find the indexer that contains the file with the given hash"""
for idx in indexers:
if file_hash in idx._file_mapping:
return idx
return None
@app.get("/{file_hash}/data")
async def get_file_data(file_hash: str):
"""Serve a specific file by its hash"""
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
indexer = _find_indexer_for_hash(file_hash)
if not indexer:
raise HTTPException(status_code=404, detail="File not found")
filename = indexer.get_filename_by_hash(file_hash)
content_type, _ = mimetypes.guess_type(filename or "")
if not content_type: if not content_type:
content_type = "application/octet-stream" content_type = "application/octet-stream"
buffer = indexer.get_file_by_hash(random_hash)
return StreamingResponse( response = StreamingResponse(
indexer.get_file_by_hash(file_hash), buffer,
media_type=content_type, media_type=content_type,
headers={ headers={
"Content-Disposition": f"inline; filename={os.path.basename(filename or '')}", "Content-Disposition": f"inline; filename={os.path.basename(filename)}",
}, "Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
) )
return response
@app.get("/{file_hash}") @app.get("/{file_hash}")
async def get_file_info(file_hash: str): async def get_file_by_hash(file_hash: str):
"""Get file info by hash""" """Serve a specific file by its hash"""
if file_hash not in file_mapping: if file_hash not in indexer.file_mapping:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
keys = list(file_mapping.keys()) file_path, content_type, buffer = indexer.get_file_by_hash(file_hash)
idx = keys.index(file_hash)
next_hash = keys[(idx + 1) % len(keys)]
prev_hash = keys[idx - 1] if idx > 0 else keys[-1]
indexer = _find_indexer_for_hash(file_hash)
if not indexer:
raise HTTPException(status_code=404, detail="File not found")
filename = indexer.get_filename_by_hash(file_hash)
return {"img": file_hash, "next": next_hash, "previous": prev_hash, "filename": filename}
return StreamingResponse(
buffer,
media_type=content_type,
headers={
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}",
}
)
# Optional: Add a health check endpoint # Optional: Add a health check endpoint
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
return {"status": "healthy", "file_count": len(file_mapping)} return {"status": "healthy", "file_count": len(file_mapping)}
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the file server") parser = argparse.ArgumentParser(description="Run the file server")
parser.add_argument( parser.add_argument("source", type=str, help="Path to directory or ZIP archive")
"source",
type=str,
help="Path to directory, ZIP archive, or glob pattern (e.g., *.zip, path/to/zips/*.zip)",
)
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")
args = parser.parse_args() args = parser.parse_args()
initialize_server(args) initialize_server(args)
import uvicorn import uvicorn
uvicorn.run(app, host=args.host, port=args.port) uvicorn.run(app, host=args.host, port=args.port)

View File

@ -8,24 +8,3 @@ dependencies = [
"fastapi>=0.128.0", "fastapi>=0.128.0",
"uvicorn>=0.40.0", "uvicorn>=0.40.0",
] ]
[project.optional-dependencies]
dev = [
"ruff>=0.15.5",
]
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
ignore = []
[tool.ruff.format]
quote-style = "double"
[dependency-groups]
dev = [
"ruff>=0.15.5",
]

42
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@ -77,26 +77,11 @@ dependencies = [
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
[package.optional-dependencies]
dev = [
{ name = "ruff" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" },
{ name = "uvicorn", specifier = ">=0.40.0" }, { name = "uvicorn", specifier = ">=0.40.0" },
] ]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.5" }]
[[package]] [[package]]
name = "h11" name = "h11"
@ -184,31 +169,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" }, { 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 = "ruff"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.50.0" version = "0.50.0"