From 3db374b1abeee6a15cb9a9c52110397d2dfd9a0f Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sun, 25 Oct 2026 18:54:46 +0100 Subject: [PATCH] feat(files): cache served files and add HTML text viewer 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 --- my_helpers | 2 +- my_modules/app/setup.py | 3 + my_modules/filecache/__init__.py | 27 ++ my_modules/filecache/access.py | 43 +++ my_modules/filecache/blobs.py | 70 +++++ my_modules/filecache/metadata.py | 53 ++++ my_modules/filecache/store.py | 62 ++++ my_modules/filecache/validators.py | 104 +++++++ my_modules/textview/__init__.py | 14 + my_modules/textview/linkify.py | 45 +++ my_modules/textview/own_links.py | 35 +++ my_modules/textview/render.py | 69 +++++ my_modules/textview/view.py | 42 +++ routes/api/link.py | 3 + routes/side/main.py | 76 ++++- .../side/views/webpage/files/text_view.htm | 57 ++++ tests/test_file_cache.py | 277 +++++++++++++++++ tests/test_serve_file_cache.py | 287 ++++++++++++++++++ tests/test_text_view.py | 172 +++++++++++ tests/test_text_view_route.py | 170 +++++++++++ uv.lock | 178 +++++------ 21 files changed, 1692 insertions(+), 97 deletions(-) create mode 100644 my_modules/filecache/__init__.py create mode 100644 my_modules/filecache/access.py create mode 100644 my_modules/filecache/blobs.py create mode 100644 my_modules/filecache/metadata.py create mode 100644 my_modules/filecache/store.py create mode 100644 my_modules/filecache/validators.py create mode 100644 my_modules/textview/__init__.py create mode 100644 my_modules/textview/linkify.py create mode 100644 my_modules/textview/own_links.py create mode 100644 my_modules/textview/render.py create mode 100644 my_modules/textview/view.py create mode 100644 templates/side/views/webpage/files/text_view.htm create mode 100644 tests/test_file_cache.py create mode 100644 tests/test_serve_file_cache.py create mode 100644 tests/test_text_view.py create mode 100644 tests/test_text_view_route.py diff --git a/my_helpers b/my_helpers index a4f068d..af8737f 160000 --- a/my_helpers +++ b/my_helpers @@ -1 +1 @@ -Subproject commit a4f068d34ac7fd7ace6c99077db8a2a894ba1547 +Subproject commit af8737f2ecc060e5cba4c62056d969bdb2894b80 diff --git a/my_modules/app/setup.py b/my_modules/app/setup.py index b9d0208..ee5f6ac 100644 --- a/my_modules/app/setup.py +++ b/my_modules/app/setup.py @@ -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( diff --git a/my_modules/filecache/__init__.py b/my_modules/filecache/__init__.py new file mode 100644 index 0000000..8aadf23 --- /dev/null +++ b/my_modules/filecache/__init__.py @@ -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', +] diff --git a/my_modules/filecache/access.py b/my_modules/filecache/access.py new file mode 100644 index 0000000..3c47893 --- /dev/null +++ b/my_modules/filecache/access.py @@ -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) diff --git a/my_modules/filecache/blobs.py b/my_modules/filecache/blobs.py new file mode 100644 index 0000000..e78cbc1 --- /dev/null +++ b/my_modules/filecache/blobs.py @@ -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 diff --git a/my_modules/filecache/metadata.py b/my_modules/filecache/metadata.py new file mode 100644 index 0000000..230ba48 --- /dev/null +++ b/my_modules/filecache/metadata.py @@ -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)) diff --git a/my_modules/filecache/store.py b/my_modules/filecache/store.py new file mode 100644 index 0000000..e0e0620 --- /dev/null +++ b/my_modules/filecache/store.py @@ -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), + ) diff --git a/my_modules/filecache/validators.py b/my_modules/filecache/validators.py new file mode 100644 index 0000000..01bcf27 --- /dev/null +++ b/my_modules/filecache/validators.py @@ -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' diff --git a/my_modules/textview/__init__.py b/my_modules/textview/__init__.py new file mode 100644 index 0000000..713257e --- /dev/null +++ b/my_modules/textview/__init__.py @@ -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', +] diff --git a/my_modules/textview/linkify.py b/my_modules/textview/linkify.py new file mode 100644 index 0000000..65eaa03 --- /dev/null +++ b/my_modules/textview/linkify.py @@ -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 diff --git a/my_modules/textview/own_links.py b/my_modules/textview/own_links.py new file mode 100644 index 0000000..1d122d1 --- /dev/null +++ b/my_modules/textview/own_links.py @@ -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 /- 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 diff --git a/my_modules/textview/render.py b/my_modules/textview/render.py new file mode 100644 index 0000000..8007a76 --- /dev/null +++ b/my_modules/textview/render.py @@ -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 diff --git a/my_modules/textview/view.py b/my_modules/textview/view.py new file mode 100644 index 0000000..3e31d5d --- /dev/null +++ b/my_modules/textview/view.py @@ -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, + } diff --git a/routes/api/link.py b/routes/api/link.py index 30696c9..43bb100 100644 --- a/routes/api/link.py +++ b/routes/api/link.py @@ -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): diff --git a/routes/side/main.py b/routes/side/main.py index 58414e2..6f8d734 100644 --- a/routes/side/main.py +++ b/routes/side/main.py @@ -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("/-") @@ -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 diff --git a/templates/side/views/webpage/files/text_view.htm b/templates/side/views/webpage/files/text_view.htm new file mode 100644 index 0000000..4d86bca --- /dev/null +++ b/templates/side/views/webpage/files/text_view.htm @@ -0,0 +1,57 @@ +{% extends "base.htm" %} + +{% block title %}NanoShare - {{ file_name }}{% endblock %} + +{% block meta %} + +{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

{{ file_name }}

+ View raw +
+ +
{% for segment in segments -%} +{%- if segment.kind == 'text' -%} +{{ segment.text }} +{%- elif segment.kind == 'file' -%} +{{ segment.label }}{% if segment.media == 'image' %} {{ segment.label }}{% endif %} +{%- else -%} +{{ segment.label or segment.url }}{% if segment.state == 'expired' %} (expired){% endif %} +{%- endif -%} +{%- endfor %}
+
+
+{% endblock %} diff --git a/tests/test_file_cache.py b/tests/test_file_cache.py new file mode 100644 index 0000000..a3beb7c --- /dev/null +++ b/tests/test_file_cache.py @@ -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 diff --git a/tests/test_serve_file_cache.py b/tests/test_serve_file_cache.py new file mode 100644 index 0000000..0129589 --- /dev/null +++ b/tests/test_serve_file_cache.py @@ -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() diff --git a/tests/test_text_view.py b/tests/test_text_view.py new file mode 100644 index 0000000..9c782da --- /dev/null +++ b/tests/test_text_view.py @@ -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,', + '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 ') diff --git a/tests/test_text_view_route.py b/tests/test_text_view_route.py new file mode 100644 index 0000000..5ae976b --- /dev/null +++ b/tests/test_text_view_route.py @@ -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: \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 'alert(1)' not in html + assert '<script>' 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') diff --git a/uv.lock b/uv.lock index 4e1da1c..274080b 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]