Compare commits

..

10 Commits

6 changed files with 309 additions and 83 deletions

14
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"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
pip install pytest uv add 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)
pip install ruff uv add ruff
# Run linting # Run linting
ruff check . ruff check .
@ -59,17 +59,11 @@ 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
pip install mypy uv add mypy
# Run type checking # Run type checking
mypy main.py mypy main.py

104
frontend.html Normal file
View File

@ -0,0 +1,104 @@
<!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>

199
main.py
View File

@ -1,31 +1,32 @@
from io import BytesIO import argparse
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()
indexer = None file_mapping = {}
indexers = []
class FileIndexer: class FileIndexer:
def __init__(self, path: str): def __init__(self, path: str, salt: str | None = None):
self.path = Path(path) self.path = Path(path)
self.file_mapping = {} self._salt = salt
self._salt = None self._file_mapping = self._index()
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 not self._salt: if self._salt is None:
self._salt = secrets.token_hex(16) self._salt = secrets.token_hex(16)
return self._salt return self._salt
@ -33,132 +34,184 @@ 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): def _index(self) -> dict[str, str]:
"""Index all files in the zip file""" """Index all files in the directory"""
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)
# Store mapping mapping[file_hash] = filepath
self.file_mapping[file_hash] = filepath 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
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: def get_filename_by_hash(self, file_hash: str) -> str | None:
"""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): def _index(self) -> dict[str, str]:
"""Index all files in the zip file""" """Index all files in the zip file"""
with zipfile.ZipFile(self.path, 'r') as zip_file: mapping = {}
self.file_mapping = { with zipfile.ZipFile(self.path, "r") as zip_file:
self._hash_path(file_info.filename): file_info for file_info in zip_file.infolist():
for file_info in zip_file.infolist() if not file_info.is_dir():
if not file_info.is_dir() file_hash = self._hash_path(file_info.filename)
} 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
file_info = self.file_mapping[file_hash] filename = 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(file_info.filename)) yield from BytesIO(zip_file.read(filename))
def get_filename_by_hash(self, file_hash: str) -> str: def get_filename_by_hash(self, file_hash: str) -> str | None:
"""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].filename return self._file_mapping[file_hash]
INDEXER_MAP = {
".zip": ZipFileIndexer INDEXER_MAP = {".zip": ZipFileIndexer}
}
def initialize_server(args: argparse.Namespace): def initialize_server(args: argparse.Namespace):
"""Initialize the server with directory indexing""" """Initialize the server with directory or glob indexing"""
global indexer global file_mapping, indexers
src_path = Path(args.source) src_path = Path(args.source)
if not src_path.exists():
raise SystemExit(f"Source path {src_path} does not exist") shared_salt = args.salt
if shared_salt is None:
shared_salt = secrets.token_hex(16)
if src_path.is_dir(): if src_path.is_dir():
indexer = FileIndexer(src_path) indexer = FileIndexer(str(src_path), shared_salt)
indexers.append(indexer)
file_mapping.update(indexer._file_mapping)
else: else:
indexer = INDEXER_MAP[src_path.suffix](src_path) pattern = args.source
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():
"""Serve a random file from the mapping""" """Get random file hashes from the mapping"""
if not indexer.file_mapping: if not file_mapping:
raise HTTPException(status_code=404, detail="No files indexed") raise HTTPException(status_code=404, detail="No files indexed")
random_hash = random.choice(list(indexer.file_mapping.keys())) keys = list(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)
content_type, _ = mimetypes.guess_type(filename)
if not content_type:
content_type = "application/octet-stream"
buffer = indexer.get_file_by_hash(random_hash)
response = StreamingResponse(
buffer,
media_type=content_type,
headers={
"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}") def _find_indexer_for_hash(file_hash: str):
async def get_file_by_hash(file_hash: str): """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""" """Serve a specific file by its hash"""
if file_hash not in indexer.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")
file_path, content_type, buffer = indexer.get_file_by_hash(file_hash) 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:
content_type = "application/octet-stream"
return StreamingResponse( return StreamingResponse(
buffer, indexer.get_file_by_hash(file_hash),
media_type=content_type, media_type=content_type,
headers={ headers={
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}", "Content-Disposition": f"inline; filename={os.path.basename(filename or '')}",
} },
) )
@app.get("/{file_hash}")
async def get_file_info(file_hash: str):
"""Get file info by hash"""
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
keys = list(file_mapping.keys())
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}
# 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("source", type=str, help="Path to directory or ZIP archive") parser.add_argument(
"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,3 +8,24 @@ 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 = 2 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@ -77,11 +77,26 @@ 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"
@ -169,6 +184,31 @@ 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"