feat(files): cache served files and add HTML text viewer
Build and Push Docker Container / build-and-push (push) Successful in 2m37s

File cache:
- Add my_modules/filecache with metadata, blob, validator and store layers
- Cache convex.get_file via AsyncCache, TTL clamped to the file expiry
- Skip caching for missing, incomplete or already expired records
- Add a process-local, size-bounded LRU for immutable storage blobs
- Build the cache stack from FILE_CACHE_* env vars in build_file_cache
- Attach the cache to the app in my_modules/app/setup.py
- Expose app-bound helpers in filecache/access.py for routes

Serving:
- Send strong ETag, Last-Modified and Cache-Control on served files
- Answer If-None-Match and If-Modified-Since with 304 from metadata only
- Avoid pulling the blob from Convex on a revalidation hit
- Add a short Cloudflare-CDN-Cache-Control edge window for deleted files
- Record cache_status in the wide event on a 304
- Invalidate cached metadata after file update and delete in both routes

Text viewer:
- Add my_modules/textview to render text/plain and text/markdown as HTML
- Linkify http(s) URLs and trim trailing sentence punctuation
- Resolve links pointing at this instance to the referenced file
- Inline image previews, mark missing and expired targets
- Cap resolution at MAX_LOOKUPS backend lookups per document
- Serve the viewer only for real browser navigations, never for curl or CLI
- Keep raw bytes for ?raw=1, downloads and Accept headers without text/html
- Add templates/side/views/webpage/files/text_view.htm with a raw link

Other:
- Add tests for cache layers, serving, text rendering and the route
- Bump multidict to 7.2.1 in uv.lock
- Update the my_helpers submodule pointer
This commit is contained in:
2026-10-25 18:54:46 +01:00
parent f6a70b5ced
commit 3db374b1ab
21 changed files with 1692 additions and 97 deletions
+3
View File
@@ -8,6 +8,7 @@ from my_modules.functions import (
from my_modules.app.constens import SECRET_KEY, THE_IP_BOT_MANAGER
from my_modules.OrphanStorageIdRegistry import OrphanStorageIdRegistry
from my_modules.AsyncCache import AsyncCache
from my_modules.filecache import build_file_cache
from my_modules.app.logger import logger
from quart_common.web.request import TRUST_PROXY_HEADERS, TRUSTED_PROXY_IPS, TRUSTED_PROXY_CIDRS
from quart_common.web.wide_event import register_wide_event_logging
@@ -104,6 +105,8 @@ app.orphan_storage_registry = OrphanStorageIdRegistry(
redis_client=orphan_redis,
)
app.file_cache = build_file_cache(cache)
@app.before_serving
async def init_convex():
await logger.info(
+27
View File
@@ -0,0 +1,27 @@
from .blobs import BlobCache
from .metadata import FileMetadataCache
# access.py is imported directly by routes: it needs a Quart app context.
from .store import FileCache, build_file_cache
from .validators import (
build_etag,
cache_control,
edge_cache_control,
etag_matches,
is_fresh,
not_modified_since,
remaining_seconds,
)
__all__ = [
'BlobCache',
'FileCache',
'FileMetadataCache',
'build_etag',
'build_file_cache',
'cache_control',
'edge_cache_control',
'etag_matches',
'is_fresh',
'not_modified_since',
'remaining_seconds',
]
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from quart import current_app
DEFAULT_BROWSER_MAX_AGE = 86400
DEFAULT_EDGE_MAX_AGE = 1800
def file_cache():
"""The app's FileCache, or None when caching is not configured."""
return getattr(current_app, 'file_cache', None)
def browser_max_age() -> int:
cache = file_cache()
return DEFAULT_BROWSER_MAX_AGE if cache is None else cache.blob_max_age
def edge_max_age() -> int:
cache = file_cache()
return DEFAULT_EDGE_MAX_AGE if cache is None else cache.edge_max_age
async def load_file(file_id: str):
async def loader():
return await current_app.convex.get_file(file_id=file_id)
cache = file_cache()
if cache is None:
return await loader()
return await cache.get_metadata(file_id, loader)
async def load_blob(storage_key: str) -> bytes:
async def loader() -> bytes:
payload = await current_app.convex.get_from_storage(storage_key)
return payload.getvalue() if hasattr(payload, 'getvalue') else bytes(payload)
cache = file_cache()
if cache is None:
return await loader()
return await cache.get_blob(storage_key, loader)
async def drop_file_cache(file_id: str) -> None:
"""Invalidate metadata after a mutation; blobs are immutable and expire on TTL."""
cache = file_cache()
if cache is not None:
await cache.invalidate(file_id)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import time
from collections import OrderedDict
class BlobCache:
"""Process-local, size-bounded LRU for file bytes.
Deliberately not AsyncCache: that layer JSON-encodes values (binary would need
base64) and its memory backend evicts only on read, so blobs could grow without
bound. Entries are immutable storage blobs, so a stale read is impossible.
"""
def __init__(self, max_total_bytes: int, max_item_bytes: int, ttl: int = 300):
self._max_total = max(0, int(max_total_bytes))
self._max_item = max(0, int(max_item_bytes))
self._ttl = max(1, int(ttl))
self._entries: OrderedDict[str, tuple[bytes, float]] = OrderedDict()
self._total = 0
self.hits = 0
self.misses = 0
@property
def enabled(self) -> bool:
return self._max_total > 0 and self._max_item > 0
@property
def current_bytes(self) -> int:
return self._total
def _drop(self, key: str) -> None:
payload = self._entries.pop(key, None)
if payload is not None:
self._total -= len(payload[0])
def get(self, key: str, now: float | None = None) -> bytes | None:
entry = self._entries.get(key)
if entry is None:
self.misses += 1
return None
data, expires_at = entry
if (now if now is not None else time.monotonic()) >= expires_at:
self._drop(key)
self.misses += 1
return None
self._entries.move_to_end(key)
self.hits += 1
return data
def set(self, key: str, data: bytes, now: float | None = None) -> bool:
if not self.enabled or len(data) > self._max_item:
return False
self._drop(key)
self._entries[key] = (data, (now if now is not None else time.monotonic()) + self._ttl)
self._total += len(data)
while self._total > self._max_total and self._entries:
oldest = next(iter(self._entries))
self._drop(oldest)
return True
def invalidate(self, key: str) -> None:
self._drop(key)
def clear(self) -> None:
self._entries.clear()
self._total = 0
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from .validators import remaining_seconds
class FileMetadataCache:
"""Cache-aside layer for convex.get_file, shared across workers via AsyncCache.
Only complete, non-expired records are cached. Misses and expired markers stay
uncached so a newly uploaded or just-expired file is never masked.
"""
def __init__(self, cache, ttl: int = 60, prefix: str = 'nanoshare:filemeta:'):
self._cache = cache
self._ttl = max(1, int(ttl))
self._prefix = prefix
self.hits = 0
self.misses = 0
def _key(self, file_id: str) -> str:
return f'{self._prefix}{file_id}'
def _effective_ttl(self, file_data: dict) -> int:
"""Never hold a record past the moment it expires."""
remaining = remaining_seconds(file_data.get('expires_at'))
if remaining is None:
return self._ttl
return min(self._ttl, remaining)
def _is_cacheable(self, file_data) -> bool:
if not isinstance(file_data, dict) or not file_data:
return False
if file_data.get('expired'):
return False
return bool(file_data.get('db_image_url'))
async def get(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]) -> dict | None:
cached = await self._cache.get(self._key(file_id))
if isinstance(cached, dict) and cached:
self.hits += 1
return cached
self.misses += 1
file_data = await loader()
if self._is_cacheable(file_data):
ttl = self._effective_ttl(file_data)
if ttl > 0:
await self._cache.set(self._key(file_id), file_data, ttl=ttl)
return file_data
async def invalidate(self, file_id: str) -> None:
await self._cache.delete(self._key(file_id))
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
from collections.abc import Awaitable, Callable
from .blobs import BlobCache
from .metadata import FileMetadataCache
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
class FileCache:
"""Facade over the metadata cache (shared) and the blob cache (process-local)."""
def __init__(self, metadata: FileMetadataCache, blobs: BlobCache, blob_max_age: int, edge_max_age: int):
self.metadata = metadata
self.blobs = blobs
self.blob_max_age = blob_max_age
self.edge_max_age = edge_max_age
async def get_metadata(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]):
return await self.metadata.get(file_id, loader)
async def get_blob(self, storage_key: str, loader: Callable[[], Awaitable[bytes]]) -> bytes:
cached = self.blobs.get(storage_key)
if cached is not None:
return cached
data = await loader()
self.blobs.set(storage_key, data)
return data
async def invalidate(self, file_id: str, storage_key: str | None = None) -> None:
await self.metadata.invalidate(file_id)
if storage_key:
self.blobs.invalidate(storage_key)
def stats(self) -> dict:
return {
'metadata_hits': self.metadata.hits,
'metadata_misses': self.metadata.misses,
'blob_hits': self.blobs.hits,
'blob_misses': self.blobs.misses,
'blob_bytes': self.blobs.current_bytes,
}
def build_file_cache(cache) -> FileCache:
"""Build the cache stack from FILE_CACHE_* environment settings."""
return FileCache(
metadata=FileMetadataCache(cache, ttl=_env_int('FILE_CACHE_METADATA_TTL', 60)),
blobs=BlobCache(
max_total_bytes=_env_int('FILE_CACHE_BLOB_TOTAL_BYTES', 64 * 1024 * 1024),
max_item_bytes=_env_int('FILE_CACHE_BLOB_ITEM_BYTES', 2 * 1024 * 1024),
ttl=_env_int('FILE_CACHE_BLOB_TTL', 300),
),
blob_max_age=_env_int('FILE_CACHE_BROWSER_MAX_AGE', 86400),
# Cloudflare keeps serving deleted files, so the edge window stays short.
edge_max_age=_env_int('FILE_CACHE_EDGE_MAX_AGE', 1800),
)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import hashlib
import time
from email.utils import parsedate_to_datetime
def build_etag(file_id: str, file_data: dict, download: bool = False) -> str:
"""Strong validator for a stored file.
The blob behind a file_id never changes (updateFile only patches metadata),
so uploaded_at plus the storage id identify the bytes. The download flag is
part of the tag because it changes Content-Disposition.
"""
parts = [
str(file_id),
str(file_data.get('uploaded_at', '')),
str(file_data.get('db_image_url', '')),
str(file_data.get('file_name', '')),
'attachment' if download else 'inline',
]
digest = hashlib.sha256('\x1f'.join(parts).encode('utf-8')).hexdigest()[:32]
return f'"{digest}"'
def _split_etags(header: str) -> list[str]:
return [value.strip() for value in header.split(',') if value.strip()]
def etag_matches(if_none_match: str | None, etag: str) -> bool:
"""RFC 9110 If-None-Match: '*' matches, weak comparison otherwise."""
if not if_none_match or not etag:
return False
header = if_none_match.strip()
if header == '*':
return True
def normalize(value: str) -> str:
return value.removeprefix('W/')
target = normalize(etag)
return any(normalize(candidate) == target for candidate in _split_etags(header))
def _parse_http_date(value: str):
try:
return parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
def not_modified_since(if_modified_since: str | None, last_modified_epoch: float | None) -> bool:
if not if_modified_since or last_modified_epoch is None:
return False
parsed = _parse_http_date(if_modified_since)
if parsed is None:
return False
# HTTP dates have second precision, so compare truncated values.
return int(last_modified_epoch) <= int(parsed.timestamp())
def is_fresh(
if_none_match: str | None,
if_modified_since: str | None,
etag: str,
last_modified_epoch: float | None,
) -> bool:
"""Decide a 304 from metadata alone, before any blob is fetched."""
if if_none_match is not None:
# An ETag was offered, so it alone decides and the date is ignored.
return etag_matches(if_none_match, etag)
return not_modified_since(if_modified_since, last_modified_epoch)
def remaining_seconds(expires_at, now_ms: float | None = None) -> int | None:
"""Seconds until expires_at (epoch ms), or None when the file never expires."""
if expires_at in (None, '', 0):
return None
try:
expires_ms = float(expires_at)
except (TypeError, ValueError):
return None
if now_ms is None:
now_ms = time.time() * 1000
return int((expires_ms - now_ms) / 1000)
def _clamped_max_age(expires_at, max_age: int) -> int:
if max_age <= 0:
return 0
remaining = remaining_seconds(expires_at)
if remaining is None:
return max_age
return min(max_age, max(0, remaining))
def cache_control(expires_at, max_age: int) -> str:
"""Browser caching for immutable blobs, clamped to the remaining lifetime."""
clamped = _clamped_max_age(expires_at, max_age)
return f'public, max-age={clamped}' if clamped > 0 else 'no-cache'
def edge_cache_control(expires_at, max_age: int) -> str:
"""Separate, deliberately short TTL for Cloudflare.
Shared caches keep serving a file after it was deleted or expired at the
origin, so the edge gets a much smaller window than the browser does.
"""
clamped = _clamped_max_age(expires_at, max_age)
return f'public, max-age={clamped}' if clamped > 0 else 'no-store'
+14
View File
@@ -0,0 +1,14 @@
from .linkify import tokenize
from .own_links import parse_share_link
from .render import MAX_LOOKUPS, build_segments
from .view import build_view_context, decode_text, wants_text_view
__all__ = [
'MAX_LOOKUPS',
'build_segments',
'build_view_context',
'decode_text',
'parse_share_link',
'tokenize',
'wants_text_view',
]
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import re
# Only http(s) is linkified; everything else stays inert plain text.
_URL_PATTERN = re.compile(r'https?://[^\s<>"\'`]+', re.IGNORECASE)
# Prose punctuation that is almost never part of the URL itself.
_TRAILING_PUNCTUATION = '.,;:!?\'"'
_BRACKET_PAIRS = {')': '(', ']': '[', '}': '{'}
def _trim_trailing(url: str) -> str:
"""Give punctuation that belongs to the surrounding sentence back to the text."""
while url:
last = url[-1]
if last in _TRAILING_PUNCTUATION:
url = url[:-1]
continue
opening = _BRACKET_PAIRS.get(last)
if opening is not None and url.count(opening) < url.count(last):
url = url[:-1]
continue
break
return url
def tokenize(text: str) -> list[tuple[str, str]]:
"""Split text into ('text', value) and ('url', value) tokens, order preserved."""
tokens: list[tuple[str, str]] = []
cursor = 0
for match in _URL_PATTERN.finditer(text):
url = _trim_trailing(match.group(0))
if not url:
continue
start = match.start()
end = start + len(url)
if start > cursor:
tokens.append(('text', text[cursor:start]))
tokens.append(('url', url))
cursor = end
if cursor < len(text):
tokens.append(('text', text[cursor:]))
return tokens
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from urllib.parse import urlsplit
_ALLOWED_SCHEMES = {'http', 'https'}
def _normalize_host(host: str) -> str:
return (host or '').split(',')[0].strip().lower().rstrip('.')
def parse_share_link(url: str, host: str, root_path: str = '') -> str | None:
"""Return the file_id when url points at this instance's /-<file_id> route."""
try:
parts = urlsplit(url)
except ValueError:
return None
if parts.scheme.lower() not in _ALLOWED_SCHEMES:
return None
if not parts.netloc or _normalize_host(parts.netloc) != _normalize_host(host):
return None
prefix = (root_path or '').rstrip('/')
path = parts.path
if prefix:
if not path.startswith(prefix + '/'):
return None
path = path[len(prefix):]
if not path.startswith('/-'):
return None
file_id = path[2:]
if not file_id or '/' in file_id:
return None
return file_id
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from .linkify import tokenize
from .own_links import parse_share_link
# Each resolved link costs one backend lookup, so a single document stays bounded.
MAX_LOOKUPS = 20
def _media_kind(content_type: str) -> str:
value = (content_type or '').split(';')[0].strip().lower()
for prefix in ('image', 'video', 'audio'):
if value.startswith(prefix + '/'):
return prefix
if 'pdf' in value:
return 'pdf'
return 'other'
async def build_segments(
text: str,
host: str,
root_path: str,
lookup: Callable[[str], Awaitable[dict | None]] | None = None,
max_lookups: int = MAX_LOOKUPS,
) -> list[dict]:
"""Turn raw text into template segments: plain text, links and resolved own files."""
segments: list[dict] = []
resolved: dict[str, dict | None] = {}
for kind, value in tokenize(text):
if kind == 'text':
segments.append({'kind': 'text', 'text': value})
continue
file_id = parse_share_link(value, host=host, root_path=root_path) if lookup else None
if file_id is None:
segments.append({'kind': 'link', 'url': value})
continue
if file_id not in resolved:
if len(resolved) >= max_lookups:
segments.append({'kind': 'link', 'url': value})
continue
resolved[file_id] = await lookup(file_id)
file_data = resolved[file_id]
if not file_data:
segments.append({'kind': 'link', 'url': value, 'state': 'missing'})
continue
if file_data.get('expired'):
segments.append({
'kind': 'link',
'url': value,
'state': 'expired',
'label': file_data.get('file_name') or file_id,
})
continue
segments.append({
'kind': 'file',
'url': value,
'state': 'ok',
'file_id': file_id,
'label': file_data.get('file_name') or file_id,
'media': _media_kind(file_data.get('content_type') or ''),
})
return segments
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from .render import build_segments
# Text types that get the rendered viewer; anything else is served untouched.
VIEWABLE_CONTENT_TYPES = {'text/plain', 'text/markdown'}
_TRUTHY = {'1', 'true', 'yes'}
def _base_content_type(content_type: str) -> str:
return (content_type or '').split(';')[0].strip().lower()
def wants_text_view(content_type: str, accept_header: str, raw_param: str | None, force_download: bool) -> bool:
"""Only real browser navigations get HTML; curl, wget and the CLI keep the raw bytes."""
if force_download or (raw_param or '').lower() in _TRUTHY:
return False
if _base_content_type(content_type) not in VIEWABLE_CONTENT_TYPES:
return False
# Accept: */* (curl, wget, CLI clients) must not trigger the viewer.
return 'text/html' in (accept_header or '').lower()
def decode_text(raw: bytes) -> str:
"""Decode the full document; invalid bytes become replacement characters."""
return raw.decode('utf-8', errors='replace')
async def build_view_context(
raw: bytes,
file_name: str,
host: str,
root_path: str,
raw_url: str,
lookup: Callable[[str], Awaitable[dict | None]] | None,
) -> dict:
text = decode_text(raw)
segments = await build_segments(text, host=host, root_path=root_path, lookup=lookup)
return {
'file_name': file_name,
'segments': segments,
'raw_url': raw_url,
}
+3
View File
@@ -19,6 +19,7 @@ from quart import current_app
from my_modules.app.setup import LIMITER
from my_modules.expiry import ensure_utc, parse_expires
from my_modules.file_meta import format_size, iso_stamp_filename
from my_modules.filecache.access import drop_file_cache
from servicelink import Forbidden, InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
MAX_RPC_BODY = 16 * 1024 * 1024
@@ -109,6 +110,7 @@ async def files_update(params, ctx):
user_id=_user_id(ctx),
preserve_missing=True,
)
await drop_file_cache(file_id)
return {'updated': True}
@router.method('files.delete')
@@ -117,6 +119,7 @@ async def files_delete(params, ctx):
if not file_id:
raise InvalidParams('file_id is required')
await current_app.convex.delete_file(file_id, _user_id(ctx))
await drop_file_cache(file_id)
return {'deleted': True}
async def _decode_access_token(token):
+69 -7
View File
@@ -1,8 +1,14 @@
from io import BytesIO
from wsgiref.handlers import format_date_time as http_date
from my_modules.decoratory.header import login_required
from my_modules.functions import get_ip
from my_modules.app.setup import LIMITER
from my_modules.app.logger import logger
from my_modules.expiry import parse_expires
from my_modules.filecache import build_etag, cache_control, edge_cache_control, is_fresh
from my_modules.filecache.access import browser_max_age, drop_file_cache, edge_max_age, load_blob, load_file
from my_modules.textview import build_view_context, wants_text_view
from quart_common.web.wide_event import add_wide_event_context
from quart import (
@@ -12,6 +18,7 @@ from quart import (
Response,
send_file,
render_template,
make_response,
abort,
current_app,
jsonify,
@@ -25,11 +32,33 @@ def find_file(files: list[dict], file_id: str):
return file_data
return None
def request_host() -> str:
return (request.headers.get("X-Forwarded-Host") or request.host).split(",")[0].strip()
def build_share_url(file_id: str) -> str:
scheme = (request.headers.get("X-Forwarded-Proto") or request.scheme or "http").split(",")[0].strip()
host = (request.headers.get("X-Forwarded-Host") or request.host).split(",")[0].strip()
root_path = request.root_path.rstrip("/")
return f"{scheme}://{host}{root_path}/-{file_id}"
return f"{scheme}://{request_host()}{root_path}/-{file_id}"
async def render_text_view(file_id: str, file_data: dict):
"""Render a pasted text file as HTML so links are clickable and own files resolve."""
root_path = request.root_path.rstrip("/")
raw = await load_blob(file_data.get("db_image_url"))
async def lookup(linked_file_id: str):
return await load_file(linked_file_id)
context = await build_view_context(
raw=raw,
file_name=file_data.get("file_name") or file_id,
host=request_host(),
root_path=root_path,
raw_url=f"{root_path}/-{file_id}?raw=1",
lookup=lookup,
)
response = await make_response(await render_template("views/webpage/files/text_view.htm", **context))
response.headers["Cache-Control"] = "private, no-store"
return response
@side_main_bp.route('/')
@LIMITER.limit("20 per minute;500 per hour;")
@@ -115,6 +144,7 @@ async def file_edit_api(file_id, user):
expires_at=expires_at,
user_id=user["sub"],
)
await drop_file_cache(file_id)
return jsonify({"ok": True})
@@ -127,6 +157,7 @@ async def file_delete_api(file_id, user):
return jsonify({"ok": False, "error": "File not found"}), 404
await current_app.convex.delete_file(file_id=file_id, user_id=user["sub"])
await drop_file_cache(file_id)
return jsonify({"ok": True})
@side_main_bp.route("/-<path:file_id>")
@@ -138,7 +169,7 @@ async def serve_file(file_id: str):
abort(404)
add_wide_event_context(nanoshare={"operation": "serve_file", "file_id": file_id})
file_data = await current_app.convex.get_file(file_id=file_id)
file_data = await load_file(file_id)
disable_logging = False
if not file_data:
@@ -173,12 +204,43 @@ async def serve_file(file_id: str):
if not disable_logging:
await current_app.convex.add_file_access(file_id=file_id, ip_address=get_ip(), user_agent=request.user_agent, status="ok")
return await send_file(
filename_or_io=await current_app.convex.get_from_storage(file_data.get('db_image_url')),
if wants_text_view(
content_type=content_type,
accept_header=request.headers.get("Accept", ""),
raw_param=request.args.get("raw"),
force_download=force_download,
):
return await render_text_view(file_id=file_id, file_data=file_data)
last_modified = int(file_data["uploaded_at"]) / 1000
etag = build_etag(file_id, file_data, download=force_download)
expires_at = file_data.get("expires_at")
freshness = {
"ETag": etag,
"Last-Modified": http_date(last_modified),
"Cache-Control": cache_control(expires_at, browser_max_age()),
# Cloudflare-only, not proxied downstream: keeps the edge window short so a
# deleted or expired file stops being served long before the browser TTL ends.
"Cloudflare-CDN-Cache-Control": edge_cache_control(expires_at, edge_max_age()),
}
# Answered from metadata only, so a revalidation never pulls the blob from Convex.
if is_fresh(
request.headers.get("If-None-Match"),
request.headers.get("If-Modified-Since"),
etag,
last_modified,
):
add_wide_event_context(nanoshare={"cache_status": "not_modified"})
return Response(status=304, headers=freshness)
response = await send_file(
filename_or_io=BytesIO(await load_blob(file_data["db_image_url"])),
mimetype=content_type,
as_attachment=force_download,
attachment_filename=file_name,
conditional=True,
cache_timeout=60,
last_modified=int(file_data['uploaded_at']) / 1000
last_modified=last_modified,
)
response.headers.update(freshness)
return response
@@ -0,0 +1,57 @@
{% extends "base.htm" %}
{% block title %}NanoShare - {{ file_name }}{% endblock %}
{% block meta %}
<meta name="robots" content="noindex, nofollow" />
{% endblock %}
{% block head %}
<style>
.text-view-page { padding: clamp(16px, 3vw, 28px); }
.text-view-head { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; justify-content: space-between; }
.text-view-body {
margin: 16px 0 0;
padding: clamp(12px, 2vw, 18px);
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(0, 0, 0, .16);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: .95rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.text-view-body a { color: var(--accent, #6cf); }
.text-view-ref { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
.text-view-ref img {
max-height: 120px;
max-width: 180px;
border-radius: 6px;
border: 1px solid var(--border);
vertical-align: middle;
}
.text-view-state { color: var(--muted); font-size: .85rem; }
</style>
{% endblock %}
{% block content %}
<main class="text-view-page">
<section class="card" style="padding: clamp(18px, 2.6vw, 28px);">
<div class="text-view-head">
<h1 class="page-title" style="margin:0;">{{ file_name }}</h1>
<a class="btn btn-ghost" href="{{ raw_url }}">View raw</a>
</div>
<div class="text-view-body">{% for segment in segments -%}
{%- if segment.kind == 'text' -%}
{{ segment.text }}
{%- elif segment.kind == 'file' -%}
<span class="text-view-ref"><a href="{{ segment.url }}" rel="noopener noreferrer">{{ segment.label }}</a>{% if segment.media == 'image' %} <a href="{{ segment.url }}" rel="noopener noreferrer"><img src="{{ segment.url }}" alt="{{ segment.label }}" loading="lazy"></a>{% endif %}</span>
{%- else -%}
<a href="{{ segment.url }}" rel="noopener noreferrer" target="_blank">{{ segment.label or segment.url }}</a>{% if segment.state == 'expired' %} <span class="text-view-state">(expired)</span>{% endif %}
{%- endif -%}
{%- endfor %}</div>
</section>
</main>
{% endblock %}
+277
View File
@@ -0,0 +1,277 @@
import asyncio
import time
import pytest
from my_modules.AsyncCache import AsyncCache
from my_modules.filecache import BlobCache, FileMetadataCache
from my_modules.filecache.store import build_file_cache
from my_modules.filecache.validators import (
build_etag,
cache_control,
edge_cache_control,
etag_matches,
is_fresh,
not_modified_since,
remaining_seconds,
)
FILE = {
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'http://convex.internal/api/storage/abc',
'uploaded_at': 1700000000000,
'expires_at': '',
'user_id': 'owner',
}
def run(coro):
return asyncio.run(coro)
# --- ETag ---------------------------------------------------------------------
def test_etag_is_stable_for_the_same_file():
assert build_etag('abc', FILE) == build_etag('abc', dict(FILE))
def test_etag_differs_per_file_id():
assert build_etag('abc', FILE) != build_etag('xyz', FILE)
def test_etag_differs_for_the_download_variant():
assert build_etag('abc', FILE) != build_etag('abc', FILE, download=True)
def test_etag_changes_when_storage_blob_changes():
other = dict(FILE, db_image_url='http://convex.internal/api/storage/zzz')
assert build_etag('abc', FILE) != build_etag('abc', other)
def test_etag_changes_with_filename_because_of_content_disposition():
assert build_etag('abc', FILE) != build_etag('abc', dict(FILE, file_name='other.png'))
def test_etag_is_a_quoted_strong_validator():
etag = build_etag('abc', FILE)
assert etag.startswith('"') and etag.endswith('"')
assert not etag.startswith('W/')
# --- If-None-Match ------------------------------------------------------------
@pytest.mark.parametrize('header,expected', [
('"tag"', True),
('W/"tag"', True),
('*', True),
('"other", "tag"', True),
('"other"', False),
('', False),
(None, False),
])
def test_etag_matches(header, expected):
assert etag_matches(header, '"tag"') is expected
# --- If-Modified-Since --------------------------------------------------------
def test_not_modified_since_accepts_newer_client_date():
assert not_modified_since('Wed, 21 Oct 2026 07:28:00 GMT', 1000.0) is True
def test_not_modified_since_rejects_older_client_date():
assert not_modified_since('Thu, 01 Jan 1970 00:00:01 GMT', time.time()) is False
def test_not_modified_since_ignores_garbage():
assert not_modified_since('not a date', 1000.0) is False
def test_etag_wins_over_date_when_both_are_sent():
# A mismatching ETag must force a 200 even if the date looks fresh.
assert is_fresh('"other"', 'Wed, 21 Oct 2026 07:28:00 GMT', '"tag"', 1000.0) is False
def test_date_is_used_when_no_etag_is_sent():
assert is_fresh(None, 'Wed, 21 Oct 2026 07:28:00 GMT', '"tag"', 1000.0) is True
# --- Cache-Control ------------------------------------------------------------
def test_cache_control_for_a_file_that_never_expires():
assert cache_control('', 86400) == 'public, max-age=86400'
def test_cache_control_is_clamped_to_remaining_lifetime():
soon = (time.time() + 120) * 1000
value = cache_control(soon, 86400)
age = int(value.rsplit('=', 1)[1])
assert 100 <= age <= 120
def test_cache_control_refuses_to_outlive_an_expired_file():
assert cache_control((time.time() - 10) * 1000, 86400) == 'no-cache'
def test_remaining_seconds_is_none_without_expiry():
assert remaining_seconds('') is None
assert remaining_seconds(None) is None
# --- Cloudflare edge TTL ------------------------------------------------------
def test_edge_ttl_is_shorter_than_the_browser_ttl():
browser = int(cache_control('', 86400).rsplit('=', 1)[1])
edge = int(edge_cache_control('', 1800).rsplit('=', 1)[1])
assert edge < browser
def test_edge_ttl_is_clamped_to_remaining_lifetime():
soon = (time.time() + 60) * 1000
value = edge_cache_control(soon, 1800)
assert 40 <= int(value.rsplit('=', 1)[1]) <= 60
def test_edge_refuses_to_store_an_expired_file():
# no-store, not no-cache: a shared cache must not hold the bytes at all.
assert edge_cache_control((time.time() - 10) * 1000, 1800) == 'no-store'
def test_edge_ttl_of_zero_disables_edge_caching():
assert edge_cache_control('', 0) == 'no-store'
# --- Metadata cache -----------------------------------------------------------
def make_metadata_cache(ttl=60):
return FileMetadataCache(AsyncCache(backend='memory'), ttl=ttl)
def test_metadata_cache_queries_convex_once():
calls = []
async def loader():
calls.append(1)
return dict(FILE)
async def scenario():
cache = make_metadata_cache()
first = await cache.get('abc', loader)
second = await cache.get('abc', loader)
return first, second, cache
first, second, cache = run(scenario())
assert calls == [1]
assert first == second
assert (cache.hits, cache.misses) == (1, 1)
def test_metadata_cache_invalidation_forces_a_refetch():
calls = []
async def loader():
calls.append(1)
return dict(FILE)
async def scenario():
cache = make_metadata_cache()
await cache.get('abc', loader)
await cache.invalidate('abc')
await cache.get('abc', loader)
run(scenario())
assert len(calls) == 2
def test_metadata_cache_never_stores_a_miss():
calls = []
convex_returned_null = None
async def loader():
calls.append(1)
return convex_returned_null
async def scenario():
cache = make_metadata_cache()
await cache.get('abc', loader)
await cache.get('abc', loader)
run(scenario())
assert len(calls) == 2
def test_metadata_cache_never_stores_an_expired_marker():
calls = []
async def loader():
calls.append(1)
return {'expired': True, 'user_id': 'owner'}
async def scenario():
cache = make_metadata_cache()
await cache.get('abc', loader)
await cache.get('abc', loader)
run(scenario())
assert len(calls) == 2
def test_metadata_ttl_is_clamped_to_the_expiry():
cache = make_metadata_cache(ttl=600)
soon = dict(FILE, expires_at=(time.time() + 30) * 1000)
assert cache._effective_ttl(soon) <= 30
assert cache._effective_ttl(FILE) == 600
# --- Blob cache ---------------------------------------------------------------
def test_blob_cache_returns_stored_bytes():
cache = BlobCache(max_total_bytes=1000, max_item_bytes=500)
assert cache.set('a', b'hello') is True
assert cache.get('a') == b'hello'
assert (cache.hits, cache.misses) == (1, 0)
def test_blob_cache_skips_items_over_the_item_limit():
cache = BlobCache(max_total_bytes=1000, max_item_bytes=4)
assert cache.set('a', b'too large') is False
assert cache.get('a') is None
def test_blob_cache_evicts_least_recently_used():
cache = BlobCache(max_total_bytes=10, max_item_bytes=10)
cache.set('a', b'12345')
cache.set('b', b'12345')
cache.get('a') # 'a' becomes the most recently used
cache.set('c', b'12345') # pushes the total over the limit
assert cache.get('b') is None
assert cache.get('a') == b'12345'
assert cache.get('c') == b'12345'
def test_blob_cache_respects_its_total_budget():
cache = BlobCache(max_total_bytes=10, max_item_bytes=10)
for index in range(10):
cache.set(f'k{index}', b'12345')
assert cache.current_bytes <= 10
def test_blob_cache_expires_entries():
cache = BlobCache(max_total_bytes=100, max_item_bytes=100, ttl=10)
cache.set('a', b'data', now=1000.0)
assert cache.get('a', now=1005.0) == b'data'
assert cache.get('a', now=1011.0) is None
def test_blob_cache_overwrite_does_not_leak_accounting():
cache = BlobCache(max_total_bytes=100, max_item_bytes=100)
cache.set('a', b'12345')
cache.set('a', b'123')
assert cache.current_bytes == 3
def test_blob_cache_can_be_disabled():
cache = BlobCache(max_total_bytes=0, max_item_bytes=0)
assert cache.enabled is False
assert cache.set('a', b'data') is False
# --- FileCache facade ---------------------------------------------------------
def test_file_cache_fetches_a_blob_once():
calls = []
async def loader():
calls.append(1)
return b'image-bytes'
async def scenario():
cache = build_file_cache(AsyncCache(backend='memory'))
first = await cache.get_blob('storage://a', loader)
second = await cache.get_blob('storage://a', loader)
return first, second
first, second = run(scenario())
assert calls == [1]
assert first == second == b'image-bytes'
def test_build_file_cache_reads_env(monkeypatch):
monkeypatch.setenv('FILE_CACHE_METADATA_TTL', '17')
monkeypatch.setenv('FILE_CACHE_BLOB_ITEM_BYTES', '11')
monkeypatch.setenv('FILE_CACHE_BROWSER_MAX_AGE', '123')
cache = build_file_cache(AsyncCache(backend='memory'))
assert cache.metadata._ttl == 17
assert cache.blobs.set('a', b'x' * 50) is False
assert cache.blob_max_age == 123
def test_build_file_cache_ignores_broken_env(monkeypatch):
monkeypatch.setenv('FILE_CACHE_METADATA_TTL', 'not-a-number')
assert build_file_cache(AsyncCache(backend='memory')).metadata._ttl == 60
+287
View File
@@ -0,0 +1,287 @@
import asyncio
import importlib.util
import sys
import time
import types
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from quart import Blueprint, Quart
from my_modules.AsyncCache import AsyncCache
from my_modules.filecache.store import build_file_cache
ROOT = Path(__file__).resolve().parents[1]
IMAGE = b'\x89PNG\r\n\x1a\n' + b'pixels' * 100
def base_file(**overrides):
data = {
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'http://convex.internal/api/storage/abc',
'uploaded_at': 1700000000000,
'expires_at': '',
'user_id': 'owner',
}
data.update(overrides)
return data
def load_share_module(monkeypatch):
stubs = {
'my_modules.decoratory.header': {'login_required': lambda fn: fn},
'my_modules.functions': {'get_ip': lambda: '192.0.2.1'},
'my_modules.app.setup': {
'LIMITER': types.SimpleNamespace(limit=lambda *args, **kwargs: lambda fn: fn),
},
'my_modules.app.logger': {'logger': None},
'my_modules.expiry': {'parse_expires': lambda value: value},
'quart_common.web.wide_event': {'add_wide_event_context': lambda **kwargs: None},
}
for name, values in stubs.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
path = ROOT / 'routes/side/main.py'
spec = importlib.util.spec_from_file_location('serve_file_cache_under_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_app(monkeypatch, file_data=None, with_cache=True):
module = load_share_module(monkeypatch)
app = Quart(
'serve_file_cache_test',
template_folder=str(ROOT / 'templates/side'),
static_folder=str(ROOT / 'templates/static'),
)
app.secret_key = 'test-secret'
record = base_file() if file_data is None else file_data
async def get_file(file_id):
return record if file_id == 'abc' else None
async def get_from_storage(storage_id):
return BytesIO(IMAGE)
app.convex = types.SimpleNamespace(
get_file=AsyncMock(side_effect=get_file),
get_from_storage=AsyncMock(side_effect=get_from_storage),
add_file_access=AsyncMock(return_value=None),
)
if with_cache:
app.file_cache = build_file_cache(AsyncCache(backend='memory'))
app.register_blueprint(module.side_main_bp)
register_template_endpoints(app)
return app
def register_template_endpoints(app):
"""Minimal endpoints so base.htm and navigation.htm can resolve url_for."""
for name, rule, endpoint in (
('basic', '/favicon.ico', 'favicon'),
('auth_login', '/login', 'login'),
('link_composer', '/links', 'link_composer_page'),
):
blueprint = Blueprint(name, __name__)
blueprint.add_url_rule(rule, endpoint, lambda: '')
app.register_blueprint(blueprint)
def get(app, path='/-abc', headers=None):
async def run():
return await app.test_client().get(path, headers=headers or {})
return asyncio.run(run())
# --- conditional requests -----------------------------------------------------
def test_first_response_carries_validators(monkeypatch):
app = build_app(monkeypatch)
response = get(app)
assert response.status_code == 200
assert response.headers['ETag'].startswith('"')
assert response.headers['Cache-Control'] == 'public, max-age=86400'
assert 'Last-Modified' in response.headers
# --- Cloudflare edge TTL ------------------------------------------------------
def test_edge_header_is_shorter_than_the_browser_header(monkeypatch):
app = build_app(monkeypatch)
response = get(app)
assert response.headers['Cache-Control'] == 'public, max-age=86400'
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=1800'
def test_plain_cdn_header_is_not_sent(monkeypatch):
# Only the Cloudflare-specific header, so it is not proxied to the browser.
app = build_app(monkeypatch)
assert 'CDN-Cache-Control' not in get(app).headers
def test_edge_header_is_on_the_304_too(monkeypatch):
app = build_app(monkeypatch)
etag = get(app).headers['ETag']
response = get(app, headers={'If-None-Match': etag})
assert response.status_code == 304
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=1800'
def test_edge_ttl_is_configurable(monkeypatch):
monkeypatch.setenv('FILE_CACHE_EDGE_MAX_AGE', '42')
app = build_app(monkeypatch)
assert get(app).headers['Cloudflare-CDN-Cache-Control'] == 'public, max-age=42'
def test_edge_caching_can_be_switched_off(monkeypatch):
monkeypatch.setenv('FILE_CACHE_EDGE_MAX_AGE', '0')
app = build_app(monkeypatch)
response = get(app)
assert response.headers['Cloudflare-CDN-Cache-Control'] == 'no-store'
# The browser must keep caching even when the edge does not.
assert response.headers['Cache-Control'] == 'public, max-age=86400'
def test_expired_file_is_never_edge_cached(monkeypatch):
app = build_app(monkeypatch, file_data={'expired': True, 'user_id': 'owner'})
response = get(app)
assert response.status_code == 410
assert response.headers['Cache-Control'] == 'no-store'
def test_text_view_is_never_edge_cached(monkeypatch):
app = build_app(monkeypatch, file_data=base_file(
file_name='note.txt', content_type='text/plain',
))
response = get(app, headers={'Accept': 'text/html,application/xhtml+xml'})
assert response.headers['Cache-Control'] == 'private, no-store'
def test_revalidation_returns_304_without_touching_storage(monkeypatch):
app = build_app(monkeypatch)
etag = get(app).headers['ETag']
app.convex.get_from_storage.reset_mock()
response = get(app, headers={'If-None-Match': etag})
assert response.status_code == 304
assert response.headers['ETag'] == etag
app.convex.get_from_storage.assert_not_awaited()
def test_revalidation_by_date_returns_304(monkeypatch):
app = build_app(monkeypatch)
last_modified = get(app).headers['Last-Modified']
app.convex.get_from_storage.reset_mock()
response = get(app, headers={'If-Modified-Since': last_modified})
assert response.status_code == 304
app.convex.get_from_storage.assert_not_awaited()
def test_stale_etag_still_gets_the_body(monkeypatch):
app = build_app(monkeypatch)
response = get(app, headers={'If-None-Match': '"stale-value"'})
assert response.status_code == 200
assert asyncio.run(response.get_data()) == IMAGE
def test_download_variant_has_its_own_etag(monkeypatch):
app = build_app(monkeypatch)
inline = get(app).headers['ETag']
attachment = get(app, '/-abc?download=1').headers['ETag']
assert inline != attachment
def test_download_etag_does_not_satisfy_the_inline_request(monkeypatch):
app = build_app(monkeypatch)
attachment = get(app, '/-abc?download=1').headers['ETag']
assert get(app, '/-abc', {'If-None-Match': attachment}).status_code == 200
# --- cache reuse --------------------------------------------------------------
def test_cold_clients_reuse_metadata_and_blob(monkeypatch):
app = build_app(monkeypatch)
for _ in range(5):
assert get(app).status_code == 200
assert app.convex.get_file.await_count == 1
assert app.convex.get_from_storage.await_count == 1
def test_without_a_cache_every_request_hits_convex(monkeypatch):
app = build_app(monkeypatch, with_cache=False)
for _ in range(3):
assert get(app).status_code == 200
assert app.convex.get_file.await_count == 3
assert app.convex.get_from_storage.await_count == 3
def test_cached_body_is_identical_on_repeat(monkeypatch):
app = build_app(monkeypatch)
first = asyncio.run(get(app).get_data())
second = asyncio.run(get(app).get_data())
assert first == second == IMAGE
def test_oversized_blobs_are_not_cached(monkeypatch):
monkeypatch.setenv('FILE_CACHE_BLOB_ITEM_BYTES', '4')
app = build_app(monkeypatch)
get(app)
get(app)
assert app.convex.get_from_storage.await_count == 2
# Metadata is small, so it stays cached even when the blob does not.
assert app.convex.get_file.await_count == 1
# --- expiry and invalidation --------------------------------------------------
def test_expired_file_is_not_served_from_cache(monkeypatch):
app = build_app(monkeypatch, file_data={'expired': True, 'user_id': 'owner'})
for _ in range(3):
assert get(app).status_code == 410
# The expired marker must never be cached.
assert app.convex.get_file.await_count == 3
def test_cache_control_is_clamped_for_a_file_that_expires_soon(monkeypatch):
soon = (time.time() + 120) * 1000
app = build_app(monkeypatch, file_data=base_file(expires_at=soon))
value = get(app).headers['Cache-Control']
assert 100 <= int(value.rsplit('=', 1)[1]) <= 120
def test_invalidation_makes_the_next_request_refetch_metadata(monkeypatch):
app = build_app(monkeypatch)
get(app)
async def invalidate():
async with app.app_context():
from my_modules.filecache.access import drop_file_cache
await drop_file_cache('abc')
asyncio.run(invalidate())
get(app)
assert app.convex.get_file.await_count == 2
def test_missing_file_is_never_cached(monkeypatch):
app = build_app(monkeypatch)
for _ in range(3):
assert get(app, '/-unknown').status_code == 404
assert app.convex.get_file.await_count == 3
# --- access logging -----------------------------------------------------------
@pytest.mark.parametrize('headers,expected_status', [
({}, 200),
({'If-None-Match': 'PLACEHOLDER'}, 304),
])
def test_access_is_logged_for_both_fresh_and_revalidated_requests(monkeypatch, headers, expected_status):
app = build_app(monkeypatch)
if 'If-None-Match' in headers:
headers['If-None-Match'] = get(app).headers['ETag']
app.convex.add_file_access.reset_mock()
response = get(app, headers=headers)
assert response.status_code == expected_status
app.convex.add_file_access.assert_awaited_once()
+172
View File
@@ -0,0 +1,172 @@
import asyncio
import pytest
from my_modules.textview import (
build_segments,
decode_text,
parse_share_link,
tokenize,
wants_text_view,
)
EXAMPLE = (
'Ref Barend: https://i.example.dev/-Q6jmHoRYjFi\n'
'Ref Hanzoku: https://i.example.dev/-eluTduc-npP in female shape like '
'https://i.example.dev/-kzmIlsf7uUe please\n\n'
'Hoodie/Skateboard : https://i.example.dev/-to4wPonAadV\n\n'
'Barend has just returned from a skating trip.'
)
# --- tokenize -----------------------------------------------------------------
def test_tokenize_finds_every_url_in_the_example():
urls = [value for kind, value in tokenize(EXAMPLE) if kind == 'url']
assert urls == [
'https://i.example.dev/-Q6jmHoRYjFi',
'https://i.example.dev/-eluTduc-npP',
'https://i.example.dev/-kzmIlsf7uUe',
'https://i.example.dev/-to4wPonAadV',
]
def test_tokenize_keeps_text_lossless():
assert ''.join(value for _, value in tokenize(EXAMPLE)) == EXAMPLE
@pytest.mark.parametrize('raw,expected', [
('see https://example.com/-abc.', 'https://example.com/-abc'),
('see https://example.com/-abc, next', 'https://example.com/-abc'),
('(https://example.com/-abc)', 'https://example.com/-abc'),
('https://example.com/wiki/Foo_(bar)', 'https://example.com/wiki/Foo_(bar)'),
])
def test_tokenize_trims_sentence_punctuation(raw, expected):
assert [value for kind, value in tokenize(raw) if kind == 'url'] == [expected]
@pytest.mark.parametrize('raw', [
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'ftp://example.com/file',
])
def test_tokenize_ignores_non_http_schemes(raw):
assert [kind for kind, _ in tokenize(raw)] == ['text']
# --- parse_share_link ---------------------------------------------------------
@pytest.mark.parametrize('url,expected', [
('https://i.example.dev/-abc123', 'abc123'),
('http://i.example.dev/-abc123', 'abc123'),
('https://I.Example.Dev/-abc123', 'abc123'),
('https://i.example.dev/-abc123?download=1', 'abc123'),
('https://other.example.dev/-abc123', None),
('https://i.example.dev/files/-abc123', None),
('https://i.example.dev/-abc/def', None),
('https://i.example.dev/-', None),
('https://i.example.dev/', None),
])
def test_parse_share_link(url, expected):
assert parse_share_link(url, host='i.example.dev') == expected
def test_parse_share_link_honours_root_path():
assert parse_share_link('https://h/sub/-abc', host='h', root_path='/sub') == 'abc'
assert parse_share_link('https://h/-abc', host='h', root_path='/sub') is None
def test_parse_share_link_rejects_host_lookalike():
assert parse_share_link('https://i.example.dev.evil.tld/-abc', host='i.example.dev') is None
# --- build_segments -----------------------------------------------------------
FILES = {
'Q6jmHoRYjFi': {'file_name': 'barend.png', 'content_type': 'image/png'},
'eluTduc-npP': {'file_name': 'hanzoku.png', 'content_type': 'image/png'},
'kzmIlsf7uUe': {'file_name': 'gone.png', 'content_type': 'image/png', 'expired': True},
'to4wPonAadV': {'file_name': 'notes.txt', 'content_type': 'text/plain'},
}
def make_lookup(calls: list):
async def lookup(file_id):
calls.append(file_id)
return FILES.get(file_id)
return lookup
def run(coro):
return asyncio.run(coro)
def test_build_segments_resolves_own_links():
calls = []
segments = run(build_segments(
EXAMPLE, host='i.example.dev', root_path='', lookup=make_lookup(calls)
))
by_url = {s['url']: s for s in segments if s['kind'] in {'link', 'file'}}
assert by_url['https://i.example.dev/-Q6jmHoRYjFi'] == {
'kind': 'file', 'url': 'https://i.example.dev/-Q6jmHoRYjFi', 'state': 'ok',
'file_id': 'Q6jmHoRYjFi', 'label': 'barend.png', 'media': 'image',
}
assert by_url['https://i.example.dev/-to4wPonAadV']['media'] == 'other'
assert by_url['https://i.example.dev/-kzmIlsf7uUe']['kind'] == 'link'
assert by_url['https://i.example.dev/-kzmIlsf7uUe']['state'] == 'expired'
assert sorted(calls) == sorted(FILES)
def test_build_segments_keeps_foreign_links_unresolved():
calls = []
segments = run(build_segments(
'see https://other.example.dev/-Q6jmHoRYjFi',
host='i.example.dev', root_path='', lookup=make_lookup(calls),
))
assert segments[-1] == {'kind': 'link', 'url': 'https://other.example.dev/-Q6jmHoRYjFi'}
assert calls == []
def test_build_segments_marks_missing_files():
segments = run(build_segments(
'https://i.example.dev/-nope', host='i.example.dev', root_path='', lookup=make_lookup([])
))
assert segments[0]['kind'] == 'link'
assert segments[0]['state'] == 'missing'
def test_build_segments_deduplicates_lookups():
calls = []
text = 'https://i.example.dev/-Q6jmHoRYjFi and https://i.example.dev/-Q6jmHoRYjFi'
run(build_segments(text, host='i.example.dev', root_path='', lookup=make_lookup(calls)))
assert calls == ['Q6jmHoRYjFi']
def test_build_segments_caps_lookups():
calls = []
text = ' '.join(f'https://i.example.dev/-id{index}' for index in range(30))
segments = run(build_segments(
text, host='i.example.dev', root_path='', lookup=make_lookup(calls), max_lookups=5
))
assert len(calls) == 5
assert all(s['kind'] == 'link' for s in segments if s['kind'] != 'text')
def test_build_segments_without_lookup_only_linkifies():
segments = run(build_segments(EXAMPLE, host='i.example.dev', root_path='', lookup=None))
assert all(segment['kind'] in {'text', 'link'} for segment in segments)
# --- wants_text_view ----------------------------------------------------------
BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
@pytest.mark.parametrize('content_type,accept,raw,download,expected', [
('text/plain', BROWSER_ACCEPT, None, False, True),
('text/plain; charset=utf-8', BROWSER_ACCEPT, None, False, True),
('text/markdown', BROWSER_ACCEPT, None, False, True),
('text/plain', '*/*', None, False, False),
('text/plain', '', None, False, False),
('text/plain', BROWSER_ACCEPT, '1', False, False),
('text/plain', BROWSER_ACCEPT, 'true', False, False),
('text/plain', BROWSER_ACCEPT, None, True, False),
('image/png', BROWSER_ACCEPT, None, False, False),
('application/json', BROWSER_ACCEPT, None, False, False),
])
def test_wants_text_view(content_type, accept, raw, download, expected):
assert wants_text_view(
content_type=content_type, accept_header=accept, raw_param=raw, force_download=download
) is expected
# --- decode_text --------------------------------------------------------------
def test_decode_text_keeps_the_whole_document():
raw = b'x' * (512 * 1024)
assert len(decode_text(raw)) == len(raw)
def test_decode_text_survives_invalid_utf8():
assert decode_text(b'ok \xff\xfe').startswith('ok ')
+170
View File
@@ -0,0 +1,170 @@
import asyncio
import importlib.util
import sys
import types
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from quart import Blueprint, Quart
ROOT = Path(__file__).resolve().parents[1]
BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
PASTE = (
'Ref Barend: https://i.example.dev/-Q6jmHoRYjFi\n'
'Foreign: https://other.example.dev/-abc\n'
'Script test: <script>alert(1)</script>\n'
)
FILES = {
'paste': {
'file_id': 'paste',
'file_name': 'pasted-2026-10-25T101010Z.txt',
'content_type': 'text/plain',
'db_image_url': 'storage://paste',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
'Q6jmHoRYjFi': {
'file_id': 'Q6jmHoRYjFi',
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'storage://barend',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
}
def load_share_module(monkeypatch):
stubs = {
'my_modules.decoratory.header': {'login_required': lambda fn: fn},
'my_modules.functions': {'get_ip': lambda: '192.0.2.1'},
'my_modules.app.setup': {
'LIMITER': types.SimpleNamespace(limit=lambda *args, **kwargs: lambda fn: fn),
},
'my_modules.app.logger': {'logger': None},
'my_modules.expiry': {'parse_expires': lambda value: value},
'quart_common.web.wide_event': {'add_wide_event_context': lambda **kwargs: None},
}
for name, values in stubs.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
path = ROOT / 'routes/side/main.py'
spec = importlib.util.spec_from_file_location('text_view_route_under_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_app(monkeypatch, paste_body: bytes = PASTE.encode()):
module = load_share_module(monkeypatch)
app = Quart(
'text_view_test',
template_folder=str(ROOT / 'templates/side'),
static_folder=str(ROOT / 'templates/static'),
)
app.secret_key = 'test-secret'
async def get_file(file_id):
return FILES.get(file_id)
async def get_from_storage(storage_id):
return BytesIO(paste_body if storage_id == 'storage://paste' else b'binary')
app.convex = types.SimpleNamespace(
get_file=AsyncMock(side_effect=get_file),
get_from_storage=AsyncMock(side_effect=get_from_storage),
add_file_access=AsyncMock(return_value=None),
)
app.register_blueprint(module.side_main_bp)
# Minimal endpoints so base.htm/navigation.htm can resolve their url_for calls.
stub_bp = Blueprint('basic', __name__)
stub_bp.add_url_rule('/favicon.ico', 'favicon', lambda: '')
app.register_blueprint(stub_bp)
auth_bp = Blueprint('auth_login', __name__)
auth_bp.add_url_rule('/login', 'login', lambda: '')
app.register_blueprint(auth_bp)
composer_bp = Blueprint('link_composer', __name__)
composer_bp.add_url_rule('/links', 'link_composer_page', lambda: '')
app.register_blueprint(composer_bp)
return app
def get(app, path, headers=None):
async def run():
return await app.test_client().get(path, headers=headers or {})
return asyncio.run(run())
async def body_of(response):
return (await response.get_data()).decode()
def text_of(response):
return asyncio.run(body_of(response))
# --- browser view -------------------------------------------------------------
def test_browser_gets_html_with_clickable_links(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('text/html')
html = text_of(response)
assert '<a href="https://other.example.dev/-abc"' in html
assert 'rel="noopener noreferrer"' in html
def test_own_link_resolves_to_filename_and_thumbnail(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert 'barend.png' in html
assert '<img src="https://i.example.dev/-Q6jmHoRYjFi"' in html
def test_text_view_escapes_html_in_the_paste(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert '<script>alert(1)</script>' not in html
assert '&lt;script&gt;' in html
def test_text_view_renders_large_pastes_completely(monkeypatch):
marker = 'END-OF-PASTE-MARKER'
body = (('filler line\n' * 60_000) + marker).encode()
app = build_app(monkeypatch, paste_body=body)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert marker in html
assert html.count('filler line') == 60_000
assert 'truncated' not in html.lower()
def test_text_view_is_not_cached(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.headers['Cache-Control'] == 'private, no-store'
# --- raw fallbacks ------------------------------------------------------------
@pytest.mark.parametrize('path,headers', [
('/-paste', {'Accept': '*/*'}),
('/-paste', {'User-Agent': 'curl/8.0'}),
('/-paste?raw=1', {'Accept': BROWSER_ACCEPT}),
('/-paste?download=1', {'Accept': BROWSER_ACCEPT}),
])
def test_non_browser_requests_still_get_raw_text(monkeypatch, path, headers):
app = build_app(monkeypatch)
headers.setdefault('Host', 'i.example.dev')
response = get(app, path, headers)
assert response.status_code == 200
assert response.content_type.startswith('text/plain')
assert text_of(response) == PASTE
def test_binary_files_are_untouched(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-Q6jmHoRYjFi', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('image/png')
Generated
+89 -89
View File
@@ -758,97 +758,97 @@ wheels = [
[[package]]
name = "multidict"
version = "7.1.0"
version = "7.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6b/29ef8e752f5854dcc267ded0652c475bec99b86f255957a0d4fcad657b18/multidict-7.2.1.tar.gz", hash = "sha256:e8af3e6f1bdbc31fd762b094133b478b89988eaebc63cbe82b1d9e3bbd15cede", size = 369994, upload-time = "2026-10-24T20:41:35.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/a7/c9f5c08348a6f903143b631546e22becf1b704a1304b17f7e1b9850308d2/multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b", size = 100435, upload-time = "2026-10-09T20:28:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/82/60/92fe617008c74cc019483b1c59202c48738c6f637ad5b12b36f01e21db43/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff", size = 95267, upload-time = "2026-10-09T20:28:27.856Z" },
{ url = "https://files.pythonhosted.org/packages/17/78/82182f311d673f17de15bc7f7465c7ea5cbd2987ca3a6bf6546fa721e9d8/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03", size = 97233, upload-time = "2026-10-09T20:28:29.817Z" },
{ url = "https://files.pythonhosted.org/packages/63/25/af4e482d053fd73b4b1d60de3ff5578c6cb1d319d39d3f2a454d65409835/multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1", size = 163070, upload-time = "2026-10-09T20:28:31.495Z" },
{ url = "https://files.pythonhosted.org/packages/df/d5/eaed52e199451dac445305fb2631d3f4e7ac9536aeef01f23622a1d93f90/multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b", size = 94582, upload-time = "2026-10-09T20:28:33.263Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e3/ff58ecad5161baa98dec05716e2745449446f2424a9c727464bf452f7fd1/multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038", size = 97406, upload-time = "2026-10-09T20:28:34.981Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a2/ae4eadf02d4bc035baa7cadf5548ca4fa441517193f76ce1aeb5ed276ae3/multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d", size = 449908, upload-time = "2026-10-09T20:28:36.659Z" },
{ url = "https://files.pythonhosted.org/packages/91/55/bd5101ef760d1af4c3f246330b29b48ee22bfdd5a83150b713dd93b431e2/multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b", size = 471605, upload-time = "2026-10-09T20:28:38.561Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8f/3dea8a28b0416ab71d47c8ce274bb3cacedde2d9838647ac67cdaa3d48e6/multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2", size = 433523, upload-time = "2026-10-09T20:28:40.913Z" },
{ url = "https://files.pythonhosted.org/packages/24/97/805d2aa4f746cc43cf4b206f1b1bfe2566add95bbb145abd3d9cf61858bf/multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7", size = 488277, upload-time = "2026-10-09T20:28:42.851Z" },
{ url = "https://files.pythonhosted.org/packages/dd/7b/eac247b7e76f6010071c7c2401da5255eeffd1b2ac981925e533413d21ec/multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec", size = 501888, upload-time = "2026-10-09T20:28:44.75Z" },
{ url = "https://files.pythonhosted.org/packages/87/58/de62e27b09dd15756265765945f68f1c0d1e23eaba09e2e109fbb4112425/multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc", size = 474883, upload-time = "2026-10-09T20:28:46.704Z" },
{ url = "https://files.pythonhosted.org/packages/21/fd/afb4e50ce3b44707b5ee39c6b5fa1573bdb50c4be660bb35b040362b5170/multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d", size = 424327, upload-time = "2026-10-09T20:28:48.681Z" },
{ url = "https://files.pythonhosted.org/packages/23/70/96c9abf933c4edae53b8a1c8d3f038120a3a60d363f16c6d94eee9b04851/multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20", size = 459255, upload-time = "2026-10-09T20:28:50.562Z" },
{ url = "https://files.pythonhosted.org/packages/24/37/dbc1dba26dc1c9e42aa07ee01908131a67fee0e79ebe5c1d0e7fc00aad55/multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08", size = 432476, upload-time = "2026-10-09T20:28:52.361Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/eac70cc1461668bad8253a2da727670a56293eaba112aeb4079af8cd37d9/multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33", size = 450030, upload-time = "2026-10-09T20:28:54.252Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/eb40652ea74f96df859c7c5d38f9997f420b9dc404300dda0c5745327a6f/multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912", size = 476169, upload-time = "2026-10-09T20:28:56.819Z" },
{ url = "https://files.pythonhosted.org/packages/44/d0/7454ab8335bc4ec9f8abcc4c83a314bab060f22db6da8436b396e07ed21a/multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9", size = 420459, upload-time = "2026-10-09T20:28:58.744Z" },
{ url = "https://files.pythonhosted.org/packages/60/7a/cedb46b287b720a2c4eda2448faf162612d0b718beb32581e35ccd9219b3/multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79", size = 483071, upload-time = "2026-10-09T20:29:01.114Z" },
{ url = "https://files.pythonhosted.org/packages/9a/11/e7ded22b008546dd30ae8c979aa5ac3282cad98876f7d5bbe93c1f2409a0/multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d", size = 467470, upload-time = "2026-10-09T20:29:03.388Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/0136144cb0b1731fd93475cea75d974a55009358d3bf61dd55e2709a3dca/multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c", size = 80205, upload-time = "2026-10-09T20:29:05.736Z" },
{ url = "https://files.pythonhosted.org/packages/74/d9/a62a690d780febc171026d3e3c5700fc8387aa3e5f77cd04ada33d23d3f4/multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc", size = 92923, upload-time = "2026-10-09T20:29:07.337Z" },
{ url = "https://files.pythonhosted.org/packages/84/08/eb7a1c34dc37c5f2c6aef52e6861e6f1cdfc6c685dcb72581eb218c00953/multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de", size = 92851, upload-time = "2026-10-09T20:29:09.15Z" },
{ url = "https://files.pythonhosted.org/packages/ff/3a/019abd746e8fbed9edd74b81259f20b278c011cef918868506d9b5bd6566/multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629", size = 177429, upload-time = "2026-10-09T20:29:10.989Z" },
{ url = "https://files.pythonhosted.org/packages/f7/41/c8dda935231305d9b83c4a4cc5b5b9e323615328d4704f00ba2cf6d89916/multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94", size = 102003, upload-time = "2026-10-09T20:29:12.78Z" },
{ url = "https://files.pythonhosted.org/packages/af/23/3c3e79a222eaaa59a32648cec34709d848657be5339f96876075731e7f8e/multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0", size = 104861, upload-time = "2026-10-09T20:29:14.591Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5b/04637e7cea5729466fb89048520fde167c5404df1beedcc86f18aeabed7c/multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400", size = 463221, upload-time = "2026-10-09T20:29:16.697Z" },
{ url = "https://files.pythonhosted.org/packages/2d/42/b121767de213a9774399c200cb877b19c2e1e5a0b0a6bc8407c7325fa37e/multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3", size = 502241, upload-time = "2026-10-09T20:29:18.657Z" },
{ url = "https://files.pythonhosted.org/packages/47/0a/1cccd17ebf39776df7d8a6c99d066fc3ac1823d044cfe86a22ebbf42b841/multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8", size = 457128, upload-time = "2026-10-09T20:29:20.589Z" },
{ url = "https://files.pythonhosted.org/packages/19/a9/0616d20dee16255f8737d9017288d9304dabf3695ac0071baca25d00c713/multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86", size = 511842, upload-time = "2026-10-09T20:29:22.569Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/5a406b82ecfeee50c097c05fd519c836a61815d81c4a9a8c523d90fb4b35/multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab", size = 519852, upload-time = "2026-10-09T20:29:24.527Z" },
{ url = "https://files.pythonhosted.org/packages/d5/da/d42234f9d4f0e33885c70149112bf62aa4436045090c15cec0810ada0b2f/multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a", size = 500322, upload-time = "2026-10-09T20:29:26.569Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c6/323e921a9812a6ea82978d4e1d9713e0b709023be037e6e4c14efe5472eb/multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742", size = 444070, upload-time = "2026-10-09T20:29:28.52Z" },
{ url = "https://files.pythonhosted.org/packages/93/87/109b7170de2168294f3e562f9d10caaaf946628d614b5a57246279acc1a6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af", size = 484862, upload-time = "2026-10-09T20:29:30.504Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/7f1f63c9cf13f47036c0c64c607e6eee87c94076e3c6876abb6990ee62ad/multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af", size = 457314, upload-time = "2026-10-09T20:29:32.6Z" },
{ url = "https://files.pythonhosted.org/packages/93/ce/7de8b4ee6a847cc951915c279fc388127ec32a7c14aa7a01c4ad692be575/multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475", size = 462672, upload-time = "2026-10-09T20:29:34.776Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6c/02dd089475983b1f5b8729319caa0b02fa3a60b527f27c86b2d53c24ca11/multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5", size = 498109, upload-time = "2026-10-09T20:29:36.763Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/b9bdc59aa8e3eaf1f9e5d2460bbe0c428a01250d44417f8ccf965a8f6fa6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f", size = 442260, upload-time = "2026-10-09T20:29:38.872Z" },
{ url = "https://files.pythonhosted.org/packages/fa/83/21b044885ab81bf5c03ec5c4c16aa449977e428c5c122e38969481cec1df/multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168", size = 498916, upload-time = "2026-10-09T20:29:41.091Z" },
{ url = "https://files.pythonhosted.org/packages/95/8b/31686730092e349ee2255fa630945ca344fc62e76f0b18d65b0f3dbb0ecf/multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c", size = 493525, upload-time = "2026-10-09T20:29:43.192Z" },
{ url = "https://files.pythonhosted.org/packages/c7/d0/c9fddcd7ac42b70a46ac9cf15e21eb527874164e14d418fcb50ce7190a4b/multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b", size = 86481, upload-time = "2026-10-09T20:29:45.188Z" },
{ url = "https://files.pythonhosted.org/packages/24/ab/ce727c06680e72b5d381caa8587f561cb6fea1bfab6ba20c877019768a6f/multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa", size = 99676, upload-time = "2026-10-09T20:29:47.441Z" },
{ url = "https://files.pythonhosted.org/packages/fa/e7/d116d7ce514d04d9bf63774ed55e8033e2ce15ab5655a597bb947c53dfb8/multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013", size = 98726, upload-time = "2026-10-09T20:29:49.365Z" },
{ url = "https://files.pythonhosted.org/packages/28/42/963e2e38ffd79487b24775841cfe2abb85aa1fa08ee152afc4a9b64e1cdd/multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb", size = 100450, upload-time = "2026-10-09T20:29:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/b3/6e/fbc4721aa0eaea2dff3274ff44e94f60a89841bbb4cdaecd9faa4eb64aa7/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec", size = 95440, upload-time = "2026-10-09T20:29:53.929Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/045aee749b599560d5348bb1bee5c9d61d8b12f31d16e0594778ef3472de/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748", size = 97496, upload-time = "2026-10-09T20:29:55.808Z" },
{ url = "https://files.pythonhosted.org/packages/97/79/f2916b81324d629eba363f760fba26cafef5cb437489c24969ada979508d/multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b", size = 163241, upload-time = "2026-10-09T20:29:57.813Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f6/2aab2b2d7bf2d69204bde0cdabfefe4df3d1954f3f156bfc53714917e757/multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2", size = 94586, upload-time = "2026-10-09T20:30:00.201Z" },
{ url = "https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f", size = 97573, upload-time = "2026-10-09T20:30:02.675Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2e/951c1412c9803e8f87c8673305be1e448761c940cd867ccfdd3e6a551dfd/multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66", size = 453476, upload-time = "2026-10-09T20:30:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/a1/55/12f9f95fc65470bf481ad3d1c0e0fc20094720d0af1985119656f2b4be25/multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442", size = 473078, upload-time = "2026-10-09T20:30:06.986Z" },
{ url = "https://files.pythonhosted.org/packages/87/31/ebe216194a68934de7053d582cde62c839bef4dbc4ae67e9bbccf80a61d2/multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5", size = 435633, upload-time = "2026-10-09T20:30:09.021Z" },
{ url = "https://files.pythonhosted.org/packages/46/e5/4aea764cd6a1d326d91f2bb92b1c8c39c3f5a8da624e7b20b88179dffb72/multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8", size = 488656, upload-time = "2026-10-09T20:30:11.119Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f8/b1e5935233fa4b504b1a58b584fcdf03526635e4b5f268f716be9108a1a4/multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d", size = 502311, upload-time = "2026-10-09T20:30:13.396Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc", size = 477470, upload-time = "2026-10-09T20:30:15.894Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/11ac1880eacddcf698aa227ba9387cda60128e38034eda182cf4585109ee/multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e", size = 430088, upload-time = "2026-10-09T20:30:18.972Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5a/09926de0ec910704507a21c4f1ad76026aab9a5657ced868c57bbbe9324b/multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0", size = 460632, upload-time = "2026-10-09T20:30:21.45Z" },
{ url = "https://files.pythonhosted.org/packages/ed/36/cc51eb3ffb09f475326bbf868c6af2805fbbe9175122ce6d7a65aa06d6c1/multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca", size = 434056, upload-time = "2026-10-09T20:30:23.679Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f4/9e0e4dce595573eba1555495ac9ca6b5f7f6e14cf6c3a5cd87ce5bd33d6f/multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4", size = 453014, upload-time = "2026-10-09T20:30:26.192Z" },
{ url = "https://files.pythonhosted.org/packages/96/14/993a7ac9f3592f5628a12f6bc2495f5cce5814b883170e06f487dede6969/multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec", size = 476343, upload-time = "2026-10-09T20:30:28.532Z" },
{ url = "https://files.pythonhosted.org/packages/b0/da/c2147c1e225a676ee0c97f97bb935fb8a70821bc12df240491bf9f140801/multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0", size = 426813, upload-time = "2026-10-09T20:30:31.015Z" },
{ url = "https://files.pythonhosted.org/packages/5f/fa/8858b260a6662035670759e7b3a7da9decc227296491749073e76202de93/multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096", size = 483882, upload-time = "2026-10-09T20:30:33.509Z" },
{ url = "https://files.pythonhosted.org/packages/c2/43/d42dc515d56d506f437e8a19c4f604719a306f8d2b60f70a14be34ac9231/multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab", size = 469953, upload-time = "2026-10-09T20:30:35.996Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/1b6fb52f7e082b4d49b069d362169a2e6dbc43800f152f33c8f0e8933117/multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b", size = 80202, upload-time = "2026-10-09T20:30:38.121Z" },
{ url = "https://files.pythonhosted.org/packages/f2/26/5dec513dec950825529037703da89a597dc0b157e7b1b8d0304dd9e672cc/multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86", size = 92921, upload-time = "2026-10-09T20:30:40.507Z" },
{ url = "https://files.pythonhosted.org/packages/dd/eb/fd62025f8cd3dad6f936df8bd7ff2642e2fe3fe0166ef2fc75699ee57cac/multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8", size = 92848, upload-time = "2026-10-09T20:30:43.026Z" },
{ url = "https://files.pythonhosted.org/packages/42/f4/2dcd45731d2f57b87211a1dab1c29a72c6a4a527f370851c3e4cef5c4445/multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f", size = 177634, upload-time = "2026-10-09T20:30:45.125Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d6/d57b12a5bb19396a99ae66ec48ff69f764cc2c83e83bd9a003bd5ed5802a/multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3", size = 101954, upload-time = "2026-10-09T20:30:47.416Z" },
{ url = "https://files.pythonhosted.org/packages/46/8b/374e3f6e4581b8f8425712214cad854ff9370019483aaa49e6207c160adf/multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c", size = 105056, upload-time = "2026-10-09T20:30:49.618Z" },
{ url = "https://files.pythonhosted.org/packages/9e/0a/6c89e2179a84eaa78eec658b9a05f10d3e22654708d7b2e3ebdf0faa0614/multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc", size = 467374, upload-time = "2026-10-09T20:30:51.933Z" },
{ url = "https://files.pythonhosted.org/packages/c5/62/431ed831b5e9b94c75ed0a410786d07176e879603601570eb25594039200/multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8", size = 504024, upload-time = "2026-10-09T20:30:54.701Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dc/52e4204538a7824e407e531b742ff6f7a2ff614dd40382d29a62755699cb/multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b", size = 460139, upload-time = "2026-10-09T20:30:57.193Z" },
{ url = "https://files.pythonhosted.org/packages/22/4c/0228d61a8fee69a7ac4d8d0426aa1810b9c36b679a8c51844de701230073/multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0", size = 513162, upload-time = "2026-10-09T20:30:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/56/50/a0b28bf4f036897bbd44c8761fbb384c014c34f621687799d07345b62820/multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc", size = 520312, upload-time = "2026-10-09T20:31:02.515Z" },
{ url = "https://files.pythonhosted.org/packages/4f/1d/ba96f77c24174eff9f6521cc7c83958f27598b924a9f7b6aca5d11c73f35/multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba", size = 502953, upload-time = "2026-10-09T20:31:05.187Z" },
{ url = "https://files.pythonhosted.org/packages/59/f2/14c3ffb649cf45a629ec38ca757a493f0caf729e3f3580816dc7779e4caa/multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683", size = 451497, upload-time = "2026-10-09T20:31:07.597Z" },
{ url = "https://files.pythonhosted.org/packages/55/df/fa4f8f6ee2314bdd3e4bc830c25f1d8a737188198abeaf105c73454f0b95/multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336", size = 486212, upload-time = "2026-10-09T20:31:10.178Z" },
{ url = "https://files.pythonhosted.org/packages/32/5e/3227762b04a8ff1a90359ee2c04af9ed095fe3892e101705fa4233b51514/multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1", size = 459864, upload-time = "2026-10-09T20:31:13.044Z" },
{ url = "https://files.pythonhosted.org/packages/5e/04/58524c7e82176438a48704a687fb383cc943dd7075c67b5bea288043b504/multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e", size = 465861, upload-time = "2026-10-09T20:31:15.503Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d6/28e549b6ec0c829647de4c75cf32e4c7ea0d0e87b91dfb399292f630c365/multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd", size = 498865, upload-time = "2026-10-09T20:31:18.548Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ac/3006640586b4d33442c53989b78fc290c8eae5fab5b62a31ac7a4c220e69/multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a", size = 449885, upload-time = "2026-10-09T20:31:21.03Z" },
{ url = "https://files.pythonhosted.org/packages/80/7e/1ece407cee9f9aff9ace9e44379ed937bccb790936ae448feb79a82c362e/multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423", size = 500237, upload-time = "2026-10-09T20:31:23.611Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/c052b700ffcf689454848ee81ba87bedfdc10caff592c0d2a327c43939a5/multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a", size = 495143, upload-time = "2026-10-09T20:31:26.237Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ef/9ddad94c32fb0bdfc5ad0942d06ae6699bd30d261b0ff59b94810a133238/multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020", size = 86526, upload-time = "2026-10-09T20:31:28.734Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ce/2efa1f32a07adfe041d48c6d04c08b6f9db66b8512c765983172399b2855/multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89", size = 99671, upload-time = "2026-10-09T20:31:31.282Z" },
{ url = "https://files.pythonhosted.org/packages/a9/b3/a44c301fe13716c5f2c08afd89a481018988cde4b337b36e28a2b0fcf3a3/multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad", size = 98893, upload-time = "2026-10-09T20:31:33.62Z" },
{ url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/2eff3c4f36f45eddf11fb10b35069d9ebcef751b16483a585527061354ac/multidict-7.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:3d22ebb716ac79bf32c2a1d15250f017faef719114af4376e8dd814d6633cf0a", size = 102695, upload-time = "2026-10-24T20:41:04.31Z" },
{ url = "https://files.pythonhosted.org/packages/e2/46/4b8667914db791922e826a847f80324cb1f0acba9b05343fcb53c5a237f9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1d369ed297e79b417766327ece16bb7e89306c588df63c364a85d90d4aeac843", size = 101581, upload-time = "2026-10-24T20:40:27.297Z" },
{ url = "https://files.pythonhosted.org/packages/5a/40/80c50aa45b607086d43ff3d467c25450b85c7d9bb7e4bbcd521c3b030be9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a9cc121b5ee2c8b4bd4b6b526d5a26bb782f4d620463f73707691b2004b8c349", size = 103677, upload-time = "2026-10-24T20:40:28.633Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e0/c822cf8d92f33f0d6fc0f733b6f5b287956133024765f31b7151b68f6198/multidict-7.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:02009b402129a73e1f3da68f48eb999afbacd29f25aef0d5052b84ff5c2786a2", size = 172007, upload-time = "2026-10-24T20:40:29.243Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d8/b36e8bbc49a59f38e6411bdf96e07c29462460d77e59527309d72469b1f8/multidict-7.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cb7fc8820b206932ec6d8f8985aeba6edfd2932e13b6c4bf2434d6bcbbeef3e1", size = 97224, upload-time = "2026-10-24T20:41:05.627Z" },
{ url = "https://files.pythonhosted.org/packages/8e/3f/c014da364e2f9707945f921864a6b5c615e88a0ec0518c7999b3b2092405/multidict-7.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cadd02ca804d6ebe799a7af19197d9730094fe2cc74b70eb4f374f4b8c1886c1", size = 103824, upload-time = "2026-10-24T20:40:28.61Z" },
{ url = "https://files.pythonhosted.org/packages/c9/73/bcd1fff09ba644499fcd864e6f86a596e51e90f5504c326354bdba3bb8a0/multidict-7.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:846b17193f1c373d6f05ada894fadf56bce2473067caea0aab51828849894b31", size = 486848, upload-time = "2026-10-24T20:40:29.976Z" },
{ url = "https://files.pythonhosted.org/packages/52/12/d6731ea19a185f76a1dc6b76f239fa3567732c8575188b47f7a715742c21/multidict-7.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6efdeed2723431999cf3bcbc84c72dbe0f65d16bb1f84e15a657f5b60ff18ca6", size = 500914, upload-time = "2026-10-24T20:40:30.626Z" },
{ url = "https://files.pythonhosted.org/packages/71/db/332a5b62d4c2c26950aede813a0da97e42cbc16b656ac936eb2c6e01f58f/multidict-7.2.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9c118e866164ce05498d9106a4bd01e745321403cd6f0b77a0b2587768f8c06", size = 462069, upload-time = "2026-10-24T20:41:06.892Z" },
{ url = "https://files.pythonhosted.org/packages/64/8d/c8c6826dc3e9b742bd21f3e84e1d8c46daffb584f45fd93429541e5e7f11/multidict-7.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc6f3dcb08c4b41e64f2d6cf272b5686d9144db585b2e138a8b1664376ab3921", size = 515356, upload-time = "2026-10-24T20:40:29.998Z" },
{ url = "https://files.pythonhosted.org/packages/92/c8/c13ffe0d352c4caaa17da0a74e9203c718606c62a1d62f98439feb1783d5/multidict-7.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:27cc258247b13008742f0e239933ad01dec5ed4e6dfc63849e30e04e1aa5bc82", size = 539793, upload-time = "2026-10-24T20:40:31.55Z" },
{ url = "https://files.pythonhosted.org/packages/3b/8f/bff861e3bd5a7cc13bd1f040d22aee8aa32b4f2755bba29b0c31a1d2ae25/multidict-7.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:822a7fcd33f6922b650e1b26594f09d53c5d011fe655fea7ded27b2728ce2a1b", size = 506944, upload-time = "2026-10-24T20:40:32.258Z" },
{ url = "https://files.pythonhosted.org/packages/a4/2f/f40fc4a7070798e31630ae0be583e0857fcab71a1bf80c37481ded49c553/multidict-7.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f30d6eaf3cb309b6c21096c6c2b4cf3800f195a78b1babae85f481ce851bdc2", size = 443550, upload-time = "2026-10-24T20:41:08.334Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7e/068b48b20c1df58dddb7885f5e1befd6def91c7b6048f2644ccd81f57fa9/multidict-7.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edffdb3950d3a8b769ddaec3a0962fb7b76b3c96f317f6b88500455a537dded2", size = 486514, upload-time = "2026-10-24T20:40:31.371Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0d/a2f8b1fdd7cc2f497bb2fb0cc59dc7e2ced1fa4b0cd9b2912973e384ea77/multidict-7.2.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8c3ff4f5c376df7126695a52af61e6084b69c9e52dcb9f4713e71ce9819e1bfd", size = 459258, upload-time = "2026-10-24T20:40:32.999Z" },
{ url = "https://files.pythonhosted.org/packages/36/aa/74652a34732f16a951fce9ca118e659f1a9dbe102ee52d9bcea4a8001dad/multidict-7.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d5738cd929f2af8651ebb284467acb58b1ec0f2f4d42489cd0597172bc82e15b", size = 487418, upload-time = "2026-10-24T20:40:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/dd/91/ae90df6402911d310cee3f44a73914ea555d5219b1976a8f2842dc247eac/multidict-7.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:81a5f84a76401c4330e6db4e170dddd58860b30c4e8a44a181e5c2809c7f9c9d", size = 502308, upload-time = "2026-10-24T20:41:09.776Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f3/5a1ab9f594e652bece00f1243bf565063790838250054ef9310999c856f7/multidict-7.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:efe695cc2081633b02fcb95bc1b13070f6647c9ad52d42a8beeb3c2a7ea062dc", size = 439169, upload-time = "2026-10-24T20:40:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/38/a4/8c4fd4ecbea568de3b4605dc7323488f65a204475e1aac98db9d36fdbfd3/multidict-7.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0c52fdd8cb8194fcb6969a417149aa1d09d6e48b69fb1b34ee1b1e4cb51ec183", size = 519617, upload-time = "2026-10-24T20:40:34.488Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b5/4964b2d8f8b4ff03df7f24e8dc7d0de534fad0430e553ff0693aa2bcfb75/multidict-7.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:02d9c04d395a36eeebe89e45f2269614a4675b8e83fddcba15bba2be903971e5", size = 499402, upload-time = "2026-10-24T20:40:35.035Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f2/c7d5c514e066fc6f9b9a2a9583f46a3d8178c49b7e91cadcc59241f22c12/multidict-7.2.1-cp314-cp314-win32.whl", hash = "sha256:b97bffa5772b7e853303de094829fcd3a9bd49fddd70534651ee4933691f6587", size = 83635, upload-time = "2026-10-24T20:41:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/08/8f/2ec03cddf5f283f979813f8c5d38f5df798e21cb07a124dfc311ed28d8e9/multidict-7.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6e22756c6e2d4f57ead0d2e6be179d8a16d661bee424b81de0c6c0c1305ec39f", size = 94544, upload-time = "2026-10-24T20:40:34.202Z" },
{ url = "https://files.pythonhosted.org/packages/2e/75/3e9803944df2233cd43924c04ef057160a75c35e87f4fc083de7c855ab74/multidict-7.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b63acdf4df5a78a15223bbb510bb3e0f8234dfa807610ccb8796d0842db2c801", size = 92767, upload-time = "2026-10-24T20:40:35.787Z" },
{ url = "https://files.pythonhosted.org/packages/96/6d/e3a4b4de34cde67f98887ac0c134e2436e1eafdcef62e81f9f8b7ac8e189/multidict-7.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:15da10cf11fd8d8716720c95b93180e94c8317405d89d08524c579ef360128ac", size = 183485, upload-time = "2026-10-24T20:40:36.397Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b7/c5aa68302159285e544c7f1b687a48d2b000217b9a90aff880052d6cb867/multidict-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c753b820738cddc097892fee95e7cd716fbdfc9640414c5e9ce9463ad125321b", size = 103109, upload-time = "2026-10-24T20:41:12.496Z" },
{ url = "https://files.pythonhosted.org/packages/54/b5/7574cad64ee957cb65a32ac4f842663fbe948c1749f688a93eef0677644c/multidict-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69befda4b1ef57027ea95dc6ed733568642f330fb3203a3e5c5ef107d41e0a1f", size = 109785, upload-time = "2026-10-24T20:40:35.496Z" },
{ url = "https://files.pythonhosted.org/packages/38/7b/ced76f7d87cce5ed15ae404213c7f9fdc594d0caaedbbacc6b8cdfac81e6/multidict-7.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8648dd5dc4d5db32b5936dc5e85e9a4d79db1135513d40d1f54ca15503750324", size = 511246, upload-time = "2026-10-24T20:40:37.078Z" },
{ url = "https://files.pythonhosted.org/packages/51/91/b598741d494865ed002df52f37f9e92bf9ab5a0524098a3f7667c1bd6ac6/multidict-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5efddcdbcb73f5485dca2b6624edc8ee063604b77bd3a3eb772826063965819f", size = 545199, upload-time = "2026-10-24T20:40:37.773Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/2e3c15a07da380a7845e7d0969fde25cd6fca078a09bf30cf6deb06a3480/multidict-7.2.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9c44bd978adddf39df5834c6a1efd7d075c84769a7a084168d035a2a276d9008", size = 489912, upload-time = "2026-10-24T20:41:13.782Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/f6e2668e393430240da34a13503c190493dac8bef352ce7e26e831dc7949/multidict-7.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff7657e79224dea46f2f2e58eb99186656ca63ebe356212210c5c00e9beb814", size = 553445, upload-time = "2026-10-24T20:40:36.916Z" },
{ url = "https://files.pythonhosted.org/packages/05/ca/d7e0605545eebc8053167683fc6aa0d49114df2e75a2c474f2b65f8f083a/multidict-7.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7fb3bc8d58e1b7c1d9fa1d995e30a4ce9dbe49f4c63c755a1ffdecdfbd026ccb", size = 573757, upload-time = "2026-10-24T20:40:38.554Z" },
{ url = "https://files.pythonhosted.org/packages/1b/de/9a74903ffdbe3712ba392166935c91785ccdff82dc4b6a15b28a4df52a14/multidict-7.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66af55b07e4593790157e85f897f06983ded8a8692cb2fb048f3049c1d3fa172", size = 543116, upload-time = "2026-10-24T20:40:39.052Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9d/8eb3ee4465610cd599b08b3320262a3448dcd03299565f4c725ee1504bbc/multidict-7.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed6c107642a9acaa7ede32dcd428a6c87e302c5d555ecd3ec132ffa8c6155b4b", size = 478536, upload-time = "2026-10-24T20:41:15.442Z" },
{ url = "https://files.pythonhosted.org/packages/49/fa/346308a741f2c06b23da186022fd72a75b9923f4fe7c88705605e20506d2/multidict-7.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc9decb36abd00ed5d123db9df70eb9a0da45d6376d86d64f4a94bfd5a1b83fe", size = 526429, upload-time = "2026-10-24T20:40:38.392Z" },
{ url = "https://files.pythonhosted.org/packages/eb/10/96986b2404bba1bdb54e364e682a2a9fc97a93c9f6598a2c35c065cbe28f/multidict-7.2.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5a7df927c7999d1d3196176a8d4139d3a0e1765936f5de8c768097275b2d617a", size = 488886, upload-time = "2026-10-24T20:40:39.949Z" },
{ url = "https://files.pythonhosted.org/packages/8c/5b/59b11e1e2f93d39b34c554bfe3beb35777aa976c35af078f23eda41fa809/multidict-7.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4eb2650aa240b62ab0c6d87238bee5d14aa4d411c0d69ad61be53e026e0785ba", size = 510875, upload-time = "2026-10-24T20:40:40.484Z" },
{ url = "https://files.pythonhosted.org/packages/83/af/79e0036cc9ecc160235f3bcbd337524cb44ebd266cfc77e7532d9bd06850/multidict-7.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:83fbd7cfaca614975efdbdcd9e2df45f3e987e819216468fb73e1db6f0365b98", size = 539190, upload-time = "2026-10-24T20:41:17.519Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b5/342db062f9ae8816c1b940360c251dff21afeb88a9c4b1694c2b3348f2db/multidict-7.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a878fef2797a7c082aa47559af68299372edebc7367bd5128dff2d36e6d820e1", size = 474879, upload-time = "2026-10-24T20:40:39.953Z" },
{ url = "https://files.pythonhosted.org/packages/8a/60/1420f7daae59b331f5506e5b32a79f60fa475e636f53bf4e6cacad5782ec/multidict-7.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f332b16911ffc668baec6aaf8f08a09e1af34a8ee87ccac26bd722589e264645", size = 549769, upload-time = "2026-10-24T20:40:41.241Z" },
{ url = "https://files.pythonhosted.org/packages/ce/af/c4b548057bee365137be7b1ed12cb6339b6911ad95d99a83d4b26e9bcb2a/multidict-7.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:81c6fc88ac9bf6920a0cdf22d3c383964167f12d35eb630463fc6d5ac89e460d", size = 535840, upload-time = "2026-10-24T20:40:41.927Z" },
{ url = "https://files.pythonhosted.org/packages/ba/df/70710e4bc50b29ced1ed69ec3a99124c56ac34747c6a64b05611f7b5856a/multidict-7.2.1-cp314-cp314t-win32.whl", hash = "sha256:0c7b4d8cc9e6ae7070b8129bdb840215552d4e2689ca765d37eb208a24919209", size = 90136, upload-time = "2026-10-24T20:41:18.854Z" },
{ url = "https://files.pythonhosted.org/packages/ac/cb/a330b1474608eef92e6a0a5861d61b8f2d3152d1a71b65dad8b56edea150/multidict-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b5fb6b048931ea925b484f0c7c01b007981415a00a6c62ee74bbf8d229d39c08", size = 101163, upload-time = "2026-10-24T20:40:41.847Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/1563cd1336111755f79a60d06ffecd11dfbce5eb8c32ece9eae888d5fbef/multidict-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e64b86d16158954825d12799271979c6944c1fd1d03575b2f22e869c3f7d00dc", size = 98630, upload-time = "2026-10-24T20:40:42.671Z" },
{ url = "https://files.pythonhosted.org/packages/03/fd/8048552a28d6ae6e1e11198b0605a4d98eee58e10527a35a9fbb3de5b6f6/multidict-7.2.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f29885fef157a602d1816439e5da964c70ebcdbbcd4bb9d2d21b7e8bb5636604", size = 102703, upload-time = "2026-10-24T20:40:44.421Z" },
{ url = "https://files.pythonhosted.org/packages/f1/89/d16f1cb45c2c4d7dab10b199dad0c2907a7846326cbd141abb6c9dedc353/multidict-7.2.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a92e3080d8889b0f7a80ea0c4a72181b4d4d5d3918a03136b4a459f51f5f59da", size = 101681, upload-time = "2026-10-24T20:41:20.058Z" },
{ url = "https://files.pythonhosted.org/packages/40/eb/2e14975652ca018dfed4bf14756f2aa9f71b74a0347aa52e8a4d0cac95a9/multidict-7.2.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:3ca4e2e9102d0788b94894a30a5e3561fd4818a0605d8748d8fe780f890aa069", size = 103843, upload-time = "2026-10-24T20:40:43.083Z" },
{ url = "https://files.pythonhosted.org/packages/46/76/9f80aa8013ac294dcbd9b51b0db0312d98f4db03dc92e56d6a404559332d/multidict-7.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a0f999155c22c8929cc492949c08c6fe7ec8079a5e1bfdeb34b9a3e4991959d0", size = 172121, upload-time = "2026-10-24T20:40:44.051Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/ffc7c5ff41445b51dd71072b066f1189682c52fb32e46a58351f413ddf11/multidict-7.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:f15205c67d4c45250fcff5da077f0ae54102b5bc39e9ee8bab90d99e09a728c5", size = 97233, upload-time = "2026-10-24T20:40:46.192Z" },
{ url = "https://files.pythonhosted.org/packages/65/19/da52a85bde6db671d652b1d11113c507aa4021f2c8d431a5074108aa4d5e/multidict-7.2.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f07f06db2e333b68dd52560b8b0b12b6f8bf6053cedebf9af4aecfecd2dc1b32", size = 103929, upload-time = "2026-10-24T20:41:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/84/77/4c98e263f94cd5b27f89cdf3b5989bed698fe3a34a6812777620a8a4c518/multidict-7.2.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d094951489bb7836342c005e790bd005e60b669aaf5c936b96dd5d68cbd2e23b", size = 490370, upload-time = "2026-10-24T20:40:44.571Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e2/36a02b000bc0605061eebe4fbfe3ee9c39ebe644bbd053e1635d5a0f9286/multidict-7.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0381074dc21225951278d7e4a7a2241f7fb86a4d8b94154dfee1411f49cf851", size = 502507, upload-time = "2026-10-24T20:40:45.371Z" },
{ url = "https://files.pythonhosted.org/packages/42/ea/1fbceb93d48c65a731bc2264c34d9374d3c811657b3616c480a3ff32b152/multidict-7.2.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2e279ad5a6e9347bc9e043fdbee180a0480c8fd52618c3258e983dd93d98ed49", size = 463742, upload-time = "2026-10-24T20:40:47.479Z" },
{ url = "https://files.pythonhosted.org/packages/a8/2c/e8e8e98bd831b74a4a011d007c6990cbebc834557825948a4c192855dd4d/multidict-7.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e0f2b33a6ae233f4da38c77ea32f90aab2cb82ba819d7b6c4f1b68cd33d871b6", size = 515827, upload-time = "2026-10-24T20:41:22.557Z" },
{ url = "https://files.pythonhosted.org/packages/af/54/a777698e55878f569d3a9daa3658b6a5248608f99ca67fab94b5b94ceb95/multidict-7.2.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57b5d0df4c60ab8b1159687610a1c3ae069ad2380d390a79dedff5c82318e714", size = 539980, upload-time = "2026-10-24T20:40:46.122Z" },
{ url = "https://files.pythonhosted.org/packages/63/a8/a00489f6ca40003a95c97e30b556eb136f510664fb9f605d8c1c9e38dd39/multidict-7.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2592fb6c335d67f1912bf72788c02fd5dab305fc3c8c7eab078a5bec1aaa0f88", size = 508202, upload-time = "2026-10-24T20:40:47.003Z" },
{ url = "https://files.pythonhosted.org/packages/90/7a/67c29db7d0daec8548ba8412d98c1f45f6416171eaa208c5682764e2a227/multidict-7.2.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d4af0d863e72f3ea48e85d3aa1d1b426fd81e9544fd7976fc6d6753584a25b03", size = 448626, upload-time = "2026-10-24T20:40:49.019Z" },
{ url = "https://files.pythonhosted.org/packages/ff/7b/986c45a79400c784c7eb2a2e848f77bdb5b97f32cb8a03fb4cda67b5ea28/multidict-7.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8148c2aaaf4b85d7872f024c8bb0c9b9797cc76b10588e3594a69cb01057472a", size = 487794, upload-time = "2026-10-24T20:41:24.08Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a1/d1707dbd709fbd774eac819aa15bf825ae6abf4c4ffaee9eb77e429ba11b/multidict-7.2.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7726c53b1d59eb57b009a9e8759d4f71049ebdacf30642a198a197f80cea451c", size = 459403, upload-time = "2026-10-24T20:40:47.645Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c8/56e3b24559ab63efc76c261e5aa6813616b2094886e9bb9de688cd1a386a/multidict-7.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:f62172cf387e92f4928b60e5936b02c06e3c8449ed7fb440978d6c9bcbfc85df", size = 489508, upload-time = "2026-10-24T20:40:48.541Z" },
{ url = "https://files.pythonhosted.org/packages/01/05/b1cbbdc23a7bcff56fb97d97e675892e9dd0bd941d8c6023c411ae6a1c26/multidict-7.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:03846be88267369908fcd9a91159358a3b142d740991c96e4f696d362fce516c", size = 502575, upload-time = "2026-10-24T20:40:50.968Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f2/946fc151a8e9e17a71f4bd69d5893576addadc8e2528999dd3c1a742b48f/multidict-7.2.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:785ef2f3e372dabc131f228a275f97eca92ad17cfab1ab138790e330d99abe3e", size = 444323, upload-time = "2026-10-24T20:41:25.534Z" },
{ url = "https://files.pythonhosted.org/packages/75/98/548bccbea2dcfe90acc130fa46124527b1460d60ba188d9b368309105a9f/multidict-7.2.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:2bac371da2c3554a99ba126f545e79ab8e8d9426a3e41df81d00ad725c1e2cbf", size = 520113, upload-time = "2026-10-24T20:40:49.455Z" },
{ url = "https://files.pythonhosted.org/packages/4a/98/25a588db57a7b4b786150aad4cb1f2aaad7aca13d20df60643461b58944e/multidict-7.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2d4cfbbb1023f0f6795b5395c400752fb23a5a09ba756aa9003886bbc47b3e12", size = 500566, upload-time = "2026-10-24T20:40:49.974Z" },
{ url = "https://files.pythonhosted.org/packages/df/49/e319d4dcd4da99f356ca963b41f9c3c48a71d76afe606d8e8f0c90827317/multidict-7.2.1-cp315-cp315-win32.whl", hash = "sha256:584ebfa26c9dcf3e465637a5a9afa2647bebb3022b50b1ff384834ba8e0380d1", size = 83644, upload-time = "2026-10-24T20:40:52.389Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3e/06a59c05402c2ff04eac97ef00783510b82b71128c67455a20b1b0eeecb4/multidict-7.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:873d35366cc115be02a1c858f9b22a1a9f6182b99aab2171ee5276af02a153e3", size = 94542, upload-time = "2026-10-24T20:41:27.144Z" },
{ url = "https://files.pythonhosted.org/packages/d2/99/90e4c68ea5ca6ae89c682e3fa463d3fa7ebda5f6e2209a1b1f7b8ab1aa91/multidict-7.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:f2cc27ec6a53b1d39bab44d2b9dd21cc31e796d01b5e18a76d569721282bb264", size = 92767, upload-time = "2026-10-24T20:40:50.934Z" },
{ url = "https://files.pythonhosted.org/packages/2a/39/66a05f6ce31217ee9ed9417c19433c7498e9468a15fae4cafd920284ef21/multidict-7.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:522f7d2351743804cdf5c5a304bc45f9d55e935dd87706fae2dbeafe0fed0b49", size = 183697, upload-time = "2026-10-24T20:40:51.79Z" },
{ url = "https://files.pythonhosted.org/packages/f9/88/185b62ccface5a6295934b24bae589f7817f79f54cd41b19267574bc1349/multidict-7.2.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a3d9a59bcdf8531d46b56bcd512017533510c25e955c2c546efd0743bd840940", size = 103097, upload-time = "2026-10-24T20:40:53.594Z" },
{ url = "https://files.pythonhosted.org/packages/23/d6/bae59cc28b4bec2019e5a2fb0262a077982f68200dde08180f079a918a62/multidict-7.2.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6966806cf917cbbeb9a8b5c581db52e19c9af0ef5fe009db54c1f5799a6e003f", size = 110045, upload-time = "2026-10-24T20:41:28.396Z" },
{ url = "https://files.pythonhosted.org/packages/36/a7/bee9b9f0ff28b751fd79d1c7630d6f88a9dead6218ef2f0e566f5d54ab0e/multidict-7.2.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:74d3e015c28b43b6c1a108eec324ea2efb185c359c459216cabd4cb98f7f02f6", size = 513704, upload-time = "2026-10-24T20:40:52.409Z" },
{ url = "https://files.pythonhosted.org/packages/4e/80/0c2781349f44d137fa138820bb871598116758e9e27042aad2d75835dc5c/multidict-7.2.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2529873a3bf68c372532a9f8058bcf4199e78387e92cd6a01b73334bfe6a7e3f", size = 547454, upload-time = "2026-10-24T20:40:53.161Z" },
{ url = "https://files.pythonhosted.org/packages/16/60/8b97c48c728c980ab5d10abe12400d3a8633910a9cf99307f31100fe1366/multidict-7.2.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:223427392fdf993e198644ec40c404fd93f1cd2a61328d18842ecbf154d02c82", size = 490355, upload-time = "2026-10-24T20:40:55.976Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e5/966b412dd21c58d13f378e60e15e08d13dae669b3a22ed31b1f582b0ec9d/multidict-7.2.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90efd90a0f9b7ae3844a4fb3ae614321832d364ae19567b7cd312315fac4ec39", size = 553571, upload-time = "2026-10-24T20:41:29.67Z" },
{ url = "https://files.pythonhosted.org/packages/13/18/e0f099bdf895d1cc1dee64aecd4e761d4d537c4513875e765a6e5dda71b1/multidict-7.2.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d10e49b940e70e2a4e91f44098007dda0bacb78c3f8e31ba7a37ee2ce52269e6", size = 572533, upload-time = "2026-10-24T20:40:54.421Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/150bfda9b5a12defbcd0c48385fcceb3efb8257bd9872c8c11cf79643eb0/multidict-7.2.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba5e8d9310a794b853fb0e327d46a827b5b015576341219a591f88ed9d799c27", size = 544014, upload-time = "2026-10-24T20:40:54.676Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/083d4980f3c3eb15663a91ddba983f1317d96e373ae86ac90e93c34140b2/multidict-7.2.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7d120bdde5b213e67ed7dc80ea3adf4f014453fbc20195d619ba2ceb34084f3", size = 485204, upload-time = "2026-10-24T20:40:58.116Z" },
{ url = "https://files.pythonhosted.org/packages/06/2f/6990b941b9315f24575afa4ec4dada55c27fa3558ddc5962ca94322e93f4/multidict-7.2.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:27166b6ec73eac37696d66b2c6efbd44b6d04b597d655070959df54dc7c71c0b", size = 528125, upload-time = "2026-10-24T20:41:31.176Z" },
{ url = "https://files.pythonhosted.org/packages/d6/69/4efe79c2aa7f8cb42a826dfbffbec129358d81828f63158108eaf2e54603/multidict-7.2.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:13268013a3db11a214ccb6920f778d175b765f69a40509610b92e43602272718", size = 494332, upload-time = "2026-10-24T20:40:55.762Z" },
{ url = "https://files.pythonhosted.org/packages/d5/86/3704dd04ab0098e509a3dbed132269aca7897dd1d352638a333df5ebfc7c/multidict-7.2.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:65e4448dadc7c94a8643bb41361e45f3a73d46e36a008a9bc03e9b525f4d7c80", size = 511543, upload-time = "2026-10-24T20:40:56.457Z" },
{ url = "https://files.pythonhosted.org/packages/90/bb/2efa33d97e8b18533cb89b356a5782b61c28f66e21fb662c9853f71f96f4/multidict-7.2.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:68b97b721d95b77d75e718d1b62a227fa4b3e647bd2a9fb39552d1a79ffb67c0", size = 538955, upload-time = "2026-10-24T20:40:59.701Z" },
{ url = "https://files.pythonhosted.org/packages/4f/aa/12510e431bbc652891297aeae4a5b19dc9532b477bbe1eb5642c933825e5/multidict-7.2.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ec4ee5d2d624088775e61cada87bbe7629bfd936bfbd57f122a106970d8ab1ee", size = 481562, upload-time = "2026-10-24T20:41:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/77/b0/3a9e0cc640a9f6031618f6b6415ba603040f68bce00ed36d659a2a8b6aba/multidict-7.2.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:4b338b45dc26ca4bac702e1278754f78613d4d0081e14e94e44fe8e261bf1750", size = 548933, upload-time = "2026-10-24T20:40:57.407Z" },
{ url = "https://files.pythonhosted.org/packages/1d/c3/f4074a8e6c249cde81b5f29667161bdd4250f26174ac4bfbd45676f06b3a/multidict-7.2.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9a53776adc1ba923c9e345fdea7b61553c44c17a9af5eba8d8def8c5dcac4dec", size = 537060, upload-time = "2026-10-24T20:40:57.916Z" },
{ url = "https://files.pythonhosted.org/packages/44/88/07382e85348d485db3f901ae7c0f3d33dd3562f6fe56d398631af0dad4c5/multidict-7.2.1-cp315-cp315t-win32.whl", hash = "sha256:fcfebcb537f419e415b6e2bfc77ddbd33abe3c036f0105410a25a14741df9c31", size = 90152, upload-time = "2026-10-24T20:41:01.076Z" },
{ url = "https://files.pythonhosted.org/packages/00/2f/45eb8700ee15716cc22f5b42592b4d5bc1ca65c880179c2c10e42fd6f0b4/multidict-7.2.1-cp315-cp315t-win_amd64.whl", hash = "sha256:25ebc4208d9098f77caeb16df895a0e56eb6c3b22870ac4e3ec43e0a9d6a33b6", size = 101193, upload-time = "2026-10-24T20:41:34.124Z" },
{ url = "https://files.pythonhosted.org/packages/7b/80/37d26f26d20b937344cfd68d9a1f57d8bc4f64adae416fc6828a7f8c8a10/multidict-7.2.1-cp315-cp315t-win_arm64.whl", hash = "sha256:4d61be3725016ef5db3543c95f1f062d7d150ba776582d959a982af88e75fa55", size = 98863, upload-time = "2026-10-24T20:40:59.146Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/1aaad0e5df33f1f5af21775c411349c0234a6edd581ea403bc81919a6eab/multidict-7.2.1-py3-none-any.whl", hash = "sha256:08ea94663d9e7c25d14d78222f7aee906c472d4837766bec62477c38f6e419dc", size = 28145, upload-time = "2026-10-24T20:40:59.159Z" },
]
[[package]]