diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..a4e89457
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,37 @@
+.git
+.gitignore
+.gitattributes
+.github
+.venv
+.vscode
+__pycache__
+*.py[cod]
+.pytest_cache
+.ruff_cache
+.mypy_cache
+*.session*
+**/logs/
+tests/
+docs/
+htmlcov/
+.coverage
+README.md
+CONTRIBUTING.md
+SECURITY.md
+Makefile
+config.env
+config.env.local
+config.env.bak
+config.env.tmp
+*.tmp
+AGENTS.md
+uv.lock
+requirements.txt
+# build/infra files the runtime image never reads
+pyproject.toml
+scripts/
+config_sample.env
+render.yaml
+app.json
+heroku.yml
+docker-compose.yml
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..857e948a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+# Normalize all text files to LF in the repository and working tree.
+* text=auto eol=lf
+
+# Binary assets
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.docx binary
+*.session binary
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..9d08ec58
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,27 @@
+# Dependabot cannot regenerate uv.lock: after each pip PR, run
+#
+# uv lock && uv export --frozen --no-dev --hashes -o requirements.lock
+#
+# and push both lockfiles to the PR branch, or CI's `uv lock --check` and the
+# requirements.lock export-diff gate will fail. These PRs are expected to be
+# red until that human step is done -- see the `lockfiles-stale` label.
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: "/"
+ labels:
+ - lockfiles-stale
+ schedule:
+ interval: weekly
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ - package-ecosystem: docker
+ directory: "/"
+ schedule:
+ interval: weekly
+ - package-ecosystem: pre-commit
+ directory: "/"
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/dockerize.yml b/.github/workflows/dockerize.yml
deleted file mode 100644
index a810be6b..00000000
--- a/.github/workflows/dockerize.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: Docker Build & Push
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-jobs:
- build:
- if: github.repository == 'fyaz05/FileToLink'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v5
-
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: fyaz05
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Build and Push
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- tags: fyaz05/thunder:latest
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 00000000..e3fe6db5
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,165 @@
+name: Quality Gates
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: quality-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.13"
+
+ # pinned: lock/export output format and gate semantics must not drift
+ # between commits (dependabot bumps this like any other dependency)
+ - name: Install uv
+ run: python -m pip install "uv==0.12.5"
+
+ # Install the hash-pinned lockfile (runtime + dev) so CI tests and audits
+ # the exact dependency graph that ships, not a fresh pip resolution.
+ - name: Install dependencies (locked, hash-pinned)
+ run: uv sync --frozen --group dev
+
+ - name: Lockfile is current with pyproject (uv.lock must never drift)
+ run: uv lock --check
+
+ - name: requirements.txt is in sync with pyproject (two sources of truth must agree)
+ run: |
+ uv run --locked python - <<'PY'
+ import sys, tomllib
+ deps = {d.strip() for d in tomllib.load(open("pyproject.toml", "rb"))["project"]["dependencies"]}
+ listed = {l.strip() for l in open("requirements.txt") if l.strip() and not l.startswith("#")}
+ if deps != listed:
+ print("pyproject:", sorted(deps))
+ print("requirements.txt:", sorted(listed))
+ sys.exit("requirements.txt drifted from pyproject [project.dependencies]")
+ PY
+
+ - name: requirements.lock is in sync with uv.lock (Docker consumes this)
+ run: |
+ uv export --frozen --no-dev --hashes -o /tmp/requirements.lock.check
+ # uv embeds the export command in the header; compare content only
+ diff -u <(grep -v '^#' requirements.lock) <(grep -v '^#' /tmp/requirements.lock.check)
+
+ - name: Ruff (lint + format check)
+ run: |
+ uv run ruff check Thunder/ update.py tests/ scripts/
+ uv run ruff format --check Thunder/ update.py tests/ scripts/
+
+ - name: Mypy (blocking)
+ run: uv run mypy Thunder update.py scripts/
+
+ - name: Unit tests
+ run: uv run pytest --cov=Thunder --cov-report=term-missing --cov-fail-under=35
+
+ # addopts = "-m unit" silently deselects any test that forgets its marker:
+ # an unmarked test is collected but never runs, and nothing else notices.
+ #
+ # The counts are grepped out of pytest's own summary rather than taken from
+ # `tail -1 | cut -d' ' -f1`: that pipeline captures whatever happens to be
+ # last on stdout, so any change to pytest's trailing output makes BOTH sides
+ # capture the same garbage, the comparison passes, and the gate silently
+ # stops guarding anything.
+ - name: No test is silently excluded by the marker filter
+ run: |
+ set -o pipefail
+ UNIT=$(uv run pytest -m unit --collect-only -q | grep -oE '[0-9]+/[0-9]+ tests collected' | tail -1)
+ ALL=$(uv run pytest -m "not integration" --collect-only -q | grep -oE '[0-9]+/[0-9]+ tests collected' | tail -1)
+ echo "unit=$UNIT all-non-integration=$ALL"
+ if [ -z "$UNIT" ] || [ -z "$ALL" ]; then
+ echo "::error::Could not parse the collection summary from pytest. Fix this step rather than letting it pass vacuously."
+ exit 1
+ fi
+ if [ "$UNIT" != "$ALL" ]; then
+ echo "::error::A test is missing its @pytest.mark.unit and addopts=-m unit is excluding it. Mark it, or move it to the integration tier."
+ exit 1
+ fi
+
+ # Audit the locked env: `-r requirements.txt` (direct pins only) never covered transitives.
+ - name: pip-audit (locked environment incl. transitives)
+ run: uv run pip-audit
+
+ - name: Bandit (medium-or-higher severity)
+ run: uv run bandit -c pyproject.toml -r Thunder update.py -ll --skip B101
+
+ - name: Vulture (dead-code gate)
+ run: uv run vulture Thunder update.py --min-confidence 80
+
+ - name: Dependency count gate (leanness is permanent)
+ run: |
+ # distinct package names, so a conditional platform marker for the same
+ # package (pyrofork[speedup] / pyrofork) does not inflate the count
+ COUNT=$(grep -vE '^[[:space:]]*(#|$)' requirements.txt \
+ | sed -E 's/[[:space:]]*;.*$//; s/[[:space:]]*\[.*\]//; s/==.*$//' \
+ | tr -d '\r' | sort -u | wc -l)
+ echo "Direct runtime deps: $COUNT"
+ if [ "$COUNT" -gt 7 ]; then
+ echo "::error::Direct dependency count increased beyond the agreed 7; justify in the PR or remove."
+ exit 1
+ fi
+
+ docker:
+ # Smoke-build on PRs (no secrets); publish only from main pushes so a
+ # feature branch can never overwrite fyaz05/thunder:latest.
+ if: github.repository == 'fyaz05/FileToLink' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
+ needs: quality
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - name: Login to Docker Hub
+ if: github.event_name == 'push'
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ with:
+ username: fyaz05
+ password: ${{ secrets.DOCKER_TOKEN }}
+
+ - name: Build and Push
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ with:
+ context: .
+ push: ${{ github.event_name == 'push' }}
+ # Attestations stay off: the default docker driver rejects them even
+ # on no-push smoke builds; revisit with a containerd-backed builder.
+ tags: |
+ fyaz05/thunder:latest
+ fyaz05/thunder:${{ github.sha }}
+
+ integration:
+ # token CAS / ingest-claim atomicity is only provable against a real
+ # MongoDB, and this tier never ran in CI before
+ needs: quality
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.13"
+
+ - name: Install uv
+ run: python -m pip install "uv==0.12.5"
+
+ - name: Install dependencies (locked, incl. testcontainers)
+ run: uv sync --frozen --group dev
+
+ - name: Integration tests (real MongoDB via testcontainers)
+ run: uv run pytest -m integration
+ env:
+ TEST_INTEGRATION: "1"
diff --git a/.gitignore b/.gitignore
index 431eecad..f87c8856 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,28 +1,38 @@
-*.py[cod]
-*$py.class
-*.so
-.venv/
-.Python
-config.env
-log.text
-.vscode/
-**/__pycache__/
-*.session
-*.session-journal
-*.session-shm
-*.session-wal
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
\ No newline at end of file
+*.py[cod]
+*$py.class
+*.so
+.venv/
+.Python
+config.env
+config.env.local
+.coverage
+htmlcov/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+log.txt
+config.env.tmp
+config.env.bak
+*.tmp
+Thunder/logs/
+.vscode/
+**/__pycache__/
+*.session
+*.session-journal
+*.session-shm
+*.session-wal
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..b374a56c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,13 @@
+repos:
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.16.6
+ hooks:
+ - id: ruff-check
+ args: [--fix]
+ - id: ruff-format
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-added-large-files
diff --git a/AGENTS.md b/AGENTS.md
index a8549d09..7630ab1b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,80 +1,240 @@
# AGENTS.md — Thunder File-to-Link Bot
-Python 3.13+ Telegram bot converting files to direct HTTP links. Uses Pyrofork, aiohttp, MongoDB, uvloop.
+Python 3.13 Telegram bot: files → direct HTTP links (Pyrofork, aiohttp, MongoDB, uvloop).
+Enforced by CI (`quality.yml`) and tests — update in the same PR that changes behavior/config.
## Run
```bash
-python -m Thunder # Primary entry point
-bash thunder.sh # Runs python3 update.py && python3 -m Thunder
+python -m Thunder # primary entry point
+bash thunder.sh # best-effort self-update (shell-free) + python3 -m Thunder
```
## Dependencies
+`pyproject.toml` owns 7 exact-pinned direct packages. The CI gate counts
+*distinct* names, so a conditional platform entry for the same package
+(pyrofork with and without the `speedup` extra) does not count twice.
+`uv.lock` pins the transitive graph (regenerate with `uv lock` on change);
+`requirements.lock` is the hashed export Docker installs with
+`--require-hashes`. CI fails if either drifts:
+
+```bash
+uv sync --frozen # reproducible env
+pip install -r requirements.txt # human installs
+pip install --require-hashes -r requirements.lock # what Docker ships
+# aiohttp, pyrofork[speedup], pymongo, Jinja2, python-dotenv, psutil, uvloop
+```
+
+`uvloop` and `tgcrypto` (pulled in by `pyrofork[speedup]`) ship no Windows
+wheels, so that extra is requested only where `sys_platform != 'win32'`:
+POSIX deploys keep both speedups, Windows dev hosts can install and run the
+suite.
+
+## Development
+
```bash
-pip install -r requirements.txt
-# aiohttp, cloudscraper, Jinja2, pyrofork, pymongo, psutil, python-dotenv, speedtest-cli, tgcrypto, uvloop==0.21.0
+make format # ruff autofix + format
+make lint # ruff + mypy (blocking: 0 errors expected)
+make test # unit tier (hermetic: no network, no Mongo)
+make audit # pip-audit + bandit + vulture + dependency-count
```
+### The fast loop — read this before running the full tier
+
+Importing `pyrofork` costs **~2.8 s** and happens on *every* pytest invocation,
+so there is a hard floor of **~3.8 s per run** no matter how few tests you
+select. The expensive part is running the wrong command, not the tool being
+slow. Measured on a Windows host with the dev group synced:
+
+| command | time | what it covers |
+|---|---|---|
+| `scripts/dev.sh gates` | 1.7 s | ruff + ruff-format + mypy, no pytest |
+| `scripts/dev.sh file
+
[](https://app.railyard.run/deploy?repo=https://github.com/fyaz05/FileToLink)
-
- ⚡ Thunder
-
- High-Performance Telegram File-to-Link Bot for Direct Links & Streaming -
- - - -
- ⭐ Star this project if you find it useful!
- Report Bug •
- Request Feature
-
+ + High-Performance Telegram File-to-Link Bot for Direct Links & Streaming +
+ + + +
+ ⭐ Star this project if you find it useful!
+ Report Bug •
+ Request Feature
+
")
+_LINK_CHUNK_UNITS = TELEGRAM_MAX_UNITS - (_DM_PREFIX_RESERVE + _HEADER_RESERVE + _TAG_RESERVE)
+assert _DM_PREFIX_RESERVE >= _MAX_TITLE_CHARS * _WORST_ESCAPE_EXPANSION, (
+ "the DM reserve no longer covers the worst-case escape expansion of a 128-character group title"
+)
+
+
+async def fwd_media(m_msg: Message) -> Message | None:
+ try:
+ result = await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL)
+ except Exception as e:
+ if "MEDIA_CAPTION_TOO_LONG" in str(e):
+ logger.debug(f"MEDIA_CAPTION_TOO_LONG error, retrying without caption: {e}")
+ try:
+ result = await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL, caption=None)
+ except Exception as e2:
+ logger.error(f"Error fwd_media copy (no caption): {e2}", exc_info=True)
+ return None
+ else:
+ logger.error(f"Error fwd_media copy: {e}", exc_info=True)
+ return None
+ if isinstance(result, list): # defensive: pyrogram returns a list for multi-chat copies
+ return result[0] if result else None
+ return result
+
+
+def get_link_buttons(links):
+ return InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=links["stream_link"]),
+ InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=links["online_link"]),
+ ]
+ ]
+ )
+
+
+async def validate_request_common(client: Client, message: Message) -> bool | None:
+ """One preflight chain for every stream entry point.
+
+ Runtime order: banned → private → token → shortener-status (routing) → force-sub.
+ """
+ shortener_val = await preflight(client, message)
+ if shortener_val is None:
+ return None
+ from Thunder.utils.decorators import force_sub_gate
+
+ if not await force_sub_gate(client, message):
+ return None
+ return shortener_val
+
+
+def _batch_chunk_text(chunk: list[str]) -> str:
+ """Render one batch-links chunk.
+
+ Links can come from the shortener, so each is escaped before it lands
+ inside the block.
+ """
+ body = "\n".join(html.escape(link) for link in chunk)
+ return MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{body}"
+
+
+def _batch_chunks(links: list[str], *, budget: int = _LINK_CHUNK_UNITS) -> list[list[str]]:
+ """Split links so every rendered chunk fits inside one Telegram message.
+
+ Chunking by count is not safe: a link embeds the percent-encoded file name,
+ so one CJK glyph costs 9 characters. 20 such links render to ~12000 UTF-16
+ units against a 4096 cap -- reply_safe raises, the error was only logged,
+ and the user silently lost the whole chunk. Measure what is actually sent.
+
+ Budget reserves room for the header, the DM prefix and the tags.
+ A single link that alone exceeds the budget still gets its own chunk rather
+ than looping forever; telegram rejects it, which is visible, not silent.
+ """
+ chunks: list[list[str]] = []
+ current: list[str] = []
+ size = 0
+ for link in links:
+ # escaped: the width that matters is the one sent on the wire
+ cost = telegram_len(html.escape(link)) + 1 # + the joining newline
+ if current and size + cost > budget:
+ chunks.append(current)
+ current = []
+ size = 0
+ current.append(link)
+ size += cost
+ if current:
+ chunks.append(current)
+ return chunks
+
+
async def send_channel_links(
- links: Dict[str, Any],
+ links: dict[str, Any],
source_info: str,
source_id: int,
*,
- target_msg: Optional[Message] = None,
- reply_to_message_id: Optional[int] = None
+ target_msg: Message | None = None,
+ reply_to_message_id: int | None = None,
):
+ text = MSG_NEW_FILE_REQUEST.format(
+ source_info=html.escape(source_info),
+ id_=source_id,
+ # shortener output is external data: escape it or entity parsing breaks
+ online_link=html.escape(links["online_link"]),
+ stream_link=html.escape(links["stream_link"]),
+ )
try:
- text = MSG_NEW_FILE_REQUEST.format(
- source_info=source_info,
- id_=source_id,
- online_link=links['online_link'],
- stream_link=links['stream_link']
- )
if target_msg:
- await target_msg.reply_text(
+ await tg_call(
+ target_msg.reply_text,
text,
disable_web_page_preview=True,
- quote=True
+ quote=True,
+ parse_mode=enums.ParseMode.HTML,
)
else:
- await StreamBot.send_message(
- chat_id=Var.BIN_CHANNEL,
+ await send_safe(
+ StreamBot,
+ Var.BIN_CHANNEL,
text=text,
disable_web_page_preview=True,
- reply_to_message_id=reply_to_message_id
+ reply_to_message_id=reply_to_message_id,
+ parse_mode=enums.ParseMode.HTML,
)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- text = MSG_NEW_FILE_REQUEST.format(
- source_info=source_info,
- id_=source_id,
- online_link=links['online_link'],
- stream_link=links['stream_link']
+ except Exception as e:
+ logger.error(f"Error sending channel links: {e}", exc_info=True)
+
+
+async def send_dm_links(bot: Client, user_id: int, links: dict[str, Any], chat_title: str):
+ try:
+ dm_text = (
+ MSG_DM_SINGLE_PREFIX.format(chat_title=html.escape(chat_title))
+ + "\n"
+ + format_link_message(links)
)
- if target_msg:
- await target_msg.reply_text(
- text,
- disable_web_page_preview=True,
- quote=True
+ await send_safe(
+ bot,
+ user_id,
+ text=dm_text,
+ disable_web_page_preview=True,
+ parse_mode=enums.ParseMode.HTML,
+ reply_markup=get_link_buttons(links),
+ )
+ except Exception as e:
+ logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True)
+
+
+async def send_link(msg: Message, links: dict[str, Any]):
+ await reply_safe(
+ msg,
+ format_link_message(links),
+ parse_mode=enums.ParseMode.HTML,
+ disable_web_page_preview=True,
+ reply_markup=get_link_buttons(links),
+ )
+
+
+@StreamBot.on_message(filters.command("link") & ~filters.private)
+async def link_handler(bot: Client, msg: Message, **kwargs):
+ if kwargs.get("rl_user_id") is None and msg.sender_chat and msg.sender_chat.id:
+ kwargs["rl_user_id"] = msg.sender_chat.id
+
+ async def _actual_link_handler(client: Client, message: Message, **handler_kwargs):
+ shortener_val = await validate_request_common(client, message)
+ if shortener_val is None:
+ return
+ if message.from_user and not await db.is_user_exist(message.from_user.id):
+ invite_link = f"https://t.me/{client.me.username}?start=start" # type: ignore[union-attr]
+ try:
+ await reply_safe(
+ message,
+ MSG_ERROR_START_BOT.format(invite_link=invite_link),
+ disable_web_page_preview=True,
+ parse_mode=enums.ParseMode.MARKDOWN,
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]
+ ),
+ )
+ except Exception as e:
+ logger.error(f"Error sending start-bot hint: {e}", exc_info=True)
+ return
+
+ if message.chat.type in [
+ enums.ChatType.GROUP,
+ enums.ChatType.SUPERGROUP,
+ ] and not await is_admin(client, message.chat.id):
+ await reply_user_err(message, MSG_ERROR_NOT_ADMIN)
+ return
+
+ if not message.reply_to_message or not message.reply_to_message.media:
+ await reply_user_err(
+ message, MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE
+ )
+ return
+
+ notification_msg = handler_kwargs.get("notification_msg")
+
+ parts = (message.text or message.caption or "").split()
+ num_files = 1
+ if len(parts) > 1:
+ try:
+ num_files = int(parts[1])
+ if not 1 <= num_files <= Var.MAX_BATCH_FILES:
+ await reply_user_err(
+ message, MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES)
+ )
+ return
+ except ValueError:
+ await reply_user_err(message, MSG_ERROR_INVALID_NUMBER)
+ return
+
+ try:
+ status_msg = await reply_safe(message, MSG_PROCESSING_REQUEST)
+ except Exception as e:
+ logger.error(f"Could not send processing status: {e}", exc_info=True)
+ return
+ if num_files == 1:
+ await process_single(
+ client,
+ message,
+ message.reply_to_message,
+ status_msg,
+ shortener_val,
+ notification_msg=notification_msg,
)
else:
- await StreamBot.send_message(
- chat_id=Var.BIN_CHANNEL,
- text=text,
- disable_web_page_preview=True,
- reply_to_message_id=reply_to_message_id
+ await process_batch(
+ client,
+ message,
+ message.reply_to_message.id,
+ num_files,
+ status_msg,
+ shortener_val,
+ notification_msg=notification_msg,
)
-
-
-async def safe_edit_message(message: Message, text: str, **kwargs):
- try:
- try:
- return await message.edit_text(text, **kwargs)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await message.edit_text(text, **kwargs)
- except MessageNotModified:
- pass
- except MessageDeleteForbidden:
- logger.debug(f"Failed to edit message {message.id} due to permissions.")
- except Exception as e:
- logger.error(f"Error editing message {message.id}: {e}", exc_info=True)
-
-
-async def safe_delete_message(message: Message):
- try:
- try:
- await message.delete()
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.delete()
- except MessageDeleteForbidden:
- logger.debug(f"Failed to delete message {message.id} due to permissions.")
- except Exception as e:
- logger.error(f"Error deleting message {message.id}: {e}", exc_info=True)
-
-
-async def send_dm_links(bot: Client, user_id: int, links: Dict[str, Any], chat_title: str):
- try:
- dm_text = MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + \
- MSG_LINKS.format(
- file_name=links['media_name'],
- file_size=links['media_size'],
- download_link=links['online_link'],
- stream_link=links['stream_link']
- )
- try:
- await bot.send_message(
- chat_id=user_id,
- text=dm_text,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- reply_markup=get_link_buttons(links)
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await bot.send_message(
- chat_id=user_id,
- text=dm_text,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- reply_markup=get_link_buttons(links)
- )
- except Exception as e:
- logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True)
-
-
-async def send_link(msg: Message, links: Dict[str, Any]):
- try:
- await msg.reply_text(
- MSG_LINKS.format(
- file_name=links['media_name'],
- file_size=links['media_size'],
- download_link=links['online_link'],
- stream_link=links['stream_link']
- ),
- quote=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- disable_web_page_preview=True,
- reply_markup=get_link_buttons(links)
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await msg.reply_text(
- MSG_LINKS.format(
- file_name=links['media_name'],
- file_size=links['media_size'],
- download_link=links['online_link'],
- stream_link=links['stream_link']
- ),
- quote=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- disable_web_page_preview=True,
- reply_markup=get_link_buttons(links)
- )
-
-
-@StreamBot.on_message(filters.command("link") & ~filters.private)
-async def link_handler(bot: Client, msg: Message, **kwargs):
- async def _actual_link_handler(client: Client, message: Message, **handler_kwargs):
- shortener_val = await validate_request_common(client, message)
- if shortener_val is None:
- return
- if message.from_user and not await db.is_user_exist(message.from_user.id):
- invite_link = f"https://t.me/{client.me.username}?start=start"
- try:
- await message.reply_text(
- MSG_ERROR_START_BOT.format(invite_link=invite_link),
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]),
- quote=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_ERROR_START_BOT.format(invite_link=invite_link),
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.MARKDOWN,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]),
- quote=True
- )
- return
-
- if (message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]
- and not await is_admin(client, message.chat.id)):
- await reply_user_err(message, MSG_ERROR_NOT_ADMIN)
- return
-
- if not message.reply_to_message or not message.reply_to_message.media:
- await reply_user_err(
- message,
- MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE)
- return
-
- notification_msg = handler_kwargs.get('notification_msg')
-
- parts = message.text.split()
- num_files = 1
- if len(parts) > 1:
- try:
- num_files = int(parts[1])
- if not 1 <= num_files <= Var.MAX_BATCH_FILES:
- await reply_user_err(
- message,
- MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES))
- return
- except ValueError:
- await reply_user_err(message, MSG_ERROR_INVALID_NUMBER)
- return
-
- try:
- status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True)
- shortener_val = handler_kwargs.get('shortener', shortener_val)
- if num_files == 1:
- await process_single(client, message, message.reply_to_message, status_msg, shortener_val, notification_msg=notification_msg)
- else:
- await process_batch(client, message, message.reply_to_message.id, num_files, status_msg, shortener_val, notification_msg=notification_msg)
-
- await handle_rate_limited_request(bot, msg, _actual_link_handler, **kwargs)
-
-
-@StreamBot.on_message(
- filters.private &
- filters.incoming &
- (filters.document | filters.video | filters.photo | filters.audio |
- filters.voice | filters.animation | filters.video_note),
- group=4
-)
-async def private_receive_handler(bot: Client, msg: Message, **kwargs):
- async def _actual_private_receive_handler(client: Client, message: Message, **handler_kwargs):
- shortener_val = await validate_request_common(client, message)
- if shortener_val is None:
- return
- if not message.from_user:
- return
-
- notification_msg = handler_kwargs.get('notification_msg')
-
- await log_newusr(client, message.from_user.id, message.from_user.first_name or "")
- try:
- status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True)
- await process_single(client, message, message, status_msg, shortener_val, notification_msg=notification_msg)
-
- await handle_rate_limited_request(bot, msg, _actual_private_receive_handler, **kwargs)
-
-
-@StreamBot.on_message(
- filters.channel &
- filters.incoming &
- (filters.document | filters.video | filters.audio) &
- ~filters.chat(Var.BIN_CHANNEL),
- group=-1
-)
-async def channel_receive_handler(bot: Client, msg: Message):
- async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs):
- if not Var.CHANNEL:
- return
- notification_msg = handler_kwargs.get('notification_msg')
-
- is_banned_statically = hasattr(Var, 'BANNED_CHANNELS') and message.chat.id in Var.BANNED_CHANNELS
- is_banned_dynamically = await db.is_channel_banned(message.chat.id) is not None
-
- if is_banned_statically or is_banned_dynamically:
- try:
- try:
- await client.leave_chat(message.chat.id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await client.leave_chat(message.chat.id)
- except Exception as e:
- logger.error(f"Error leaving banned channel {message.chat.id}: {e}")
- return
- if not await is_admin(client, message.chat.id):
- logger.debug(
- f"Bot is not admin in channel {message.chat.id} "
- f"({message.chat.title or 'Unknown'}). Ignoring message.")
- return
-
+
+ await handle_rate_limited_request(bot, msg, _actual_link_handler, **kwargs)
+
+
+@StreamBot.on_message(
+ filters.private
+ & filters.incoming
+ & (
+ filters.document
+ | filters.video
+ | filters.photo
+ | filters.audio
+ | filters.voice
+ | filters.animation
+ | filters.video_note
+ ),
+ group=4,
+)
+async def private_receive_handler(bot: Client, msg: Message, **kwargs):
+ async def _actual_private_receive_handler(client: Client, message: Message, **handler_kwargs):
+ shortener_val = await validate_request_common(client, message)
+ if shortener_val is None:
+ return
+ if not message.from_user:
+ return
+
+ notification_msg = handler_kwargs.get("notification_msg")
+
+ await log_newusr(client, message.from_user.id, message.from_user.first_name or "")
try:
+ status_msg = await reply_safe(message, MSG_PROCESSING_FILE)
+ except Exception as e:
+ logger.error(f"Could not send processing status: {e}", exc_info=True)
+ return
+ await process_single(
+ client, message, message, status_msg, shortener_val, notification_msg=notification_msg
+ )
+
+ await handle_rate_limited_request(bot, msg, _actual_private_receive_handler, **kwargs)
+
+
+@StreamBot.on_message(
+ filters.channel
+ & filters.incoming
+ & (filters.document | filters.video | filters.audio)
+ & ~filters.chat(Var.BIN_CHANNEL),
+ group=-1,
+)
+async def channel_receive_handler(bot: Client, msg: Message):
+ async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs):
+ if not Var.CHANNEL:
+ return
+ # Fail closed: channels have no from_user for gates.
+ if Var.PRIVATE_MODE:
+ logger.debug(f"Ignoring channel post from {message.chat.id} (PRIVATE_MODE).")
+ return
+ notification_msg = handler_kwargs.get("notification_msg")
+
+ is_banned_statically = message.chat.id in Var.BANNED_CHANNELS
+ # Fail-open is deliberate: leave_chat is irreversible.
+ is_banned_dynamically = (
+ await flags.get_or_load(
+ ("banned_channel", message.chat.id),
+ lambda: db.is_channel_banned(message.chat.id),
+ )
+ is not None
+ )
+
+ if is_banned_statically or is_banned_dynamically:
+ try:
+ await tg_call(client.leave_chat, message.chat.id, retries=1)
+ except Exception as e:
+ logger.error(f"Error leaving banned channel {message.chat.id}: {e}")
+ return
+ if not await is_admin(client, message.chat.id):
+ logger.debug(
+ f"Bot is not admin in channel {message.chat.id} "
+ f"({message.chat.title or 'Unknown'}). Ignoring message."
+ )
+ return
+
+ try:
+ from Thunder.utils.decorators import get_shortener_status
+
shortener_val = await get_shortener_status(client, message)
- canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(message, fwd_media)
+ canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(
+ message, fwd_media, client
+ )
if reused_existing and stored_msg:
- await safe_delete_message(stored_msg)
+ await delete_safe(stored_msg)
stored_msg = None
if canonical_record:
links = await gen_canonical_links(
file_name=canonical_record["file_name"],
file_size=int(canonical_record.get("file_size", 0) or 0),
public_hash=canonical_record["public_hash"],
- shortener=shortener_val
+ shortener=shortener_val,
)
reply_to_message_id = int(canonical_record["canonical_message_id"])
else:
@@ -358,46 +435,36 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha
stored_msg = await fwd_media(message)
if not stored_msg:
logger.error(
- f"Failed to forward media from channel {message.chat.id}. Ignoring.")
+ f"Failed to forward media from channel {message.chat.id}. Ignoring."
+ )
return
links = await gen_links(stored_msg, shortener=shortener_val)
reply_to_message_id = stored_msg.id
source_info = message.chat.title or "Unknown Channel"
- # When we reused an existing canonical BIN copy, stored_msg is intentionally
- # None so send_channel_links falls back to StreamBot.send_message(...,
- # reply_to_message_id=...) and keeps the log threaded to the canonical message.
if notification_msg:
try:
- try:
- await notification_msg.edit_text(
- MSG_NEW_FILE_REQUEST.format(
- source_info=source_info,
- id_=message.chat.id,
- online_link=links['online_link'],
- stream_link=links['stream_link']
- ),
- disable_web_page_preview=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await notification_msg.edit_text(
- MSG_NEW_FILE_REQUEST.format(
- source_info=source_info,
- id_=message.chat.id,
- online_link=links['online_link'],
- stream_link=links['stream_link']
- ),
- disable_web_page_preview=True
- )
+ await edit_safe(
+ notification_msg,
+ MSG_NEW_FILE_REQUEST.format(
+ source_info=html.escape(source_info),
+ id_=message.chat.id,
+ online_link=html.escape(links["online_link"]),
+ stream_link=html.escape(links["stream_link"]),
+ ),
+ disable_web_page_preview=True,
+ parse_mode=enums.ParseMode.HTML,
+ )
except Exception as e:
- logger.error(f"Error editing notification message with links: {e}", exc_info=True)
+ logger.error(
+ f"Error editing notification message with links: {e}", exc_info=True
+ )
await send_channel_links(
links,
source_info,
message.chat.id,
target_msg=stored_msg,
- reply_to_message_id=reply_to_message_id
+ reply_to_message_id=reply_to_message_id,
)
else:
await send_channel_links(
@@ -405,58 +472,71 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha
source_info,
message.chat.id,
target_msg=stored_msg,
- reply_to_message_id=reply_to_message_id
+ reply_to_message_id=reply_to_message_id,
+ )
+
+ try:
+ await tg_call(message.edit_reply_markup, reply_markup=get_link_buttons(links))
+ except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid):
+ logger.debug(
+ f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead."
+ )
+ await send_link(message, links)
+ except Exception as e:
+ logger.error(
+ f"Error editing reply markup for message {message.id}: {e}", exc_info=True
)
-
- try:
- try:
- await message.edit_reply_markup(reply_markup=get_link_buttons(links))
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.edit_reply_markup(reply_markup=get_link_buttons(links))
- except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid):
- logger.debug(f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead.")
- await send_link(message, links)
- except Exception as e:
- logger.error(f"Error editing reply markup for message {message.id}: {e}", exc_info=True)
- await send_link(message, links)
- except Exception as e:
- logger.error(f"Error in _actual_channel_receive_handler for message {message.id}: {e}", exc_info=True)
-
- rl_user_id = None
- if msg.sender_chat and msg.sender_chat.id:
- rl_user_id = msg.sender_chat.id
- elif msg.from_user:
- rl_user_id = msg.from_user.id
-
- if rl_user_id is None:
- logger.debug(f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly.")
- await _actual_channel_receive_handler(bot, msg)
- return
-
- await handle_rate_limited_request(bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id)
-
-
-async def process_single(
- bot: Client,
- msg: Message,
- file_msg: Message,
- status_msg: Message,
- shortener_val: bool,
- original_request_msg: Optional[Message] = None,
- notification_msg: Optional[Message] = None
+ await send_link(message, links)
+ except IngestContended:
+ # must reach the rate limiter, which requeues instead of letting
+ # this handler occupy an executor worker until the claim TTL
+ raise
+ except Exception as e:
+ logger.error(
+ f"Error in _actual_channel_receive_handler for message {message.id}: {e}",
+ exc_info=True,
+ )
+
+ rl_user_id = None
+ if msg.sender_chat and msg.sender_chat.id:
+ rl_user_id = msg.sender_chat.id
+ elif msg.from_user:
+ rl_user_id = msg.from_user.id
+
+ if rl_user_id is None:
+ logger.debug(
+ f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly."
+ )
+ await _actual_channel_receive_handler(bot, msg)
+ return
+
+ await handle_rate_limited_request(
+ bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id
+ )
+
+
+async def process_single(
+ bot: Client,
+ msg: Message,
+ file_msg: Message,
+ status_msg: Message | None,
+ shortener_val: bool,
+ original_request_msg: Message | None = None,
+ notification_msg: Message | None = None,
):
try:
- canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(file_msg, fwd_media)
+ canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(
+ file_msg, fwd_media, bot
+ )
if reused_existing and stored_msg:
- await safe_delete_message(stored_msg)
+ await delete_safe(stored_msg)
stored_msg = None
if canonical_record:
links = await gen_canonical_links(
file_name=canonical_record["file_name"],
file_size=int(canonical_record.get("file_size", 0) or 0),
public_hash=canonical_record["public_hash"],
- shortener=shortener_val
+ shortener=shortener_val,
)
canonical_reply_id = int(canonical_record["canonical_message_id"])
else:
@@ -464,205 +544,300 @@ async def process_single(
stored_msg = await fwd_media(file_msg)
if not stored_msg:
logger.error(f"Failed to forward media for message {file_msg.id}. Skipping.")
+ # unstick the status message: without this the user's chat
+ # keeps saying "Processing your file..." forever
+ if status_msg:
+ try:
+ await edit_safe(status_msg, MSG_ERROR_PROCESSING_MEDIA)
+ except Exception:
+ pass
return None
links = await gen_links(stored_msg, shortener=shortener_val)
canonical_reply_id = stored_msg.id
- if notification_msg:
- result = await safe_edit_message(
- notification_msg,
- MSG_LINKS.format(
- file_name=links['media_name'],
- file_size=links['media_size'],
- download_link=links['online_link'],
- stream_link=links['stream_link']
- ),
- parse_mode=enums.ParseMode.MARKDOWN,
- disable_web_page_preview=True,
- reply_markup=get_link_buttons(links)
- )
- if not result:
- await send_link(msg, links)
+ if notification_msg:
+ try:
+ await edit_safe(
+ notification_msg,
+ format_link_message(links),
+ parse_mode=enums.ParseMode.HTML,
+ disable_web_page_preview=True,
+ reply_markup=get_link_buttons(links),
+ )
+ except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid):
+ pass
+ except Exception:
+ await send_link(msg, links)
elif not original_request_msg:
- await send_link(msg, links)
- if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user and not original_request_msg:
- await send_dm_links(bot, msg.from_user.id, links, msg.chat.title or "the chat")
- source_msg = original_request_msg if original_request_msg else msg
- source_info = ""
- source_id = 0
- if source_msg.from_user:
- source_info = source_msg.from_user.full_name
- if not source_info:
- source_info = f"@{source_msg.from_user.username}" if source_msg.from_user.username else "Unknown User"
- source_id = source_msg.from_user.id
+ await send_link(msg, links)
+ if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user and not original_request_msg:
+ await send_dm_links(bot, msg.from_user.id, links, msg.chat.title or "the chat")
+ source_msg = original_request_msg if original_request_msg else msg
+ source_info = ""
+ source_id = 0
+ if source_msg.from_user:
+ source_info = source_msg.from_user.full_name
+ if not source_info:
+ source_info = (
+ f"@{source_msg.from_user.username}"
+ if source_msg.from_user.username
+ else "Unknown User"
+ )
+ source_id = source_msg.from_user.id
elif source_msg.chat.type == enums.ChatType.CHANNEL:
source_info = source_msg.chat.title or "Unknown Channel"
source_id = source_msg.chat.id
if source_info and source_id:
+ await send_channel_links(
+ links,
+ source_info,
+ source_id,
+ target_msg=stored_msg,
+ reply_to_message_id=canonical_reply_id,
+ )
+ if status_msg:
+ await delete_safe(status_msg)
+ return links
+ except IngestContended:
+ # reach the rate limiter so it can requeue; see _actual_channel_receive_handler
+ raise
+ except Exception as e:
+ logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True)
+ if status_msg:
+ await edit_safe(status_msg, MSG_ERROR_PROCESSING_MEDIA)
+
+ await notify_own(
+ bot, MSG_CRITICAL_ERROR.format(error=str(e), error_id=secrets.token_hex(6))
+ )
+ return None
+
+
+def _chunk_pause(total_chunks: int) -> float:
+ """Seconds to wait before each chunk message.
+
+ Scales with how MANY messages this batch will produce, not how many are
+ left: the limiter's window fills progressively, so decaying the pause as
+ the batch drains would send the tail faster than the head -- backwards.
+
+ Two pressure signals, because only one of them works in the shipped config:
+
+ - The breaker, which is authoritative when it has anything to say. But
+ _breaker_rate() returns 0 unless GLOBAL_RPS_LIMIT > 0 or
+ GLOBAL_RATE_LIMIT is on, and both ship disabled -- so on a default
+ deployment the breaker never reports anything and a branch that only
+ consulted it would be dead code wearing a comment about deferring to the
+ limiter.
+ - Queue depth, which is always populated regardless of configuration. A
+ backlog means work is waiting, so our own burst should not add to it.
+ """
+ scaled = MESSAGE_DELAY * max(1.0, min(total_chunks, 8) / _CHUNK_PAUSE_REFERENCE_CHUNKS)
+
+ breaker = rate_limiter.breaker
+ if breaker.rate > 0:
+ scaled = max(scaled, breaker.retry_after())
+ else:
+ queued = rate_limiter.occupancy()["queued"]
+ if queued:
+ scaled = max(scaled, MESSAGE_DELAY * min(queued, 4))
+
+ return min(MAX_CHUNK_PAUSE_SECONDS, scaled)
+
+
+async def _deliver_chunks(bot: Client, msg: Message, links: list[str]) -> tuple[int, int, int]:
+ """Send the batch links, mirroring each chunk to the requester's DM.
+
+ Returns ``(chat_failed, dm_failed, total_chunks)``. A failed send is
+ counted and reported rather than logged and forgotten -- an undelivered
+ chunk is links the user never receives, and in a private chat there is no
+ other indication that anything went wrong.
+ """
+ chunks = _batch_chunks(links)
+ dm_failed = 0
+ chat_failed = 0
+ mirror = msg.chat.type != enums.ChatType.PRIVATE and msg.from_user
+ for idx, chunk in enumerate(chunks):
+ chunk_text = _batch_chunk_text(chunk)
+ try:
+ await reply_safe(
+ msg, chunk_text, disable_web_page_preview=True, parse_mode=enums.ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error sending batch chunk: {e}", exc_info=True)
+ chat_failed += 1
+ if mirror:
try:
- await send_channel_links(
- links,
- source_info,
- source_id,
- target_msg=stored_msg,
- reply_to_message_id=canonical_reply_id
+ await send_safe(
+ bot,
+ msg.from_user.id,
+ text=MSG_DM_BATCH_PREFIX.format(
+ chat_title=html.escape((msg.chat.title or "the chat")[:_MAX_TITLE_CHARS])
+ )
+ + "\n"
+ + chunk_text,
+ disable_web_page_preview=True,
+ parse_mode=enums.ParseMode.HTML,
)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await send_channel_links(
- links,
- source_info,
- source_id,
- target_msg=stored_msg,
- reply_to_message_id=canonical_reply_id
+ except Exception as e:
+ logger.error(f"Error sending DM in batch: {e}", exc_info=True)
+ dm_failed += 1
+ if idx + 1 < len(chunks):
+ await asyncio.sleep(_chunk_pause(len(chunks)))
+ return chat_failed, dm_failed, len(chunks)
+
+
+async def process_batch(
+ bot: Client,
+ msg: Message,
+ start_id: int,
+ count: int,
+ status_msg: Message,
+ shortener_val: bool,
+ notification_msg: Message | None = None,
+):
+ """Worker-pooled batch, order-preserving.
+
+ Stops STARTING new items after deadline (30+2n); in-flight unbounded.
+ """
+ total_started = time.monotonic()
+ deadline = total_started + _BATCH_DEADLINE_BASE + 2 * count
+ worker_count = max(1, int(Var.BATCH_WORKERS))
+
+ ids: list[int] = list(range(start_id, start_id + count))
+ results: dict[int, dict[str, Any] | None] = {}
+ skipped = 0
+ counters = {"done": 0, "failed": 0}
+
+ # ---- pre-fetch phase (chunked) ----
+ fetched: dict[int, Message | None] = {}
+ fetch_failed: set[int] = set()
+ for chunk_start in range(0, count, BATCH_SIZE):
+ if time.monotonic() > deadline:
+ break
+ chunk_ids = ids[chunk_start : chunk_start + BATCH_SIZE]
+ try:
+ fetched_msgs = await tg_call(bot.get_messages, msg.chat.id, chunk_ids, retries=1)
+ if fetched_msgs is None:
+ messages = []
+ elif isinstance(fetched_msgs, Message): # single id -> single message
+ messages = [fetched_msgs]
+ else:
+ messages = list(fetched_msgs)
+ except Exception as e:
+ logger.error(f"Error getting messages in batch: {e}", exc_info=True)
+ fetch_failed.update(chunk_ids)
+ messages = []
+ for i, mid in enumerate(chunk_ids):
+ m = messages[i] if i < len(messages) else None
+ if i >= len(messages):
+ fetch_failed.add(mid)
+ fetched[mid] = None
+ else:
+ fetched[mid] = m if (m is not None and getattr(m, "media", None)) else None
+
+ queue: asyncio.Queue[int | None] = asyncio.Queue()
+ for mid in ids:
+ queue.put_nowait(mid)
+ for _ in range(worker_count):
+ queue.put_nowait(None)
+
+ async def progress_edit():
+ try:
+ await edit_safe(
+ status_msg,
+ MSG_PROCESSING_STATUS.format(
+ processed=counters["done"] - counters["failed"],
+ total=count,
+ failed=counters["failed"],
+ ),
+ )
+ except MessageNotModified:
+ pass
+ except Exception:
+ pass
+
+ async def worker():
+ nonlocal skipped
+ while True:
+ mid = await queue.get()
+ if mid is None:
+ return
+ if time.monotonic() > deadline:
+ # deadline-expired counts as failed, not skipped.
+ results[mid] = None
+ counters["failed"] += 1
+ counters["done"] += 1
+ continue
+ m = fetched.get(mid)
+ if mid in fetch_failed:
+ results[mid] = None
+ counters["failed"] += 1
+ elif m is not None:
+ links = await process_single(
+ bot, msg, m, None, shortener_val, original_request_msg=msg
)
- if status_msg:
- await safe_delete_message(status_msg)
- return links
- except Exception as e:
- logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True)
- if status_msg:
- await safe_edit_message(status_msg, MSG_ERROR_PROCESSING_MEDIA)
-
- await notify_own(bot, MSG_CRITICAL_ERROR.format(
- error=str(e),
- error_id=secrets.token_hex(6)
- ))
- return None
-
-
-async def process_batch(
- bot: Client,
- msg: Message,
- start_id: int,
- count: int,
- status_msg: Message,
- shortener_val: bool,
- notification_msg: Optional[Message] = None
-):
- processed = 0
- failed = 0
- links_list = []
- for batch_start in range(0, count, BATCH_SIZE):
- batch_size = min(BATCH_SIZE, count - batch_start)
- batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size))
- try:
- try:
- await status_msg.edit_text(
- MSG_PROCESSING_BATCH.format(
- batch_number=(batch_start // BATCH_SIZE) + 1,
- total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE,
- file_count=batch_size
- )
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await status_msg.edit_text(
- MSG_PROCESSING_BATCH.format(
- batch_number=(batch_start // BATCH_SIZE) + 1,
- total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE,
- file_count=batch_size
- )
- )
- except MessageNotModified:
- pass
- try:
- try:
- messages = await bot.get_messages(msg.chat.id, batch_ids)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- messages = await bot.get_messages(msg.chat.id, batch_ids)
- if messages is None:
- messages = []
- except Exception as e:
- logger.error(f"Error getting messages in batch: {e}", exc_info=True)
- messages = []
- for m in messages:
- if m and m.media:
- links = await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg)
- if links:
- links_list.append(links['online_link'])
- processed += 1
- else:
- failed += 1
- else:
- failed += 1
- if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count:
- try:
- try:
- await status_msg.edit_text(
- MSG_PROCESSING_STATUS.format(
- processed=processed,
- total=count,
- failed=failed
- )
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await status_msg.edit_text(
- MSG_PROCESSING_STATUS.format(
- processed=processed,
- total=count,
- failed=failed
- )
- )
- except MessageNotModified:
- pass
- for i in range(0, len(links_list), LINK_CHUNK_SIZE):
- chunk = links_list[i:i+LINK_CHUNK_SIZE]
- chunk_text = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{chr(10).join(chunk)}"
- try:
- await msg.reply_text(
- chunk_text,
- quote=True,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.HTML
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await msg.reply_text(
- chunk_text,
- quote=True,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.HTML
- )
- if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user:
- try:
- try:
- await bot.send_message(
- chat_id=msg.from_user.id,
- text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.HTML
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await bot.send_message(
- chat_id=msg.from_user.id,
- text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text,
- disable_web_page_preview=True,
- parse_mode=enums.ParseMode.HTML
- )
- except Exception as e:
- logger.error(f"Error sending DM in batch: {e}", exc_info=True)
- await reply_user_err(msg, MSG_ERROR_DM_FAILED)
- if i + LINK_CHUNK_SIZE < len(links_list):
- await asyncio.sleep(MESSAGE_DELAY)
- try:
- await status_msg.edit_text(
- MSG_PROCESSING_RESULT.format(
- processed=processed,
- total=count,
- failed=failed
- )
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await status_msg.edit_text(
- MSG_PROCESSING_RESULT.format(
- processed=processed,
- total=count,
- failed=failed
- )
- )
- if notification_msg:
- await safe_delete_message(notification_msg)
+ results[mid] = links
+ if not links:
+ counters["failed"] += 1
+ else:
+ results[mid] = None
+ skipped += 1
+ counters["done"] += 1
+ if counters["done"] % BATCH_UPDATE_INTERVAL == 0 and counters["done"] < count:
+ await progress_edit()
+
+ try:
+ await edit_safe(status_msg, MSG_PROCESSING_BATCH.format(file_count=count))
+ except Exception as e:
+ logger.debug(f"Could not update batch status message: {e}")
+
+ workers = [asyncio.create_task(worker(), name=f"batch_worker_{i}") for i in range(worker_count)]
+ try:
+ await asyncio.gather(*workers)
+ except BaseException:
+ # process_single used to swallow everything, so gather never saw an
+ # exception. It can now (IngestContended), and gather propagates the
+ # first one while leaving its siblings running -- orphaned tasks that
+ # keep mutating results and still own the status message.
+ for w in workers:
+ w.cancel()
+ await asyncio.gather(*workers, return_exceptions=True)
+ raise
+
+ failed = counters["failed"]
+ processed = sum(1 for r in results.values() if r)
+
+ links_list = [rec["online_link"] for mid in ids if (rec := results.get(mid))]
+ chat_failed_chunks, dm_failed_chunks, total_chunks = await _deliver_chunks(bot, msg, links_list)
+
+ # A chunk that fails to send is a chunk of links the user never receives.
+ # Reporting it is the whole point: the old code only logged and moved on.
+ if chat_failed_chunks:
+ await reply_user_err(
+ msg,
+ MSG_ERROR_BATCH_CHUNK_FAILED.format(
+ failed_chunks=chat_failed_chunks, total_chunks=total_chunks
+ ),
+ )
+ if dm_failed_chunks:
+ await reply_user_err(
+ msg,
+ MSG_ERROR_DM_BATCH_FAILED.format(
+ failed_chunks=dm_failed_chunks, total_chunks=total_chunks
+ ),
+ )
+
+ try:
+ await edit_safe(
+ status_msg,
+ MSG_PROCESSING_RESULT.format(
+ processed=processed,
+ total=count,
+ skipped=skipped,
+ failed=failed,
+ ),
+ )
+ except MessageNotModified:
+ pass
+ except Exception as e:
+ logger.debug(f"Could not finalize batch status: {e}")
+ if notification_msg:
+ await delete_safe(notification_msg)
diff --git a/Thunder/bot/registry.py b/Thunder/bot/registry.py
new file mode 100644
index 00000000..540585eb
--- /dev/null
+++ b/Thunder/bot/registry.py
@@ -0,0 +1,60 @@
+"""Single command registry: drives menu, /help, and AGENTS.md (tested)."""
+
+from typing import NamedTuple
+
+from pyrogram.types import BotCommand
+
+from Thunder.utils.messages import MSG_HELP_COMMAND_ROW
+
+
+class Command(NamedTuple):
+ name: str
+ description: str
+ owner_only: bool = False
+
+
+COMMANDS: list[Command] = [
+ Command("start", "Start the bot and get a welcome message"),
+ Command("help", "Show help and usage instructions"),
+ Command("link", "(Group) Generate a direct link for a file or batch"),
+ Command("dc", "Retrieve the data center (DC) information of a user or file"),
+ Command("ping", "Check the bot's status and response time"),
+ Command("about", "Get information about the bot"),
+ Command("users", "Show the total number of users", owner_only=True),
+ Command("status", "View bot details and current workload", owner_only=True),
+ Command("stats", "View usage statistics and resource consumption", owner_only=True),
+ Command("broadcast", "Send a message to all users", owner_only=True),
+ Command("ban", "Ban a user", owner_only=True),
+ Command("unban", "Unban a user", owner_only=True),
+ Command("log", "Send redacted bot logs", owner_only=True),
+ Command("restart", "Update and restart the bot", owner_only=True),
+ Command("shell", "Execute a shell command (requires ENABLE_SHELL)", owner_only=True),
+ Command("authorize", "Grant permanent access to a user", owner_only=True),
+ Command("deauthorize", "Remove permanent access from a user", owner_only=True),
+ Command("listauth", "List all authorized users", owner_only=True),
+]
+
+# Telegram's set_bot_commands description limit
+_MAX_DESC_LEN = 256
+
+
+def bot_commands() -> list[BotCommand]:
+ """Menu surface: owner-only commands are hidden."""
+ return [
+ BotCommand(cmd.name, cmd.description[:_MAX_DESC_LEN])
+ for cmd in COMMANDS
+ if not cmd.owner_only
+ ]
+
+
+def help_command_rows() -> str:
+ """/help surface: same public commands, same order."""
+ rows = ""
+ for cmd in COMMANDS:
+ if cmd.owner_only:
+ continue
+ rows += MSG_HELP_COMMAND_ROW.format(name=cmd.name, description=cmd.description)
+ return rows
+
+
+__all__ = ["Command", "COMMANDS", "bot_commands", "help_command_rows"]
diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py
index ab450eec..96a6e5eb 100644
--- a/Thunder/server/__init__.py
+++ b/Thunder/server/__init__.py
@@ -1,10 +1,74 @@
-# Thunder/server/__init__.py
-
-from aiohttp import web
-from .stream_routes import routes
-
-
-async def web_server():
- web_app = web.Application(client_max_size=50 * 1024 * 1024)
- web_app.add_routes(routes)
- return web_app
+import re
+import time
+
+from aiohttp import web
+
+from Thunder.utils.logger import hash_path_token, logger
+
+from .stream_routes import routes
+
+# access log middleware -- method, redacted path, status, bytes, duration.
+
+
+# Single pass: sequential rules would let the id-first rule re-match 8-hex
+# pseudonyms from the canonical rule (~39% end in two digits), double-hashing them.
+# The (?=/|$) tail anchor also covers the no-filename legacy shape
+# (/AbCdEf12345), whose bare hash+id is the whole link credential.
+_PSEUDONYM_RE = re.compile(
+ r"(?P(?<=/f/)(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{20}))"
+ r"|(?P(?<=/watch/)[a-zA-Z0-9_-]{6}\d+)"
+ r"|(?P(?<=/activate/)[A-Za-z0-9_-]{43})"
+ r"|(?P(?<=/)[a-zA-Z0-9_-]{6}\d+(?=/|$))"
+)
+
+# "…" marks legacy segments whose id suffix was consumed by the hash;
+# canonical and /activate/ tokens keep their bare pseudonym.
+_TRUNCATED_GROUPS = frozenset({"legacy", "idfirst"})
+
+
+def _pseudonymize(m: re.Match) -> str:
+ token = m.group(0)
+ suffix = "…" if m.lastgroup in _TRUNCATED_GROUPS else ""
+ return hash_path_token(token) + suffix
+
+
+def _redact_path(path: str) -> str:
+ return _PSEUDONYM_RE.sub(_pseudonymize, path)
+
+
+def _escape_control_chars(path: str) -> str:
+ """Neutralize log forging: request.path is percent-DECODED, so a request
+ for /f/x/%0A[INFO] fake would otherwise inject forged log lines."""
+ return "".join(ch if ch.isprintable() else f"%{ord(ch):02X}" for ch in path)
+
+
+@web.middleware
+async def access_log_middleware(request: web.Request, handler):
+ start = time.perf_counter()
+ response: web.Response | None = None
+ try:
+ response = await handler(request)
+ except web.HTTPException as e:
+ response = e
+ raise
+ finally:
+ duration_ms = (time.perf_counter() - start) * 1000
+ try:
+ # response is None for non-HTTP exceptions; getattr(None, ...) then yields 500
+ status = getattr(response, "status", 500)
+ size = getattr(response, "content_length", None)
+ logger.info(
+ f'{request.remote} "{request.method} '
+ f'{_escape_control_chars(_redact_path(request.path))}" '
+ f"{status} {size if size is not None else '-'} {duration_ms:.1f}ms"
+ )
+ except Exception:
+ pass
+ return response
+
+
+async def web_server():
+ # GET-only server -- no request bodies, so no client_max_size cap.
+ web_app = web.Application(middlewares=[access_log_middleware])
+ web_app.add_routes(routes)
+ return web_app
diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py
index b25ef7ec..fcae6cd0 100644
--- a/Thunder/server/exceptions.py
+++ b/Thunder/server/exceptions.py
@@ -1,7 +1,46 @@
-# Thunder/server/exceptions.py
-
-class InvalidHash(Exception):
- pass
-
-class FileNotFound(Exception):
- pass
+class InvalidHash(Exception):
+ pass
+
+
+class FileNotFound(Exception):
+ """The file/record is genuinely gone. Safe to self-heal a record on."""
+
+
+class TelegramUnavailable(Exception):
+ """Transient Telegram-side failure (FloodWait-exhaustion, timeout,
+ transport error). MUST NOT trigger record self-healing -- raising it
+ as FileNotFound made a Telegram brownout delete every vault record
+ requested during the outage. Route handlers map this to 503."""
+
+
+# The shared HTTP error contract lives here because this module is the bottom
+# of the server package: it imports nothing, so both stream_routes and
+# render_template can depend on it without a cycle. render_template has to
+# import it lazily (importing any Thunder.server submodule runs the package
+# __init__, which imports stream_routes, which imports render_template).
+
+CORS_HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
+ "Access-Control-Allow-Headers": "Range, Content-Type",
+ "Access-Control-Expose-Headers": "Content-Length, Content-Range, Content-Disposition",
+}
+
+# Every error response carries CORS (readable browser errors) + no-store
+# (a cached failure must never outlive the underlying problem).
+ERROR_HEADERS = {**CORS_HEADERS, "Cache-Control": "no-store"}
+RETRY_5 = {**ERROR_HEADERS, "Retry-After": "5"}
+RETRY_2 = {**ERROR_HEADERS, "Retry-After": "2"}
+
+
+def unavailable(text: str, *, retry_after: str = "5"):
+ """503 + Retry-After + no-store, from one place.
+
+ Every 5xx the server emits must carry no-store, so a CDN or a browser does
+ not cache a transient failure. Hand-rolling these headers per call site is
+ how one of them ended up missing it.
+ """
+ from aiohttp import web
+
+ headers = {"Retry-After": retry_after}
+ return web.HTTPServiceUnavailable(text=text, headers={**ERROR_HEADERS, **headers})
diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py
index febfd11d..2c209afe 100644
--- a/Thunder/server/stream_routes.py
+++ b/Thunder/server/stream_routes.py
@@ -1,22 +1,39 @@
-# Thunder/server/stream_routes.py
-
+import asyncio
+import contextlib
import re
import secrets
import time
-from urllib.parse import quote, unquote
+from collections.abc import Mapping
+from urllib.parse import quote, quote_plus, unquote
from aiohttp import web
+from pymongo.errors import PyMongoError
+from pyrogram.errors import FloodWait
+from pyrogram.types import Message
-from Thunder import __version__, StartTime
+from Thunder import StartTime, __version__
from Thunder.bot import StreamBot, multi_clients, work_loads
-from Thunder.server.exceptions import FileNotFound, InvalidHash
+from Thunder.server.exceptions import (
+ CORS_HEADERS as _CORS_HEADERS,
+)
+from Thunder.server.exceptions import (
+ ERROR_HEADERS,
+ RETRY_5,
+ FileNotFound,
+ InvalidHash,
+ TelegramUnavailable,
+ unavailable,
+)
from Thunder.utils.bot_utils import quote_media_name
from Thunder.utils.canonical_files import (
+ LEGACY_PUBLIC_HASH_LENGTH,
PUBLIC_HASH_LENGTH,
+ forget_stale_record,
get_file_by_hash,
+ touch_buffer_stats,
update_cached_file_id,
)
-from Thunder.utils.custom_dl import ByteStreamer
+from Thunder.utils.custom_dl import CHUNK_SIZE, ByteStreamer
from Thunder.utils.file_properties import get_media
from Thunder.utils.logger import logger
from Thunder.utils.render_template import render_media_page, render_page
@@ -25,26 +42,37 @@
routes = web.RouteTableDef()
+# Legacy 6-char capability hash family (kept while ENABLE_LEGACY_LINKS=on)
SECURE_HASH_LENGTH = 6
-CHUNK_SIZE = 1024 * 1024
-MAX_CONCURRENT_PER_CLIENT = 8
+# Per-client admission cap
+MAX_CONCURRENT_PER_CLIENT = max(1, Var.MAX_CONCURRENT_STREAMS)
OVERLOAD_RETRY_AFTER_SECONDS = 2
RANGE_REGEX = re.compile(r"^bytes=(?P\d*)-(?P\d*)$")
-PATTERN_HASH_FIRST = re.compile(
- rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
+PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$")
-VALID_HASH_REGEX = re.compile(r'^[a-zA-Z0-9_-]+$')
-VALID_PUBLIC_HASH_REGEX = re.compile(rf'^[0-9a-f]{{{PUBLIC_HASH_LENGTH}}}$')
+VALID_HASH_REGEX = re.compile(r"^[a-zA-Z0-9_-]+$")
+# Both hash families validate side-by-side forever (20 = legacy links,
+# 32 = new ingestions), so existing links never break.
+VALID_PUBLIC_HASH_REGEX = re.compile(
+ rf"^[0-9a-f]{{{LEGACY_PUBLIC_HASH_LENGTH}}}$|^[0-9a-f]{{{PUBLIC_HASH_LENGTH}}}$"
+)
VALID_DISPOSITIONS = {"inline", "attachment"}
+_ASCII_FALLBACK_RE = re.compile(r"[^A-Za-z0-9._ -]")
+
+CORS_HEADERS = _CORS_HEADERS
+
+# Every error response carries CORS (readable browser errors) + no-store
+# (a cached failure must never outlive the underlying problem). Defined once in
+# server.exceptions so render_template can shed with the same headers.
+_ERROR_HEADERS = ERROR_HEADERS
+_RETRY_5 = RETRY_5
-CORS_HEADERS = {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
- "Access-Control-Allow-Headers": "Range, Content-Type, *",
- "Access-Control-Expose-Headers": "Content-Length, Content-Range, Content-Disposition",
-}
-streamers = {}
+def _unavailable(text: str) -> web.HTTPServiceUnavailable:
+ return unavailable(text)
+
+
+streamers: dict[int, "ByteStreamer"] = {}
def get_streamer(client_id: int) -> ByteStreamer:
@@ -53,56 +81,56 @@ def get_streamer(client_id: int) -> ByteStreamer:
return streamers[client_id]
-def parse_media_request(path: str, query: dict) -> tuple[int, str]:
- clean_path = unquote(path).strip('/')
+def parse_media_request(path: str, query: Mapping[str, str]) -> tuple[int, str]:
+ clean_path = unquote(path).strip("/")
+ # ids are regex-derived \d+; the guards below only catch absurd 4300+
+ # digit ids against int's max_str_digits limit
match = PATTERN_HASH_FIRST.match(clean_path)
if match:
try:
message_id = int(match.group(2))
- secure_hash = match.group(1)
- if (len(secure_hash) == SECURE_HASH_LENGTH and
- VALID_HASH_REGEX.match(secure_hash)):
- return message_id, secure_hash
- except ValueError as e:
- raise InvalidHash(f"Invalid message ID format in path: {e}") from e
+ except ValueError:
+ raise InvalidHash("Invalid message ID format in path") from None
+ secure_hash = match.group(1)
+ if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
+ return message_id, secure_hash
match = PATTERN_ID_FIRST.match(clean_path)
if match:
try:
message_id = int(match.group(1))
- secure_hash = query.get("hash", "").strip()
- if (len(secure_hash) == SECURE_HASH_LENGTH and
- VALID_HASH_REGEX.match(secure_hash)):
- return message_id, secure_hash
- else:
- raise InvalidHash("Invalid or missing hash in query parameter")
- except ValueError as e:
- raise InvalidHash(f"Invalid message ID format in path: {e}") from e
+ except ValueError:
+ raise InvalidHash("Invalid message ID format in path") from None
+ secure_hash = query.get("hash", "").strip()
+ if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
+ return message_id, secure_hash
+ raise InvalidHash("Invalid or missing hash in query parameter")
raise InvalidHash("Invalid URL structure or missing hash")
def validate_public_hash(public_hash: str) -> str:
secure_hash = public_hash.strip().lower()
- if len(secure_hash) != PUBLIC_HASH_LENGTH or not VALID_PUBLIC_HASH_REGEX.match(secure_hash):
+ if not VALID_PUBLIC_HASH_REGEX.match(secure_hash):
raise InvalidHash("Invalid canonical file hash")
return secure_hash
def select_optimal_client() -> tuple[int, ByteStreamer]:
if not work_loads:
- raise web.HTTPInternalServerError(
- text=("No available clients to handle the request. "
- "Please try again later."),
- headers=CORS_HEADERS,
+ raise web.HTTPServiceUnavailable(
+ text=("No available clients to handle the request. Please try again later."),
+ headers={**_ERROR_HEADERS, "Retry-After": "2"},
)
available_clients = [
- (cid, load) for cid, load in work_loads.items()
- if load < MAX_CONCURRENT_PER_CLIENT]
+ (cid, load) for cid, load in work_loads.items() if load < MAX_CONCURRENT_PER_CLIENT
+ ]
if not available_clients:
+ # admission control: refuse instead of stacking unlimited
+ # handlers on one client; always advertise Retry-After.
loads = list(work_loads.values())
load_range = f"~{min(loads)}–{max(loads)}" if min(loads) != max(loads) else f"~{min(loads)}"
raise web.HTTPServiceUnavailable(
@@ -111,7 +139,7 @@ def select_optimal_client() -> tuple[int, ByteStreamer]:
f"({load_range} active streams). Please retry shortly."
),
headers={
- **CORS_HEADERS,
+ **_ERROR_HEADERS,
"Retry-After": str(OVERLOAD_RETRY_AFTER_SECONDS),
},
)
@@ -125,104 +153,272 @@ def get_content_disposition(request: web.Request) -> str:
return disposition if disposition in VALID_DISPOSITIONS else "attachment"
+def build_content_disposition(disposition: str, filename: str) -> str:
+ """RFC 5987 ``filename*`` plus an ASCII fallback so non-Latin names
+ survive on clients that ignore RFC 5987."""
+ ascii_name = _ASCII_FALLBACK_RE.sub("_", filename).strip() or "file"
+ return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename, safe='')}"
+
+
def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
if not range_header:
return 0, file_size - 1
+ if "," in range_header:
+ # Valid multi-range syntax ("bytes=0-1,3-4") that this server does not
+ # implement. RFC 9110 lets a server ignore a ranges-specifier it cannot
+ # satisfy, and the caller turns a full span into a plain 200. A 400 here
+ # would claim the client's whole request is malformed, which some
+ # players and proxies act on by abandoning the transfer.
+ return 0, file_size - 1
+
match = RANGE_REGEX.fullmatch(range_header)
if not match:
- raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}")
+ # Genuinely unparseable input is a real client error. Constant body: the
+ # header value is attacker-controlled and must not be reflected.
+ raise web.HTTPBadRequest(text="Invalid range header", headers=_ERROR_HEADERS)
start_str = match.group("start")
end_str = match.group("end")
if start_str:
start = int(start_str)
end = int(end_str) if end_str else file_size - 1
+ # RFC 7233: last-byte-pos >= length means "rest of the representation" --
+ # clamp, not 416; download managers send fixed-chunk ends without knowing the size.
+ end = min(end, file_size - 1)
else:
if not end_str:
- raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}")
+ raise web.HTTPBadRequest(text="Invalid range header", headers=_ERROR_HEADERS)
suffix_len = int(end_str)
if suffix_len <= 0:
raise web.HTTPRequestRangeNotSatisfiable(
- headers={"Content-Range": f"bytes */{file_size}"})
+ headers={**_ERROR_HEADERS, "Content-Range": f"bytes */{file_size}"}
+ )
start = max(file_size - suffix_len, 0)
end = file_size - 1
- if start < 0 or end >= file_size or start > end:
+ if start >= file_size or start > end:
+ # 416 discipline with Content-Range
raise web.HTTPRequestRangeNotSatisfiable(
- headers={"Content-Range": f"bytes */{file_size}"}
+ headers={**_ERROR_HEADERS, "Content-Range": f"bytes */{file_size}"}
)
return start, end
-def _resolve_unique_id(file_info: dict) -> str:
- unique_id = file_info.get("unique_id") or file_info.get("file_unique_id")
- if not unique_id:
- raise FileNotFound("File unique ID not found in info.")
- return unique_id
-
-
def _resolve_filename(file_info: dict, mime_type: str) -> str:
filename = file_info.get("file_name")
if filename:
return filename
- ext = mime_type.split('/')[-1] if '/' in mime_type else 'bin'
- ext_map = {'jpeg': 'jpg', 'mpeg': 'mp3', 'octet-stream': 'bin'}
+ ext = mime_type.split("/")[-1] if "/" in mime_type else "bin"
+ ext_map = {"jpeg": "jpg", "mpeg": "mp3", "octet-stream": "bin"}
ext = ext_map.get(ext, ext)
return f"file_{secrets.token_hex(4)}.{ext}"
-async def _serve_media_response(
- request: web.Request,
- *,
- file_info: dict,
- streamer: ByteStreamer,
- client_id: int,
- media_ref: int | str,
- fallback_message_id: int | None = None,
- on_fallback_message=None
-):
- file_size = int(file_info.get('file_size', 0) or 0)
- if file_size == 0:
- raise FileNotFound("File size is reported as zero or unavailable.")
+def _resolve_unique_id(file_info: dict) -> str:
+ unique_id = file_info.get("unique_id") or file_info.get("file_unique_id")
+ if not unique_id:
+ raise FileNotFound("File unique ID not found in info.")
+ return unique_id
- range_header = request.headers.get("Range", "")
- start, end = parse_range_header(range_header, file_size)
- content_length = end - start + 1
- if start == 0 and end == file_size - 1:
- range_header = ""
+async def _fetch_file_record(secure_hash: str) -> dict | None:
+ """Canonical-record lookup with brownout mapping: a DB transport
+ failure is NOT absence -- 503 + Retry-After so clients/CDNs retry
+ instead of caching a permanent-looking 404."""
+ try:
+ return await get_file_by_hash(secure_hash)
+ except (PyMongoError, TimeoutError) as e:
+ logger.error(f"Canonical record lookup failed (transport): {e}", exc_info=True)
+ raise _unavailable("File index temporarily unavailable; please retry shortly.") from e
- mime_type = file_info.get('mime_type') or 'application/octet-stream'
- filename = _resolve_filename(file_info, mime_type)
- disposition = get_content_disposition(request)
- headers = {
- "Content-Type": mime_type,
- "Content-Length": str(content_length),
- "Content-Disposition": (
- f"{disposition}; filename*=UTF-8''{quote(filename, safe='')}"),
- "Accept-Ranges": "bytes",
- "Cache-Control": "public, max-age=31536000",
- "Connection": "keep-alive",
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Headers": "Range, Content-Type, *",
- "Access-Control-Expose-Headers": (
- "Content-Length, Content-Range, Content-Disposition"),
- "X-Content-Type-Options": "nosniff"
- }
+@contextlib.asynccontextmanager
+async def _route_ladder(label: str):
+ """Shared outer ladder: client errors -> 404, Telegram unavailability
+ (FloodWait/transport, P2-6) -> uniform 503 + Retry-After, HTTP
+ pass-through, the rest -> error-id 500 (no internals in the body)."""
+ try:
+ yield
+ except (InvalidHash, FileNotFound) as e:
+ logger.debug(f"{label}: {type(e).__name__} - {e}")
+ raise web.HTTPNotFound(
+ text="Resource not found",
+ headers=_ERROR_HEADERS,
+ ) from e
+ except (TelegramUnavailable, FloodWait) as e:
+ logger.warning(f"{label}: Telegram unavailable ({type(e).__name__})")
+ raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e
+ except web.HTTPException as e:
+ # A 5xx here is either load shedding (_preview_rpc_slot) or an outage
+ # already logged further up, and the client already has the status and
+ # the Retry-After. Logging one line per shed request means an
+ # unauthenticated caller who can trigger the shed can fill the log
+ # file that /log uploads -- the flood just moves from ERROR to
+ # WARNING. Only 4xx is worth a line.
+ if e.status < 500:
+ logger.warning(f"HTTP exception in {label}: {e}")
+ else:
+ logger.debug(f"{label}: shedding/broken with {e.status}: {e}")
+ raise
+ except Exception as e:
+ error_id = secrets.token_hex(6)
+ logger.error(f"{label} error {error_id}: {e}", exc_info=True)
+ raise web.HTTPInternalServerError(
+ text=f"An unexpected server error occurred: {error_id}",
+ headers=_ERROR_HEADERS,
+ ) from e
+
+
+@contextlib.asynccontextmanager
+async def _admission_ladder(client_id: int, label: str):
+ """Shared post-admission ladder: the slot is released on every exit
+ path; Telegram unavailability maps to 503 + Retry-After with a constant
+ body (the exception text carries internal state).
+
+ Yields the IDEMPOTENT release closure rather than decrementing directly, and
+ callers hand the same closure to the body generator and its watchdog. A
+ failure between "watchdog created" and "Response returned" used to be
+ released twice -- once by the ladder here, once by the watchdog at its
+ deadline -- driving work_loads negative, which makes select_optimal_client
+ believe there is spare capacity. One closure, released once, no ordering
+ hazard.
+ """
+ release = _slot_release(client_id)
+ try:
+ yield release
+ except (FileNotFound, InvalidHash):
+ release()
+ raise
+ except TelegramUnavailable as e:
+ release()
+ logger.warning(f"{label}: Telegram unavailable: {e}")
+ raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e
+ except FloodWait as e:
+ # defense in depth: get_message converts exhaustions already, but any
+ # raw FloodWait reaching admission must shed as 503, never 500
+ release()
+ logger.warning(f"{label}: Telegram FloodWait: {e.value}s")
+ raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e
+ except web.HTTPException as e:
+ release()
+ logger.debug(f"Client HTTP error in {label}: {e}")
+ raise
+ except Exception as e:
+ release()
+ error_id = secrets.token_hex(6)
+ logger.error(f"{label} error {error_id}: {e}", exc_info=True)
+ raise web.HTTPInternalServerError(
+ text=f"Server error during streaming: {error_id}", headers=_ERROR_HEADERS
+ ) from e
+ except BaseException:
+ # cancellation (client disconnect) must still release the slot,
+ # or abandoned tickets pile up into false 503s
+ release()
+ raise
- if range_header:
- headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
- if request.method == 'HEAD':
+# A stream slot is released in the body generator's `finally`. If the client
+# opens the connection and then stops reading, the transport's write buffer
+# fills, the generator is suspended at `yield` forever, and `finally` never
+# runs: the slot leaks permanently. EXECUTOR_WORKERS of those and the client
+# 503s forever with no in-flight request to show for it.
+#
+# Bounding the total transfer time caps the leak. It is a WALL-CLOCK deadline,
+# not a stall detector, and there is no idle check anywhere in it: any client
+# sustaining less than size/1800s gets cut mid-transfer, with Content-Length
+# already sent, and must resume with a Range. Concretely, at the 1800s cap that
+# is 1.14 MiB/s for a 2 GiB file and 2.28 MiB/s for 4 GiB -- fine on a wired
+# connection, not fine on a congested mobile link. A prompt answer to "this
+# client is not reading" needs progress-based reaping of live transfers, which is
+# a larger change than this branch should absorb. Stated here rather than
+# dressed up as idle-only, because an earlier version of this comment claimed
+# the deadline "only bites on a socket that has been idle", which is false.
+_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND = 64 * 1024
+_STREAM_MIN_SECONDS = 120.0
+_STREAM_MAX_SECONDS = 1800.0
+
+
+def _stream_deadline_seconds(file_size: int) -> float:
+ """How long this transfer may take before the slot is reclaimed."""
+ need = max(0.0, file_size) / _STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND
+ return min(_STREAM_MAX_SECONDS, max(_STREAM_MIN_SECONDS, need))
+
+
+async def _reclaim_slot_when_stalled(completed, deadline_seconds, release, body):
+ """Give the slot back if the transfer is still running at the deadline.
+
+ A watchdog rather than a timeout inside the body generator: a stalled
+ reader leaves the generator suspended at a `yield`, so a timeout scoped to
+ the generator can never fire -- the consumer's await is what is pending, and
+ cancelling that task unwinds nothing inside the generator. A separate task
+ does not care where the generator is sitting.
+
+ The slot IS always returned; that is the leak this exists to close.
+
+ Stopping the upstream fetch is best-effort and is NOT guaranteed. `aclose()`
+ only works on a generator suspended at a `yield`. While a consumer is
+ actively pulling -- which is the normal state, and exactly what a slow
+ upstream looks like -- the generator is suspended inside
+ `await streamer.stream_file(...)` and `aclose()` raises RuntimeError. The
+ fetch then keeps running until the consumer's own teardown or the GC closes
+ it. That is a bounded annoyance, not a second slot leak: the counter is
+ correct either way.
+
+ Returns when `completed` is set (the stream finished normally) or when this
+ task is cancelled by the generator's own cleanup.
+ """
+ try:
+ await asyncio.wait_for(completed.wait(), timeout=deadline_seconds)
+ return
+ except TimeoutError:
+ pass
+ except asyncio.CancelledError:
+ return
+ release()
+ if body.ag_running:
+ # a consumer is mid-__anext__; closing now would raise. Not an error --
+ # the consumer owns the generator and will close it.
+ logger.debug("stream slot reclaimed at its deadline; body still in flight")
+ return
+ # CancelledError is deliberately NOT suppressed here: swallowing it would
+ # hide a real cancellation of this watchdog as a quiet success.
+ await body.aclose()
+
+
+def _slot_release(client_id: int):
+ """An idempotent release of one admission slot.
+
+ Two paths give the slot back -- the body generator's `finally` and the
+ stall watchdog -- and either may get there first. Releasing twice drives
+ the counter negative, which makes select_optimal_client believe there is
+ spare capacity; that is worse than the leak this exists to prevent. So the
+ release is made once, here, and both paths call it.
+ """
+ released = False
+
+ def release() -> None:
+ nonlocal released
+ if released:
+ return
+ released = True
work_loads[client_id] -= 1
- return web.Response(
- status=206 if range_header else 200,
- headers=headers
- )
+
+ return release
+
+
+def _make_body(streamer, media_ref, start, content_length, release_slot, deadline_seconds):
+ """Build the response body generator and its slot watchdog.
+
+ Returned separately from the Response so the streaming path can be driven
+ directly -- aiohttp wraps the generator in a payload and it is no longer
+ iterable from outside, which is exactly the object whose stalled state we
+ need to test.
+ """
+ completed = asyncio.Event()
+ holder: dict = {}
async def stream_generator():
try:
@@ -233,8 +429,6 @@ async def stream_generator():
media_ref,
offset=start,
limit=content_length,
- fallback_message_id=fallback_message_id,
- on_fallback_message=on_fallback_message
):
if bytes_to_skip > 0:
if len(chunk) <= bytes_to_skip:
@@ -254,197 +448,323 @@ async def stream_generator():
if bytes_sent >= content_length:
break
finally:
- work_loads[client_id] -= 1
+ completed.set()
+ watchdog = holder.get("task")
+ if watchdog is not None and not watchdog.done():
+ watchdog.cancel()
+ release_slot()
+
+ body = stream_generator()
+ holder["task"] = asyncio.create_task(
+ _reclaim_slot_when_stalled(completed, deadline_seconds, release_slot, body),
+ name="stream_slot_watchdog",
+ )
+ return body
- return web.Response(
- status=206 if range_header else 200,
- body=stream_generator(),
- headers=headers
+
+async def _serve_media_response(
+ request: web.Request,
+ *,
+ file_info: dict,
+ streamer: ByteStreamer,
+ client_id: int,
+ media_ref: int | Message,
+ release_slot,
+):
+ file_size = int(file_info.get("file_size", 0) or 0)
+ if file_size <= 0:
+ # <= not ==: a corrupt or legacy record carrying a negative size would
+ # otherwise produce a negative Content-Length
+ raise FileNotFound("File size is reported as zero or unavailable.")
+
+ range_header = request.headers.get("Range", "")
+ start, end = parse_range_header(range_header, file_size)
+ content_length = end - start + 1
+
+ if start == 0 and end == file_size - 1:
+ range_header = ""
+
+ mime_type = file_info.get("mime_type") or "application/octet-stream"
+ filename = _resolve_filename(file_info, mime_type)
+ disposition = get_content_disposition(request)
+
+ headers = {
+ "Content-Type": mime_type,
+ "Content-Length": str(content_length),
+ "Content-Disposition": build_content_disposition(disposition, filename),
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "public, max-age=31536000",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": ("Content-Length, Content-Range, Content-Disposition"),
+ "X-Content-Type-Options": "nosniff",
+ # sandbox: attacker-uploaded SVG/HTML served inline must not execute
+ # in site origin; players fetch bytes cross-origin, unaffected
+ "Content-Security-Policy": "sandbox",
+ }
+
+ if range_header:
+ headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
+
+ if request.method == "HEAD":
+ release_slot()
+ return web.Response(status=206 if range_header else 200, headers=headers)
+
+ # release_slot is REQUIRED, not defaulted to None. The ladder owns one
+ # closure per request and shares it with the body generator and the
+ # watchdog; minting a second here would decrement twice. A default of None
+ # advertised a mode that only worked for GET -- HEAD calls release_slot()
+ # before any fallback could run, so omitting it was a TypeError and a 500.
+
+ body = _make_body(
+ streamer,
+ media_ref,
+ start,
+ content_length,
+ release_slot,
+ _stream_deadline_seconds(content_length),
)
+ return web.Response(status=206 if range_header else 200, body=body, headers=headers)
+
@routes.get("/", allow_head=True)
async def root_redirect(request):
raise web.HTTPFound("https://github.com/fyaz05/FileToLink")
+@routes.get("/health", allow_head=True)
+async def health_endpoint(request):
+ """Zero-dependency liveness endpoint (keepalive now targets this)."""
+ return web.json_response(
+ {"status": "ok"},
+ headers={"Cache-Control": "no-store"},
+ )
+
+
+_ACTIVATION_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{43}")
+
+
+def _is_activation_token(token: str) -> bool:
+ """Strict shape check for tokens issued by tokens.generate().
+
+ Activation tokens are ``secrets.token_urlsafe(32)`` -- exactly 43
+ URL-safe base64 chars. Validating the shape before interpolation into
+ the t.me redirect keeps untrusted input out of the Location header
+ (CodeQL py/url-redirection) and skips a DB roundtrip for garbage input.
+ """
+ return bool(_ACTIVATION_TOKEN_RE.fullmatch(token))
+
+
+def _telegram_activate_url(username: str, token: str) -> str:
+ """Build the t.me deep link with the token percent-encoded.
+
+ ``quote_plus(safe='')`` guarantees the token can only ever occupy the
+ query-value slot (no ``&``/``#``/CR/LF can reshape the URL) -- a no-op
+ for shape-valid tokens, which are already URL-safe.
+
+ Built with ``+`` concatenation, not an f-string: the redirect target is
+ then provably constant-prefixed ("https://t.me/"), which is exactly the
+ safety property CodeQL's py/url-redirection sanitizer model recognizes
+ (right-operand-of-concat sanitizer; formatting is not modeled). Do not
+ "modernize" this back to an f-string -- it would re-flag the alert.
+ """
+ return "https://t.me/" + username + "?start=" + quote_plus(token, safe="")
+
+
+@routes.get("/activate/{token}")
+async def activate_endpoint(request: web.Request):
+ """Web entry for activation -- shorteners can produce real URLs."""
+ token = request.match_info.get("token", "").strip()
+ username = getattr(StreamBot, "username", None)
+ if not token or not _is_activation_token(token):
+ raise web.HTTPBadRequest(text="Malformed activation token", headers=_ERROR_HEADERS)
+ if not username:
+ raise web.HTTPServiceUnavailable(
+ text="Bot is still starting; try again shortly.",
+ headers=_RETRY_5,
+ )
+ raise web.HTTPFound(
+ _telegram_activate_url(username, token),
+ headers={"Cache-Control": "no-store"},
+ )
+
+
@routes.get("/status", allow_head=True)
async def status_endpoint(request):
uptime = time.time() - StartTime
total_load = sum(work_loads.values())
-
workload_distribution = {str(k): v for k, v in sorted(work_loads.items())}
+ dc_id = getattr(getattr(StreamBot, "session", None), "dc_id", None)
+
return web.json_response(
{
"server": {
"status": "operational",
"version": __version__,
- "uptime": get_readable_time(uptime)
+ "uptime": get_readable_time(uptime),
},
"telegram_bot": {
- "username": f"@{StreamBot.username}",
- "active_clients": len(multi_clients)
+ "username": f"@{getattr(StreamBot, 'username', None) or 'unknown'}",
+ "active_clients": len(multi_clients),
+ "dc_id": dc_id,
},
"resources": {
"total_workload": total_load,
- "workload_distribution": workload_distribution
- }
+ "workload_distribution": workload_distribution,
+ "touch_buffer": touch_buffer_stats(),
+ },
+ },
+ headers={
+ # status is dynamic -- never serve it from cache. CORS stays so
+ # browser dashboards can read it; nothing here is secret.
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "no-store",
},
- headers={"Access-Control-Allow-Origin": "*"}
)
-@routes.options("/status")
-async def status_options(request: web.Request):
- return web.Response(headers={
- **CORS_HEADERS,
- "Access-Control-Max-Age": "86400"
- })
-
-
@routes.options(r"/{path:.+}")
async def media_options(request: web.Request):
- return web.Response(headers={
- **CORS_HEADERS,
- "Access-Control-Max-Age": "86400"
- })
+ return web.Response(headers={**CORS_HEADERS, "Access-Control-Max-Age": "86400"})
@routes.get(r"/watch/f/{secure_hash}/{name:.+}", allow_head=True)
async def canonical_media_preview(request: web.Request):
- try:
+ async with _route_ladder("canonical preview"):
secure_hash = validate_public_hash(request.match_info["secure_hash"])
- file_record = await get_file_by_hash(secure_hash, raise_on_error=False)
+ file_record = await _fetch_file_record(secure_hash)
if not file_record:
raise FileNotFound("Canonical file not found")
file_name = file_record.get("file_name") or f"file_{secure_hash}"
src = f"{Var.URL.rstrip('/')}/f/{secure_hash}/{quote_media_name(file_name)}"
- rendered_page = await render_media_page(file_name, src, requested_action='stream')
-
- response = web.Response(
- text=rendered_page,
- content_type='text/html',
- headers={
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Headers": "Range, Content-Type, *",
- "X-Content-Type-Options": "nosniff",
- }
+ rendered_page = await render_media_page(
+ file_name,
+ src,
+ mime_type=file_record.get("mime_type"),
+ size_bytes=file_record.get("file_size") or None,
)
- response.enable_compression()
- return response
- except (InvalidHash, FileNotFound) as e:
- logger.debug(f"Canonical preview error: {type(e).__name__} - {e}", exc_info=True)
- raise web.HTTPNotFound(text="Resource not found") from e
- except Exception as e:
- error_id = secrets.token_hex(6)
- logger.error(f"Canonical preview error {error_id}: {e}", exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"Server error occurred: {error_id}") from e
+
+ return _preview_response(rendered_page)
+
+
+def _preview_response(rendered_page: str) -> web.Response:
+ """Both preview routes serve the same unindexed, compressed player page."""
+ response = web.Response(
+ text=rendered_page,
+ content_type="text/html",
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "X-Content-Type-Options": "nosniff",
+ # player pages are per-file dynamic; keep them unindexed
+ "X-Robots-Tag": "noindex, nofollow",
+ },
+ )
+ response.enable_compression()
+ return response
@routes.get(r"/watch/{path:.+}", allow_head=True)
async def media_preview(request: web.Request):
- try:
+ # the legacy URL family can be switched off explicitly.
+ if not Var.ENABLE_LEGACY_LINKS:
+ raise web.HTTPGone(
+ text="Legacy links are disabled on this server. "
+ "Please re-send the file to the bot to get a fresh link.",
+ headers=_ERROR_HEADERS,
+ )
+ async with _route_ladder("preview"):
path = request.match_info["path"]
message_id, secure_hash = parse_media_request(path, request.query)
- rendered_page = await render_page(
- message_id, secure_hash, requested_action='stream')
-
- response = web.Response(
- text=rendered_page,
- content_type='text/html',
- headers={
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Headers": "Range, Content-Type, *",
- "X-Content-Type-Options": "nosniff",
- }
- )
- response.enable_compression()
- return response
+ rendered_page = await render_page(message_id, secure_hash)
- except (InvalidHash, FileNotFound) as e:
- logger.debug(
- f"Client error in preview: {type(e).__name__} - {e}",
- exc_info=True)
- raise web.HTTPNotFound(text="Resource not found") from e
- except Exception as e:
- error_id = secrets.token_hex(6)
- logger.error(f"Preview error {error_id}: {e}", exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"Server error occurred: {error_id}") from e
+ return _preview_response(rendered_page)
@routes.get(r"/f/{secure_hash}/{name:.+}", allow_head=True)
async def canonical_media_delivery(request: web.Request):
- try:
+ async with _route_ladder("canonical stream"):
secure_hash = validate_public_hash(request.match_info["secure_hash"])
- file_record = await get_file_by_hash(secure_hash, raise_on_error=False)
+ file_record = await _fetch_file_record(secure_hash)
if not file_record:
raise FileNotFound("Canonical file not found")
client_id, streamer = select_optimal_client()
work_loads[client_id] += 1
- try:
+ async with _admission_ladder(client_id, "canonical stream") as release_slot:
+ # called for its side effect: a record without a unique id cannot be
+ # served (there is no vault file to stream), so this is the
+ # availability check for the whole route
_resolve_unique_id(file_record)
media_ref = int(file_record["canonical_message_id"])
- fallback_message_id = int(file_record["canonical_message_id"])
-
- async def persist_refreshed_file_id(message):
- if client_id != 0:
- return
- media = get_media(message)
- new_file_id = getattr(media, "file_id", None) if media else None
- if new_file_id and new_file_id != file_record.get("file_id"):
- try:
- await update_cached_file_id(file_record, new_file_id)
- except Exception as e:
- logger.warning(
- f"Failed to refresh cached file_id for canonical file {secure_hash}: {e}",
- exc_info=True
- )
+
+ # one vault fetch serves both the self-heal check and the
+ # Content-Length verification; the Message is passed on so stream_file does not re-fetch.
+ try:
+ vault_message = await streamer.get_message(media_ref)
+ except FileNotFound:
+ await forget_stale_record(file_record)
+ raise FileNotFound(
+ "Vault message missing; record self-healed, re-upload to regenerate the link"
+ ) from None
+ # TelegramUnavailable (FloodWait/timeout/transport) is NOT proof the
+ # vault message is gone -- must not delete the record; the ladder
+ # maps it to 503.
+
+ media = get_media(vault_message)
+ if not media:
+ await forget_stale_record(file_record)
+ raise FileNotFound("Vault message has no media; record self-healed")
+
+ serve_info = dict(file_record)
+ actual_size = int(getattr(media, "file_size", 0) or 0)
+ if actual_size and actual_size != int(serve_info.get("file_size", 0) or 0):
+ # no capability hash in logs: hash+size would fingerprint the
+ # link for anyone who later reads /log output
+ logger.warning(
+ f"Record size {serve_info.get('file_size')} != vault size {actual_size}; "
+ "serving verified length"
+ )
+ if actual_size:
+ # never tell clients a Content-Length the upstream cannot deliver
+ serve_info["file_size"] = actual_size
+ serve_info.setdefault("mime_type", None)
+ if not serve_info.get("mime_type"):
+ serve_info["mime_type"] = getattr(media, "mime_type", None)
+
+ new_file_id = getattr(media, "file_id", None)
+ if new_file_id and new_file_id != file_record.get("file_id") and client_id == 0:
+ try:
+ await update_cached_file_id(file_record, new_file_id)
+ except Exception as e:
+ logger.warning(
+ f"Failed to refresh cached file_id for a canonical file: {e}",
+ exc_info=True,
+ )
return await _serve_media_response(
request,
- file_info=file_record,
+ file_info=serve_info,
streamer=streamer,
client_id=client_id,
- media_ref=media_ref,
- fallback_message_id=fallback_message_id,
- on_fallback_message=persist_refreshed_file_id
+ media_ref=vault_message,
+ release_slot=release_slot,
)
- except (FileNotFound, InvalidHash):
- work_loads[client_id] -= 1
- raise
- except web.HTTPException as e:
- work_loads[client_id] -= 1
- logger.debug(f"Client HTTP error in canonical stream: {e}")
- raise
- except Exception as e:
- work_loads[client_id] -= 1
- error_id = secrets.token_hex(6)
- logger.error(f"Canonical stream error {error_id}: {e}", exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"Server error during streaming: {error_id}") from e
- except (InvalidHash, FileNotFound) as e:
- logger.debug(f"Canonical client error: {type(e).__name__} - {e}", exc_info=True)
- raise web.HTTPNotFound(text="Resource not found") from e
- except web.HTTPException as e:
- logger.warning(f"HTTP exception in canonical stream: {e}")
- raise
- except Exception as e:
- error_id = secrets.token_hex(6)
- logger.error(f"Canonical server error {error_id}: {e}", exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"An unexpected server error occurred: {error_id}") from e
@routes.get(r"/{path:.+}", allow_head=True)
async def media_delivery(request: web.Request):
- try:
+ # legacy delivery route honors the same switch
+ if not Var.ENABLE_LEGACY_LINKS:
+ raise web.HTTPGone(
+ text="Legacy links are disabled on this server. "
+ "Please re-send the file to the bot to get a fresh link.",
+ headers=_ERROR_HEADERS,
+ )
+ async with _route_ladder("media stream"):
path = request.match_info["path"]
message_id, secure_hash = parse_media_request(path, request.query)
@@ -452,45 +772,20 @@ async def media_delivery(request: web.Request):
work_loads[client_id] += 1
- try:
- file_info = await streamer.get_file_info(message_id)
+ async with _admission_ladder(client_id, "media stream") as release_slot:
+ # one vault fetch serves both the info derivation and the stream:
+ # passing the Message on means stream_file does not re-fetch
+ vault_message = await streamer.get_message(message_id)
+ file_info = streamer.get_file_info_sync(vault_message)
unique_id = _resolve_unique_id(file_info)
if unique_id[:SECURE_HASH_LENGTH] != secure_hash:
- raise InvalidHash(
- "Provided hash does not match file's unique ID.")
+ raise InvalidHash("Provided hash does not match file's unique ID.")
return await _serve_media_response(
request,
file_info=file_info,
streamer=streamer,
client_id=client_id,
- media_ref=message_id
+ media_ref=vault_message,
+ release_slot=release_slot,
)
-
- except (FileNotFound, InvalidHash):
- work_loads[client_id] -= 1
- raise
- except web.HTTPException as e:
- work_loads[client_id] -= 1
- logger.debug(f"Client HTTP error in media stream: {e}")
- raise
- except Exception as e:
- work_loads[client_id] -= 1
- error_id = secrets.token_hex(6)
- logger.error(
- f"Stream error {error_id}: {e}",
- exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"Server error during streaming: {error_id}") from e
-
- except (InvalidHash, FileNotFound) as e:
- logger.debug(f"Client error: {type(e).__name__} - {e}", exc_info=True)
- raise web.HTTPNotFound(text="Resource not found") from e
- except web.HTTPException as e:
- logger.warning(f"HTTP exception in media stream: {e}")
- raise
- except Exception as e:
- error_id = secrets.token_hex(6)
- logger.error(f"Server error {error_id}: {e}", exc_info=True)
- raise web.HTTPInternalServerError(
- text=f"An unexpected server error occurred: {error_id}") from e
diff --git a/Thunder/template/dl.html b/Thunder/template/dl.html
deleted file mode 100644
index a0e3bb45..00000000
--- a/Thunder/template/dl.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
- Downloading: {{ file_name }}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Thunder/template/req.html b/Thunder/template/req.html
index c538902a..a6c26ef5 100644
--- a/Thunder/template/req.html
+++ b/Thunder/template/req.html
@@ -1,359 +1,425 @@
-
-
-
-
-
-
- {{ heading }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ file_name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ heading }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if kind == 'video' %}
+
+
+ {% elif kind == 'audio' %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% if kind == 'video' %}
+
+
+
+ {% elif kind == 'audio' %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ file_name }}
+
+
+
+
+
+
+
+ {% if kind == 'audio' %}
+
+ {% endif %}
+ {% if kind in ('video', 'audio') %}
+
+ {% endif %}
+ {% if kind == 'video' %}
+
+
+
+
+ {% elif kind == 'audio' %}
+
+
+
+
+ {% elif kind == 'image' %}
+
+
+
+ {% else %}
+
+
+ This file type ({{ mime_type }}) can't be played in the browser.
+ Download
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py
index 10bf3c01..a804768e 100644
--- a/Thunder/utils/bot_utils.py
+++ b/Thunder/utils/bot_utils.py
@@ -1,21 +1,26 @@
-# Thunder/utils/bot_utils.py
-
import asyncio
-from typing import Any, Dict, Optional
+import html
+from typing import Any
from urllib.parse import quote
-
-from pyrogram import Client
-from pyrogram.enums import ChatMemberStatus
-from pyrogram.errors import FloodWait
-from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
- Message, User)
-
-from Thunder.utils.database import db
-from Thunder.utils.file_properties import get_fname, get_fsize, get_hash
-from Thunder.utils.human_readable import humanbytes
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (MSG_BUTTON_GET_HELP, MSG_DC_UNKNOWN,
- MSG_DC_USER_INFO, MSG_NEW_USER)
+
+from pyrogram import Client
+from pyrogram.enums import ChatMemberStatus
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, User
+
+from Thunder.utils.database import db
+from Thunder.utils.file_properties import get_fname, get_fsize, get_hash
+from Thunder.utils.human_readable import humanbytes
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_BUTTON_GET_HELP,
+ MSG_DC_UNKNOWN,
+ MSG_DC_USER_INFO,
+ MSG_FILE_EXPIRY_NOTE,
+ MSG_FILE_TTL_DAYS_LABEL,
+ MSG_LINKS,
+ MSG_NEW_USER,
+)
+from Thunder.utils.safe_call import reply_safe, send_safe, tg_call
from Thunder.utils.shortener import shorten
from Thunder.vars import Var
@@ -24,42 +29,63 @@ def quote_media_name(file_name: str) -> str:
return quote(str(file_name).replace("/", "_"), safe="")
+def format_link_message(links: dict[str, str]) -> str:
+ """Render the MSG_LINKS template, appending the TTL expiry note.
+
+ ``media_name`` is user-controlled and MSG_LINKS is HTML -- escape it
+ so a crafted file name cannot inject markup into the link message.
+ """
+ text = MSG_LINKS.format(
+ file_name=html.escape(str(links["media_name"])),
+ file_size=links["media_size"],
+ # shortener output is external data: escape it or entity parsing breaks
+ download_link=html.escape(str(links["online_link"])),
+ stream_link=html.escape(str(links["stream_link"])),
+ )
+ if Var.FILE_TTL_DAYS > 0:
+ text += "\n\n" + MSG_FILE_EXPIRY_NOTE.format(
+ days=MSG_FILE_TTL_DAYS_LABEL.format(days=Var.FILE_TTL_DAYS)
+ )
+ return text
+
+
async def _build_links(
*,
download_path: str,
stream_path: str,
media_name: str,
media_size: str,
- shortener: bool = True
-) -> Dict[str, str]:
+ shortener: bool = True,
+) -> dict[str, str]:
base_url = Var.URL.rstrip("/")
slink = f"{base_url}{stream_path}"
olink = f"{base_url}{download_path}"
- if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False):
+ if shortener and Var.SHORTEN_MEDIA_LINKS:
try:
s_results = await asyncio.gather(shorten(slink), shorten(olink), return_exceptions=True)
- if not isinstance(s_results[0], Exception):
- slink = s_results[0]
- else:
+ if isinstance(s_results[0], BaseException):
logger.warning(f"Failed to shorten stream_link: {s_results[0]}")
- if not isinstance(s_results[1], Exception):
- olink = s_results[1]
else:
+ slink = s_results[0]
+ if isinstance(s_results[1], BaseException):
logger.warning(f"Failed to shorten online_link: {s_results[1]}")
+ else:
+ olink = s_results[1]
except Exception as e:
logger.error(f"Error during link shortening: {e}")
- return {"stream_link": slink, "online_link": olink, "media_name": media_name, "media_size": media_size}
+ return {
+ "stream_link": slink,
+ "online_link": olink,
+ "media_name": media_name,
+ "media_size": media_size,
+ }
async def gen_canonical_links(
- *,
- file_name: str,
- file_size: int,
- public_hash: str,
- shortener: bool = True
-) -> Dict[str, str]:
+ *, file_name: str, file_size: int, public_hash: str, shortener: bool = True
+) -> dict[str, str]:
media_name = str(file_name)
media_size = humanbytes(file_size)
encoded_name = quote_media_name(media_name)
@@ -68,72 +94,66 @@ async def gen_canonical_links(
stream_path=f"/watch/f/{public_hash}/{encoded_name}",
media_name=media_name,
media_size=media_size,
- shortener=shortener
+ shortener=shortener,
)
+async def notify_own(cli: Client, txt: str):
+ targets = [Var.OWNER_ID]
+ if isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
+ targets.append(Var.BIN_CHANNEL)
+
+ async def _notify(chat_id: int) -> None:
+ try:
+ await send_safe(cli, chat_id, text=txt)
+ except Exception as e:
+ logger.warning(f"Could not notify chat {chat_id}: {e}")
+
+ # _notify never raises, so plain gather suffices
+ await asyncio.gather(*(_notify(t) for t in targets))
+
-async def notify_ch(cli: Client, txt: str):
- if not (hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0):
- return
- try:
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
-
-
-async def notify_own(cli: Client, txt: str):
- o_ids = Var.OWNER_ID if isinstance(Var.OWNER_ID, (list, tuple, set)) else [Var.OWNER_ID]
-
- async def send_with_flood_wait(chat_id: int):
- try:
- await cli.send_message(chat_id=chat_id, text=txt)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=chat_id, text=txt)
-
- tasks = [send_with_flood_wait(oid) for oid in o_ids]
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
- tasks.append(send_with_flood_wait(Var.BIN_CHANNEL))
- await asyncio.gather(*tasks, return_exceptions=True)
-
-
-async def reply_user_err(msg: Message, err_txt: str):
- try:
- await msg.reply_text(
- text=err_txt,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
- disable_web_page_preview=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await msg.reply_text(
- text=err_txt,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
- disable_web_page_preview=True
- )
-
-
-async def log_newusr(cli: Client, uid: int, fname: str):
- try:
- is_new = await db.add_user(uid)
- if not is_new:
- return
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
- try:
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
- except Exception as e:
- logger.error(f"Database error in log_newusr for user {uid}: {e}")
-
-
-async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]:
+async def reply_user_err(msg: Message, err_txt: str):
+ try:
+ await reply_safe(
+ msg,
+ err_txt,
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]
+ ),
+ disable_web_page_preview=True,
+ )
+ except Exception as e:
+ logger.error(f"Error sending user error reply: {e}", exc_info=True)
+
+
+async def log_newusr(cli: Client, uid: int, fname: str):
+ try:
+ is_new = await db.add_user(uid)
+ if not is_new:
+ return
+ if isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
+ try:
+ await send_safe(
+ cli,
+ Var.BIN_CHANNEL,
+ # MSG_NEW_USER is HTML; first_name is user-controlled
+ text=MSG_NEW_USER.format(first_name=html.escape(str(fname or "")), user_id=uid),
+ )
+ except Exception as e:
+ logger.warning(f"Could not log new user {uid}: {e}")
+ except Exception as e:
+ logger.error(f"Database error in log_newusr for user {uid}: {e}")
+
+
+async def gen_links(fwd_msg: Message, shortener: bool = True) -> dict[str, str]:
fid = fwd_msg.id
m_name_raw = get_fname(fwd_msg)
- m_name = m_name_raw.decode('utf-8', errors='replace') if isinstance(m_name_raw, bytes) else str(m_name_raw)
+ m_name = (
+ m_name_raw.decode("utf-8", errors="replace")
+ if isinstance(m_name_raw, bytes)
+ else str(m_name_raw)
+ )
m_size_hr = humanbytes(get_fsize(fwd_msg))
enc_fname = quote_media_name(m_name)
f_hash = get_hash(fwd_msg)
@@ -142,57 +162,63 @@ async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]:
stream_path=f"/watch/{f_hash}{fid}/{enc_fname}",
media_name=m_name,
media_size=m_size_hr,
- shortener=shortener
+ shortener=shortener,
)
-
-
-async def gen_dc_txt(usr: User) -> str:
- dc_id_val = usr.dc_id if usr.dc_id is not None else MSG_DC_UNKNOWN
- return MSG_DC_USER_INFO.format(user_name=usr.first_name or 'User', user_id=usr.id, dc_id=dc_id_val)
-
-
-async def get_user(cli: Client, qry: Any) -> Optional[User]:
- if isinstance(qry, str):
- if qry.startswith('@'):
- try:
- return await cli.get_users(qry)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(qry)
- elif qry.isdigit():
- try:
- return await cli.get_users(int(qry))
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(int(qry))
- elif isinstance(qry, int):
- try:
- return await cli.get_users(qry)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(qry)
- return None
-
-
-async def is_admin(cli: Client, chat_id_val: int) -> bool:
- try:
- member = await cli.get_chat_member(chat_id_val, cli.me.id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- member = await cli.get_chat_member(chat_id_val, cli.me.id)
- except Exception:
- return False
- except Exception:
- return False
- if member is None:
- return False
- return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
-
-
-async def reply(msg: Message, **kwargs):
- try:
- return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
+
+
+async def gen_dc_txt(usr: User) -> str:
+ dc_id_val = usr.dc_id if usr.dc_id is not None else MSG_DC_UNKNOWN
+ # user_name lands in an HTML label; escape it (attacker-controlled)
+ return MSG_DC_USER_INFO.format(
+ user_name=html.escape(usr.first_name or "User", quote=False),
+ user_id=usr.id,
+ dc_id=dc_id_val,
+ )
+
+
+async def get_user(cli: Client, qry: Any) -> User | None:
+ # @username stays a str; numeric strings become ints -- one shared lookup path
+ if isinstance(qry, str) and not qry.startswith("@") and qry.isdigit():
+ qry = int(qry)
+ if isinstance(qry, (str, int)):
+ try:
+ result = await tg_call(cli.get_users, qry)
+ except Exception as e:
+ logger.debug(f"get_users failed for {qry}: {e}")
+ return None
+ if isinstance(result, list): # defensive: pyrogram returns a list for list inputs
+ return result[0] if result else None
+ return result
+ return None
+
+
+async def is_admin(cli: Client, chat_id_val: int) -> bool:
+ try:
+ # cli.me is populated after client.start(); id 0 matches no chat member
+ me_id = cli.me.id if cli.me else 0
+ member = await tg_call(cli.get_chat_member, chat_id_val, me_id, retries=1)
+ except Exception:
+ return False
+ if member is None:
+ return False
+ return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
+
+
+async def reply(msg: Message, **kwargs):
+ kwargs.setdefault("disable_web_page_preview", True)
+ return await reply_safe(msg, kwargs.pop("text", ""), **kwargs)
+
+
+__all__ = [
+ "quote_media_name",
+ "format_link_message",
+ "gen_canonical_links",
+ "notify_own",
+ "reply_user_err",
+ "log_newusr",
+ "gen_links",
+ "gen_dc_txt",
+ "get_user",
+ "is_admin",
+ "reply",
+]
diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py
index 044dcd42..f0d6985b 100644
--- a/Thunder/utils/broadcast.py
+++ b/Thunder/utils/broadcast.py
@@ -1,189 +1,318 @@
-# Thunder/utils/broadcast.py
-
-import asyncio
-import os
-import time
-
-from pyrogram.client import Client
-from pyrogram.enums import ParseMode
-from pyrogram.errors import (ChatWriteForbidden, FloodWait, PeerIdInvalid, UserDeactivated,
- UserIsBlocked, ChannelInvalid, InputUserDeactivated)
-from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
- Message)
-
-from Thunder.utils.database import db
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (
- MSG_INVALID_BROADCAST_CMD,
- MSG_BROADCAST_START,
- MSG_BUTTON_CANCEL_BROADCAST,
- MSG_BROADCAST_COMPLETE
-)
-from Thunder.utils.time_format import get_readable_time
-
-
-broadcast_ids = {}
-
-async def broadcast_message(client: Client, message: Message, mode: str = "all"):
- if not message.reply_to_message:
- try:
- await message.reply_text(MSG_INVALID_BROADCAST_CMD)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(MSG_INVALID_BROADCAST_CMD)
- except Exception as e:
- logger.error(f"Error sending invalid broadcast message: {e}", exc_info=True)
- return
-
- broadcast_id = os.urandom(3).hex()
- stats = {"total": 0, "success": 0, "failed": 0, "deleted": 0, "cancelled": False}
- broadcast_ids[broadcast_id] = stats
-
- try:
- status_msg = await message.reply_text(
- MSG_BROADCAST_START,
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
- ]])
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- status_msg = await message.reply_text(
- MSG_BROADCAST_START,
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
- ]])
- )
- except Exception as e:
- logger.error(f"Error starting broadcast: {e}", exc_info=True)
- del broadcast_ids[broadcast_id]
- return
-
- start_time = time.time()
-
- try:
- if mode == "authorized":
- stats["total"] = await db.get_authorized_users_count()
- cursor = await db.get_authorized_users_cursor()
- elif mode == "regular":
- stats["total"] = await db.get_regular_users_count()
- cursor = await db.get_regular_users_cursor()
- else:
- stats["total"] = await db.total_users_count()
- cursor = await db.get_all_users()
- except Exception as e:
- logger.error(f"Error getting user cursor for mode '{mode}': {e}", exc_info=True)
- try:
- await status_msg.edit_text(f"❌ **Broadcast Failed:** Unable to fetch users for mode '{mode}'.")
- except Exception:
- pass
- del broadcast_ids[broadcast_id]
- return
-
- if stats["total"] == 0:
- try:
- await status_msg.edit_text(f"ℹ️ **No users found for broadcast mode:** `{mode}`")
- except Exception:
- pass
- del broadcast_ids[broadcast_id]
- return
-
- async def do_broadcast():
- async for user in cursor:
- if stats["cancelled"]:
- break
-
- user_id = user.get('id') or user.get('user_id')
- if not user_id:
- logger.warning(f"Skipping user with no ID: {user}")
- continue
-
- try:
- success = False
- for attempt in range(3):
- try:
- await message.reply_to_message.copy(user_id)
- stats["success"] += 1
- success = True
- break
- except FloodWait as e:
- if attempt < 2:
- await asyncio.sleep(e.value)
- else:
- logger.warning(f"FloodWait persisted for user {user_id} after 3 attempts, last wait: {e.value}s")
- stats["failed"] += 1
- break
-
- except (UserDeactivated, UserIsBlocked, PeerIdInvalid, ChatWriteForbidden, ChannelInvalid, InputUserDeactivated) as e:
- if isinstance(e, ChannelInvalid):
- recipient_type = "Channel"
- reason = "invalid channel"
- elif isinstance(e, InputUserDeactivated):
- recipient_type = "User"
- reason = "deactivated account"
- elif isinstance(e, UserIsBlocked):
- recipient_type = "User"
- reason = "blocked the bot"
- elif isinstance(e, UserDeactivated):
- recipient_type = "User"
- reason = "deactivated account"
- elif isinstance(e, PeerIdInvalid):
- recipient_type = "Recipient"
- reason = "invalid ID"
- elif isinstance(e, ChatWriteForbidden):
- recipient_type = "Chat"
- reason = "write forbidden"
- else:
- recipient_type = "Recipient"
- reason = f"error: {type(e).__name__}"
-
- logger.warning(f"{recipient_type} {user_id} removed due to {reason}")
-
- is_authorized = await db.is_user_authorized(user_id)
- if not is_authorized:
- await db.delete_user(user_id)
- stats["deleted"] += 1
- else:
- stats["failed"] += 1
-
- except Exception as e:
- logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True)
- stats["failed"] += 1
-
- try:
- await status_msg.delete()
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- await status_msg.delete()
- except Exception:
- pass
- except Exception as e:
- logger.debug(f"Could not delete status message: {e}")
-
- completion_msg = MSG_BROADCAST_COMPLETE.format(
- elapsed_time=get_readable_time(int(time.time() - start_time)),
- total_users=stats["total"],
- successes=stats["success"],
- failures=stats["failed"],
- deleted_accounts=stats["deleted"]
- )
-
- if stats["cancelled"]:
- completion_msg = "🛑 **Broadcast Cancelled**\n\n" + completion_msg
-
- try:
- await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
- except Exception as e:
- logger.error(f"Failed to send completion message after FloodWait: {e}", exc_info=True)
- except Exception as e:
- logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True)
-
- if broadcast_id in broadcast_ids:
- del broadcast_ids[broadcast_id]
-
- asyncio.create_task(do_broadcast())
+import asyncio
+import os
+import time
+from typing import Any
+
+from pyrogram.client import Client
+from pyrogram.enums import ParseMode
+from pyrogram.errors import (
+ ChannelInvalid,
+ ChatWriteForbidden,
+ FloodWait,
+ InputUserDeactivated,
+ PeerIdInvalid,
+ RPCError,
+ UserDeactivated,
+ UserIsBlocked,
+)
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from Thunder.utils.database import db
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_BROADCAST_CANCELLED_PREFIX,
+ MSG_BROADCAST_COMPLETE,
+ MSG_BROADCAST_FAILED_USERS,
+ MSG_BROADCAST_INTERRUPTED_PREFIX,
+ MSG_BROADCAST_NO_USERS,
+ MSG_BROADCAST_PROGRESS,
+ MSG_BROADCAST_START,
+ MSG_BUTTON_CANCEL_BROADCAST,
+ MSG_INVALID_BROADCAST_CMD,
+)
+from Thunder.utils.safe_call import reply_safe, tg_call
+from Thunder.utils.time_format import get_readable_time
+from Thunder.vars import Var
+
+broadcast_ids: dict[str, dict[str, Any]] = {}
+
+# Errors that mean the recipient will never be reachable again.
+_PERMANENT_ERRORS = (
+ UserDeactivated,
+ UserIsBlocked,
+ PeerIdInvalid,
+ ChatWriteForbidden,
+ ChannelInvalid,
+ InputUserDeactivated,
+)
+
+# Closed mapping for the exact exception set above -- no dead fallback branch.
+_PERMANENT_ERROR_REASONS: dict[type[Exception], tuple[str, str]] = {
+ ChannelInvalid: ("Channel", "invalid channel"),
+ InputUserDeactivated: ("User", "deactivated account"),
+ UserIsBlocked: ("User", "blocked the bot"),
+ UserDeactivated: ("User", "deactivated account"),
+ PeerIdInvalid: ("Recipient", "invalid ID"),
+ ChatWriteForbidden: ("Chat", "write forbidden"),
+}
+
+# pacing between sends per worker + progress-edit cadence
+_BROADCAST_PACE_SECONDS = 0.2
+_PROGRESS_EVERY = 25
+
+# strong refs: CPython only weakly references tasks; unreferenced ones
+# can be garbage-collected mid-run
+_BROADCAST_TASKS: set[asyncio.Task] = set()
+
+
+async def broadcast_message(client: Client, message: Message, mode: str = "all"):
+ if not message.reply_to_message:
+ try:
+ await reply_safe(message, MSG_INVALID_BROADCAST_CMD)
+ except Exception as e:
+ logger.error(f"Error sending invalid broadcast message: {e}", exc_info=True)
+ return
+
+ broadcast_id = os.urandom(3).hex()
+ stats = {"total": 0, "success": 0, "failed": 0, "deleted": 0, "cancelled": False}
+ # unreachable ids; deletes happen after the summary, never in workers
+ prune_ids: list[int] = []
+ broadcast_ids[broadcast_id] = stats
+
+ try:
+ status_msg = await tg_call(
+ message.reply_text,
+ MSG_BROADCAST_START,
+ reply_markup=InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(
+ MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}"
+ )
+ ]
+ ]
+ ),
+ )
+ except Exception as e:
+ logger.error(f"Error starting broadcast: {e}", exc_info=True)
+ del broadcast_ids[broadcast_id]
+ return
+
+ start_time = time.time()
+
+ try:
+ if mode == "authorized":
+ stats["total"] = await db.get_authorized_users_count()
+ cursor = await db.get_authorized_users_cursor()
+ elif mode == "regular":
+ stats["total"] = await db.get_regular_users_count()
+ cursor = await db.get_regular_users_cursor()
+ else:
+ stats["total"] = await db.total_users_count()
+ cursor = await db.get_all_users()
+ except Exception as e:
+ logger.error(f"Error getting user cursor for mode '{mode}': {e}", exc_info=True)
+ try:
+ await tg_call(
+ status_msg.edit_text, MSG_BROADCAST_FAILED_USERS.format(mode=mode), retries=0
+ )
+ except Exception:
+ pass
+ del broadcast_ids[broadcast_id]
+ return
+
+ if stats["total"] == 0:
+ try:
+ await tg_call(status_msg.edit_text, MSG_BROADCAST_NO_USERS.format(mode=mode), retries=0)
+ except Exception:
+ pass
+ del broadcast_ids[broadcast_id]
+ return
+
+ async def do_broadcast():
+ # bounded-queue worker pool; cursor streamed, never materialized;
+ # sends paced, progress edits throttled, cancel stays responsive
+ queue: asyncio.Queue = asyncio.Queue(maxsize=200)
+
+ async def producer():
+ try:
+ async for user in cursor:
+ if stats["cancelled"]:
+ break
+ await queue.put(user)
+ except Exception as e:
+ logger.error(f"Broadcast cursor error: {e}", exc_info=True)
+ finally:
+ # every worker needs exactly one pill or it blocks on get()
+ # forever: retry (workers drain concurrently) but bail out if
+ # all workers are already done
+ for _ in range(worker_count):
+ while True:
+ try:
+ queue.put_nowait(None) # poison pills
+ break
+ except asyncio.QueueFull:
+ if all(w.done() for w in workers):
+ break
+ await asyncio.sleep(0.05)
+
+ async def worker():
+ while True:
+ user = await queue.get()
+ try:
+ if user is None:
+ return
+ if stats["cancelled"]:
+ continue
+ user_id = user.get("id") or user.get("user_id")
+ if not user_id:
+ logger.warning(f"Skipping user with no ID: {user}")
+ continue
+ await _send_one(client, message, user_id, stats, prune_ids)
+ # idempotent modulo _PROGRESS_EVERY: concurrent workers may
+ # skip or duplicate a progress edit; the final completion
+ # message is the source of truth
+ if stats["success"] and stats["success"] % _PROGRESS_EVERY == 0:
+ await _edit_progress(status_msg, stats)
+ finally:
+ if user is not None:
+ await asyncio.sleep(_BROADCAST_PACE_SECONDS)
+
+ worker_count = Var.BROADCAST_WORKERS
+ workers = [
+ asyncio.create_task(worker(), name=f"broadcast_worker_{i}") for i in range(worker_count)
+ ]
+ producer_task = asyncio.create_task(producer(), name="broadcast_producer")
+
+ completed_normally = False
+ worker_crashed = False
+ try:
+ await producer_task
+ results = await asyncio.gather(*workers, return_exceptions=True)
+ for r in results:
+ if isinstance(r, BaseException) and not isinstance(r, asyncio.CancelledError):
+ logger.error(f"Broadcast worker failed: {r!r}")
+ worker_crashed = True
+ completed_normally = True
+ finally:
+ # no worker/producer/status/registry leakage on ANY exit path
+ for t in workers:
+ if not t.done():
+ t.cancel()
+ if not producer_task.done():
+ producer_task.cancel()
+ try:
+ await tg_call(status_msg.delete, retries=0)
+ except Exception:
+ pass
+ broadcast_ids.pop(broadcast_id, None)
+
+ if completed_normally:
+ completion_msg = MSG_BROADCAST_COMPLETE.format(
+ elapsed_time=get_readable_time(int(time.time() - start_time)),
+ mode=mode,
+ total_users=stats["total"],
+ successes=stats["success"],
+ failures=stats["failed"],
+ deleted_accounts=stats["deleted"],
+ )
+
+ if worker_crashed:
+ completion_msg = MSG_BROADCAST_INTERRUPTED_PREFIX + completion_msg
+ elif stats["cancelled"]:
+ completion_msg = MSG_BROADCAST_CANCELLED_PREFIX + completion_msg
+
+ try:
+ await reply_safe(message, completion_msg, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True)
+
+ if prune_ids:
+ # summary is out; prune in the background so sequential
+ # deletes never stall workers
+ prune_task = asyncio.create_task(_prune_collected(prune_ids))
+ _BROADCAST_TASKS.add(prune_task)
+ prune_task.add_done_callback(_BROADCAST_TASKS.discard)
+
+ task = asyncio.create_task(do_broadcast())
+ # hold a strong ref so CPython cannot GC the task mid-run
+ _BROADCAST_TASKS.add(task)
+ task.add_done_callback(_BROADCAST_TASKS.discard)
+
+
+async def _prune_collected(user_ids: list[int]) -> None:
+ """Best-effort background prune of unreachable recipients."""
+ for user_id in user_ids:
+ try:
+ await db.delete_user(user_id)
+ except Exception as e:
+ logger.error(f"Background prune failed for {user_id}: {e}", exc_info=True)
+
+
+async def _send_one(
+ client: Client, message: Message, user_id: int, stats: dict, prune_ids: list[int]
+) -> None:
+ # transient errors get 3 attempts with attempt-squared backoff (1s, 4s);
+ # permanent and FloodWait outcomes return immediately (tg_call already
+ # retried FloodWaits with bounded sleeps)
+ for attempt in range(1, 4):
+ try:
+ # fail-count fast on sustained throttle: a per-recipient fan-out
+ # must not ride out a 600s ingest-style wait per user
+ await tg_call(message.reply_to_message.copy, user_id, retries=1, max_flood_sleep=30.0)
+ stats["success"] += 1
+ return
+ except _PERMANENT_ERRORS as e:
+ recipient_type, reason = _PERMANENT_ERROR_REASONS.get(
+ type(e), ("Recipient", "unreachable")
+ )
+
+ logger.warning(f"{recipient_type} {user_id} removed due to {reason}")
+ try:
+ # raise_on_error=True: a Mongo brownout is not "unauthorized" -- the
+ # fail-soft default would prune live authorized users; counted as a
+ # plain failure below.
+ is_authorized = await db.is_user_authorized(user_id, raise_on_error=True)
+ if not is_authorized:
+ prune_ids.append(user_id)
+ stats["deleted"] += 1
+ else:
+ stats["failed"] += 1
+ except Exception as db_err:
+ logger.error(f"Prune lookup failed for {user_id}: {db_err}", exc_info=True)
+ stats["failed"] += 1
+ return
+ except FloodWait as e:
+ # allowed: classify-only, no sleep (tg_call already retried)
+ logger.warning(f"FloodWait persisted for user {user_id}, last wait: {e.value}s")
+ stats["failed"] += 1
+ return
+ except TimeoutError:
+ # transient transport stall: backoff below, fail after 3 attempts
+ pass
+ except RPCError as e:
+ if getattr(e, "code", 0) not in (500, 502, 503, 504):
+ logger.error(f"Non-retryable RPC error for user {user_id}: {e}", exc_info=True)
+ stats["failed"] += 1
+ return
+ # else: transient server-side error, backoff below
+ except Exception as e:
+ logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True)
+ stats["failed"] += 1
+ return
+ if attempt >= 3:
+ logger.error(f"Transient error persisted for user {user_id}; giving up")
+ stats["failed"] += 1
+ return
+ await asyncio.sleep(attempt * attempt)
+
+
+async def _edit_progress(status_msg: Message, stats: dict) -> None:
+ try:
+ await tg_call(
+ status_msg.edit_text,
+ MSG_BROADCAST_PROGRESS.format(success=stats["success"], total=stats["total"]),
+ retries=0,
+ )
+ except Exception:
+ pass
diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py
index 367171d1..28c31361 100644
--- a/Thunder/utils/canonical_files.py
+++ b/Thunder/utils/canonical_files.py
@@ -1,463 +1,603 @@
-import asyncio
-import datetime
-import hashlib
-from collections import OrderedDict
-from contextlib import asynccontextmanager
-from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
-
-from pyrogram.errors import FloodWait
-from pyrogram.types import Message
-from pymongo.errors import DuplicateKeyError
-
-from Thunder.bot import StreamBot
-from Thunder.utils.database import db
-from Thunder.utils.file_properties import get_fname, get_media, get_uniqid
-from Thunder.utils.logger import logger
-from Thunder.vars import Var
-
-PUBLIC_HASH_LENGTH = 20
-_CACHE_TTL_SECONDS = 600
-_CACHE_MAX_ITEMS = 4096
-_INGEST_CLAIM_TTL_SECONDS = 60
-_INGEST_CLAIM_WAIT_SECONDS = 15
-_INGEST_CLAIM_POLL_SECONDS = 0.5
-_MAX_INGEST_RETRIES = 10
-_CACHE_PRUNE_INTERVAL = 50
-
-_cache_by_unique_id: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-_cache_by_hash: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-_cache_by_message_id: "OrderedDict[int, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-
-_upload_locks: dict[str, asyncio.Lock] = {}
-_upload_lock_counts: dict[str, int] = {}
-_upload_locks_guard = asyncio.Lock()
-_insert_counter: int = 0
-_pending_touches: Dict[str, Tuple[Dict[str, Any], bool]] = {}
-_flush_task: Optional[asyncio.Task] = None
-_FLUSH_DELAY_SECONDS = 10
-
-
-def build_public_hash(file_unique_id: str) -> str:
- return hashlib.sha256(file_unique_id.encode("utf-8")).hexdigest()[:PUBLIC_HASH_LENGTH]
-
-
-def _infer_mime_type(media: Any) -> str:
- mime_type = getattr(media, "mime_type", None)
- if mime_type:
- return mime_type
-
- mime_map = {
- "photo": "image/jpeg",
- "voice": "audio/ogg",
- "videonote": "video/mp4",
- }
- return mime_map.get(type(media).__name__.lower(), "application/octet-stream")
-
-
-def build_file_record(
- stored_message: Message,
- *,
- source_chat_id: Optional[int] = None,
- source_message_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- media = get_media(stored_message)
- file_unique_id = get_uniqid(stored_message)
- if not media or not file_unique_id:
- return None
-
- now = datetime.datetime.now(datetime.timezone.utc)
- return {
- "file_unique_id": file_unique_id,
- "public_hash": build_public_hash(file_unique_id),
- "canonical_message_id": stored_message.id,
- "file_id": getattr(media, "file_id", None),
- "file_name": get_fname(stored_message),
- "mime_type": _infer_mime_type(media),
- "file_size": getattr(media, "file_size", 0) or 0,
- "media_type": type(media).__name__.lower(),
- "first_source_chat_id": source_chat_id,
- "first_source_message_id": source_message_id,
- "created_at": now,
- "last_seen_at": now,
- "seen_count": 1,
- "reuse_count": 0
- }
-
-
-def _prune_cache(cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]") -> None:
- now = asyncio.get_running_loop().time()
- expired_keys = [key for key, (ts, _) in cache.items() if now - ts > _CACHE_TTL_SECONDS]
- for key in expired_keys:
- cache.pop(key, None)
- while len(cache) > _CACHE_MAX_ITEMS:
- cache.popitem(last=False)
-
-
-def _cache_get(
- cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]",
- key: Any
-) -> Optional[Dict[str, Any]]:
- if key not in cache:
- return None
- ts, value = cache[key]
- now = asyncio.get_running_loop().time()
- if now - ts > _CACHE_TTL_SECONDS:
- cache.pop(key, None)
- return None
- cache.move_to_end(key)
- return value
-
-
-def _remember(record: Dict[str, Any]) -> Dict[str, Any]:
- global _insert_counter
- now = asyncio.get_running_loop().time()
- file_unique_id = record.get("file_unique_id")
- public_hash = record.get("public_hash")
- canonical_message_id = record.get("canonical_message_id")
-
- _insert_counter += 1
- should_prune = (_insert_counter % _CACHE_PRUNE_INTERVAL == 0)
-
- if file_unique_id:
- _cache_by_unique_id[file_unique_id] = (now, record)
- _cache_by_unique_id.move_to_end(file_unique_id)
- if should_prune:
- _prune_cache(_cache_by_unique_id)
- if public_hash:
- _cache_by_hash[public_hash] = (now, record)
- _cache_by_hash.move_to_end(public_hash)
- if should_prune:
- _prune_cache(_cache_by_hash)
- if canonical_message_id is not None:
- _cache_by_message_id[canonical_message_id] = (now, record)
- _cache_by_message_id.move_to_end(canonical_message_id)
- if should_prune:
- _prune_cache(_cache_by_message_id)
- return record
-
-
-def _forget(record: Dict[str, Any]) -> None:
- file_unique_id = record.get("file_unique_id")
- public_hash = record.get("public_hash")
- canonical_message_id = record.get("canonical_message_id")
-
- if file_unique_id:
- _cache_by_unique_id.pop(file_unique_id, None)
- if public_hash:
- _cache_by_hash.pop(public_hash, None)
- if canonical_message_id is not None:
- _cache_by_message_id.pop(canonical_message_id, None)
-
-
-async def get_file_by_unique_id(file_unique_id: str) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_unique_id, file_unique_id)
- if cached:
- return cached
- record = await db.get_file_by_unique_id(file_unique_id)
- return _remember(record) if record else None
-
-
-async def get_file_by_hash(
- public_hash: str,
- *,
- raise_on_error: bool = True
-) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_hash, public_hash)
- if cached:
- return cached
- record = await db.get_file_by_hash(public_hash, raise_on_error=raise_on_error)
- return _remember(record) if record else None
-
-
-async def get_file_by_message_id(canonical_message_id: int) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_message_id, canonical_message_id)
- if cached:
- return cached
- record = await db.get_file_by_message_id(canonical_message_id)
- return _remember(record) if record else None
-
-
-async def touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
- if not record.get("public_hash"):
- return
- record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
- record["seen_count"] = int(record.get("seen_count", 0)) + 1
- if reused:
- record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
- _remember(record)
- await db.touch_file_record(record["public_hash"], reused=reused, raise_on_error=True)
-
-
-async def _flush_pending_touches() -> None:
- global _flush_task
- flushed = False
- try:
- await asyncio.sleep(_FLUSH_DELAY_SECONDS)
-
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
- flushed = True
- except asyncio.CancelledError:
- pass
- finally:
- if not flushed and _pending_touches:
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
- _flush_task = None
-
-
-def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
- global _flush_task
- if not record.get("public_hash"):
- return
-
- record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
- record["seen_count"] = int(record.get("seen_count", 0)) + 1
- if reused:
- record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
- _remember(record)
-
- public_hash = record["public_hash"]
- if public_hash in _pending_touches:
- _, existing_reused = _pending_touches[public_hash]
- _pending_touches[public_hash] = (record, existing_reused or reused)
- else:
- _pending_touches[public_hash] = (record, reused)
-
- if _flush_task is None or _flush_task.done():
+import asyncio
+import datetime
+import hashlib
+from collections import OrderedDict
+from collections.abc import Awaitable, Callable
+from contextlib import asynccontextmanager
+from typing import Any
+
+from pymongo.errors import DuplicateKeyError
+from pyrogram.types import Message
+
+from Thunder.utils.database import db
+from Thunder.utils.file_properties import get_fname, get_media, get_uniqid
+from Thunder.utils.logger import hash_path_token, logger
+from Thunder.utils.media_types import ext_and_mime_for_class
+from Thunder.utils.safe_call import tg_call
+from Thunder.vars import Var
+
+# new hashes are 32 hex chars; legacy 20-char hashes stay valid forever.
+PUBLIC_HASH_LENGTH = 32
+LEGACY_PUBLIC_HASH_LENGTH = 20
+
+
+class IngestContended(Exception):
+ """Another worker holds the ingest claim for this file.
+
+ Raised instead of sleeping on it, so the caller can requeue rather than
+ occupy an executor worker until the claim TTL expires. Carries how long to
+ wait before the record is likely to exist.
+ """
+
+ def __init__(self, file_unique_id: str, retry_after: float) -> None:
+ super().__init__(f"ingest claim held elsewhere for {file_unique_id}")
+ self.file_unique_id = file_unique_id
+ self.retry_after = retry_after
+
+
+_CACHE_TTL_SECONDS = 600
+_CACHE_MAX_ITEMS = 4096
+# No heartbeat while copy_media runs, so this must outlast the slowest copy:
+# a claim that expires mid-upload is stolen and both workers copy the file.
+# The owner token still protects the release, just not the duplicate copy.
+_INGEST_CLAIM_TTL_SECONDS = 900
+# A crashed worker's claim blocks the file for the whole TTL. Waiting that out
+# in-handler means occupying an executor worker for up to
+# _MAX_INGEST_RETRIES * _INGEST_CLAIM_WAIT_SECONDS, and the pool only has
+# EXECUTOR_WORKERS of those -- five requests for the same file used to wedge the
+# whole ingest queue for a quarter of an hour.
+#
+# So the handler does NOT wait the TTL out. It polls briefly, and if the other
+# worker is still working it raises IngestContended, which the rate limiter
+# turns into a requeue: the worker is freed immediately and the request comes
+# back later, when the record the other worker is producing will exist. An
+# earlier version of this comment demanded that the retry budget cover the TTL
+# with margin -- that is exactly backwards. Waiting longer while holding a
+# worker is strictly worse than giving up the worker and requeueing.
+_INGEST_CLAIM_WAIT_SECONDS = 20
+# Polling at 0.5s made each poll two Mongo reads plus a Telegram get_messages
+# when a stale record exists -- ~3.6k reads and 1.8k RPCs per stuck request,
+# and the RPCs could themselves raise the FloodWait the poll is waiting out.
+_INGEST_CLAIM_POLL_SECONDS = 2.0
+_MAX_INGEST_RETRIES = 3
+_CACHE_PRUNE_INTERVAL = 50
+
+# bounded touch buffer; overflow drops (counted), flush is one BulkWrite.
+_FLUSH_DELAY_SECONDS = max(1, min(60, Var.TOUCH_FLUSH_SECONDS))
+_TOUCH_BUFFER_MAX = max(100, min(10_000, Var.TOUCH_BUFFER_MAX))
+_dropped_touches = 0
+
+_cache_by_unique_id: "OrderedDict[str, tuple[float, dict[str, Any]]]" = OrderedDict()
+_cache_by_hash: "OrderedDict[str, tuple[float, dict[str, Any]]]" = OrderedDict()
+
+_upload_locks: dict[str, asyncio.Lock] = {}
+_upload_lock_counts: dict[str, int] = {}
+_upload_locks_guard = asyncio.Lock()
+_insert_counter: int = 0
+# value = (latest record, reuse_delta, seen_delta): deltas so N touches of
+# one hash flush as N increments instead of a single merged $inc: 1
+_pending_touches: "OrderedDict[str, tuple[dict[str, Any], int, int]]" = OrderedDict()
+_flush_task: asyncio.Task | None = None
+
+
+def build_public_hash(file_unique_id: str) -> str:
+ return hashlib.sha256(file_unique_id.encode("utf-8")).hexdigest()[:PUBLIC_HASH_LENGTH]
+
+
+def _infer_mime_type(media: Any) -> str:
+ """Mime for a record: the media's own mime when present, else the
+ canonical map (NOT a local mini-map that drifted from it)."""
+ mime_type = getattr(media, "mime_type", None)
+ if mime_type:
+ return mime_type
+ return ext_and_mime_for_class(type(media).__name__.lower())[1]
+
+
+def build_file_record(
+ stored_message: Message,
+ *,
+ source_chat_id: int | None = None,
+ source_message_id: int | None = None,
+) -> dict[str, Any] | None:
+ media = get_media(stored_message)
+ file_unique_id = get_uniqid(stored_message)
+ if not media or not file_unique_id:
+ return None
+
+ now = datetime.datetime.now(datetime.UTC)
+ return {
+ "file_unique_id": file_unique_id,
+ "public_hash": build_public_hash(file_unique_id),
+ "canonical_message_id": stored_message.id,
+ "file_id": getattr(media, "file_id", None),
+ "file_name": get_fname(stored_message),
+ "mime_type": _infer_mime_type(media),
+ "file_size": getattr(media, "file_size", 0) or 0,
+ "media_type": type(media).__name__.lower(),
+ "first_source_chat_id": source_chat_id,
+ "first_source_message_id": source_message_id,
+ "created_at": now,
+ "last_seen_at": now,
+ "seen_count": 1,
+ "reuse_count": 0,
+ }
+
+
+def _prune_cache(cache: "OrderedDict[Any, tuple[float, dict[str, Any]]]") -> None:
+ now = asyncio.get_running_loop().time()
+ expired_keys = [key for key, (ts, _) in cache.items() if now - ts > _CACHE_TTL_SECONDS]
+ for key in expired_keys:
+ cache.pop(key, None)
+ while len(cache) > _CACHE_MAX_ITEMS:
+ cache.popitem(last=False)
+
+
+def _cache_get(
+ cache: "OrderedDict[Any, tuple[float, dict[str, Any]]]", key: Any
+) -> dict[str, Any] | None:
+ if key not in cache:
+ return None
+ ts, value = cache[key]
+ now = asyncio.get_running_loop().time()
+ if now - ts > _CACHE_TTL_SECONDS:
+ cache.pop(key, None)
+ return None
+ cache.move_to_end(key)
+ return value
+
+
+def _remember(record: dict[str, Any]) -> dict[str, Any]:
+ global _insert_counter
+ now = asyncio.get_running_loop().time()
+ file_unique_id = record.get("file_unique_id")
+ public_hash = record.get("public_hash")
+
+ _insert_counter += 1
+ should_prune = _insert_counter % _CACHE_PRUNE_INTERVAL == 0
+
+ if file_unique_id:
+ _cache_by_unique_id[file_unique_id] = (now, record)
+ _cache_by_unique_id.move_to_end(file_unique_id)
+ if should_prune:
+ _prune_cache(_cache_by_unique_id)
+ if public_hash:
+ _cache_by_hash[public_hash] = (now, record)
+ _cache_by_hash.move_to_end(public_hash)
+ if should_prune:
+ _prune_cache(_cache_by_hash)
+ return record
+
+
+def _forget(record: dict[str, Any]) -> None:
+ file_unique_id = record.get("file_unique_id")
+ public_hash = record.get("public_hash")
+
+ if file_unique_id:
+ _cache_by_unique_id.pop(file_unique_id, None)
+ if public_hash:
+ _cache_by_hash.pop(public_hash, None)
+
+
+async def get_file_by_unique_id(file_unique_id: str) -> dict[str, Any] | None:
+ cached = _cache_get(_cache_by_unique_id, file_unique_id)
+ if cached:
+ return cached
+ record = await db.get_file_by_unique_id(file_unique_id)
+ return _remember(record) if record else None
+
+
+async def get_file_by_hash(
+ public_hash: str, *, raise_on_error: bool = True
+) -> dict[str, Any] | None:
+ cached = _cache_get(_cache_by_hash, public_hash)
+ if cached:
+ return cached
+ record = await db.get_file_by_hash(public_hash, raise_on_error=raise_on_error)
+ return _remember(record) if record else None
+
+
+async def forget_stale_record(record: dict[str, Any]) -> bool:
+ """Self-healing: drop a corrupted/stale record from cache + DB so
+ the next upload re-ingests cleanly instead of erroring forever."""
+ public_hash = record.get("public_hash")
+ if not public_hash:
+ return False
+ _forget(record)
+ deleted = await db.delete_file_record(public_hash)
+ logger.warning(
+ f"Self-healed stale file record {hash_path_token(public_hash)} (deleted={deleted})"
+ )
+ return deleted
+
+
+async def _flush_pending_touches() -> None:
+ global _flush_task
+ cancelled = False
+ try:
+ await asyncio.sleep(_FLUSH_DELAY_SECONDS)
+ await _bulk_flush()
+ except asyncio.CancelledError:
+ # _bulk_flush re-queued its own snapshot before letting this through, so
+ # there is nothing to retry here -- drain_background_touch_tasks picks
+ # the buffer up on the way out.
+ cancelled = True
+ finally:
+ _flush_task = None
+ if _pending_touches and not cancelled:
+ # touches added during this flush escaped its snapshot; re-arm
+ # or they sit unflushed until process exit
+ _flush_task = asyncio.create_task(_flush_pending_touches())
+
+
+def _requeue_touches(items: list[tuple[str, tuple[dict[str, Any], int, int]]]) -> None:
+ """Put a flush snapshot back into the buffer after a failed write.
+
+ ``_bulk_flush`` removes the snapshot from ``_pending_touches`` before
+ awaiting, so every failure mode must re-queue it or the deltas are lost.
+ Deltas that arrived while the write was in flight are merged in.
+ """
+ global _dropped_touches
+ for h, payload in items:
+ old = _pending_touches.get(h)
+ if old is None:
+ _pending_touches[h] = payload
+ else:
+ _pending_touches[h] = (payload[0], old[1] + payload[1], old[2] + payload[2])
+ # Re-queued entries are freshly touched, so they belong at the BACK.
+ # Without this a key that keeps failing to flush stays at the front and
+ # is evicted by the trim below on the very next overflow, while live
+ # arrivals at the back are the ones that survive.
+ _pending_touches.move_to_end(h)
+ # The cap binds here too: repeated failures must not grow the buffer
+ # without limit. Least-recently-touched entries drop first (counted, like
+ # the schedule-site overflow).
+ while len(_pending_touches) > _TOUCH_BUFFER_MAX:
+ _pending_touches.popitem(last=False)
+ _dropped_touches += 1
+
+
+async def _bulk_flush() -> None:
+ items = list(_pending_touches.items())
+ _pending_touches.clear()
+ if not items:
+ return
+ try:
+ # raise_on_error is mandatory, not decorative: bulk_touch_file_records
+ # catches every Mongo exception and returns False by default, so
+ # without this the failure is invisible here, the re-queue below never
+ # runs, and the deltas are dropped -- while schedule_touch_file_record
+ # has already bumped the cached counters, leaving cache and Mongo
+ # permanently divergent.
+ ok = await db.bulk_touch_file_records(
+ [(h, reuse_delta, seen_delta) for h, (_, reuse_delta, seen_delta) in items],
+ raise_on_error=True,
+ )
+ if not ok:
+ # raise_on_error makes this unreachable today; it is here so that
+ # a future change to the callee's default cannot turn a lost
+ # delta into a silent success.
+ raise RuntimeError("bulk_touch_file_records reported failure")
+ except BaseException as e:
+ # CancelledError is a BaseException, not an Exception: shutdown cancels
+ # this task mid-write, and the snapshot is already out of the buffer, so
+ # re-queue before letting the cancellation propagate.
+ _requeue_touches(items)
+ if isinstance(e, Exception):
+ # merge back so the next flush retries; NOTE: a part-way failed
+ # ordered=False bulk re-applies some increments (accepted over-count)
+ logger.error(f"Failed to bulk-flush {len(items)} touches: {e}", exc_info=True)
+ return
+ raise
+
+
+def schedule_touch_file_record(record: dict[str, Any], *, reused: bool = False) -> None:
+ global _flush_task, _dropped_touches
+ if not record.get("public_hash"):
+ return
+
+ record["last_seen_at"] = datetime.datetime.now(datetime.UTC)
+ record["seen_count"] = int(record.get("seen_count", 0)) + 1
+ if reused:
+ record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
+ _remember(record)
+
+ public_hash = record["public_hash"]
+ if public_hash in _pending_touches:
+ _, reuse_delta, seen_delta = _pending_touches[public_hash]
+ _pending_touches[public_hash] = (record, reuse_delta + int(reused), seen_delta + 1)
+ # move_to_end is REQUIRED here, not tidiness. Assigning to an existing
+ # OrderedDict key does not reorder it, so without this the most
+ # frequently touched file stays at position 0 and is the FIRST thing
+ # popitem(last=False) throws away -- losing every delta it accumulated
+ # while keeping files touched once each. The sibling caches at lines
+ # 145/160/165 already do this; the touch buffer did not.
+ _pending_touches.move_to_end(public_hash)
+ else:
+ _pending_touches[public_hash] = (record, int(reused), 1)
+ # Evict the least-recently-touched to make room. This used to REJECT the
+ # new key instead, keeping the oldest -- the opposite of what the merge
+ # path does. Two rules for one bound, and neither was pinned.
+ while len(_pending_touches) > _TOUCH_BUFFER_MAX:
+ _pending_touches.popitem(last=False)
+ _dropped_touches += 1
+ if _dropped_touches % 100 == 1:
+ logger.warning(
+ f"Touch buffer full ({_TOUCH_BUFFER_MAX}); "
+ f"dropped {_dropped_touches} increments so far"
+ )
+
+ if _flush_task is None or _flush_task.done():
_flush_task = asyncio.create_task(_flush_pending_touches())
-
-
-async def drain_background_touch_tasks() -> None:
- if _flush_task and not _flush_task.done():
- _flush_task.cancel()
- try:
- await _flush_task
- except asyncio.CancelledError:
- pass
-
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
-
-
-async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None:
- if not record.get("public_hash") or not file_id:
- return
- record["file_id"] = file_id
- _remember(record)
- await db.update_file_id(record["public_hash"], file_id, raise_on_error=True)
-
-
-async def _fetch_canonical_message(record: Dict[str, Any]) -> Optional[Message]:
- canonical_message_id = record.get("canonical_message_id")
- if canonical_message_id is None:
- return None
-
- try:
- try:
- message = await StreamBot.get_messages(
- chat_id=int(Var.BIN_CHANNEL),
- message_ids=int(canonical_message_id)
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- message = await StreamBot.get_messages(
- chat_id=int(Var.BIN_CHANNEL),
- message_ids=int(canonical_message_id)
- )
- except Exception as e:
- logger.warning(
- f"Error fetching canonical message {canonical_message_id}: {e}",
- exc_info=True
- )
- raise
-
- if not message or not message.media:
- return None
- return message
-
-
-async def _is_canonical_record_valid(record: Dict[str, Any], file_unique_id: str) -> bool:
- message = await _fetch_canonical_message(record)
- return bool(message and get_uniqid(message) == file_unique_id)
-
-
-async def _get_reusable_canonical_record(
- file_unique_id: str
-) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
- existing = await get_file_by_unique_id(file_unique_id)
- if not existing:
- return None, None
-
- try:
- is_valid = await _is_canonical_record_valid(existing, file_unique_id)
- except Exception as e:
- logger.warning(
- f"Falling back to BIN re-copy for {file_unique_id} after canonical validation failed: {e}",
- exc_info=True
- )
- is_valid = False
-
- if is_valid:
- return existing, None
-
- _forget(existing)
- return None, existing
-
-
-async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Optional[Dict[str, Any]]:
- loop = asyncio.get_running_loop()
- deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS
-
- while loop.time() < deadline:
- reusable_record, _ = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- return reusable_record
-
- if not await db.is_file_ingest_claim_active(file_unique_id):
- break
-
- await asyncio.sleep(_INGEST_CLAIM_POLL_SECONDS)
-
- return None
-
-
-def _merge_replacement_record(
- existing: Dict[str, Any],
- refreshed: Dict[str, Any]
-) -> Dict[str, Any]:
- refreshed["created_at"] = existing.get("created_at", refreshed["created_at"])
- refreshed["seen_count"] = int(existing.get("seen_count", 0)) + 1
- refreshed["reuse_count"] = int(existing.get("reuse_count", 0))
- refreshed["first_source_chat_id"] = existing.get(
- "first_source_chat_id",
- refreshed.get("first_source_chat_id")
- )
- refreshed["first_source_message_id"] = existing.get(
- "first_source_message_id",
- refreshed.get("first_source_message_id")
- )
- return refreshed
-
-
-@asynccontextmanager
-async def file_ingest_lock(file_unique_id: str):
- async with _upload_locks_guard:
- lock = _upload_locks.get(file_unique_id)
- if lock is None:
- lock = asyncio.Lock()
- _upload_locks[file_unique_id] = lock
- _upload_lock_counts[file_unique_id] = 0
- _upload_lock_counts[file_unique_id] += 1
-
- acquired = False
- try:
- await lock.acquire()
- acquired = True
- yield
- finally:
- if acquired:
- lock.release()
- async with _upload_locks_guard:
- remaining = _upload_lock_counts.get(file_unique_id, 1) - 1
- if remaining <= 0:
- _upload_lock_counts.pop(file_unique_id, None)
- _upload_locks.pop(file_unique_id, None)
- else:
- _upload_lock_counts[file_unique_id] = remaining
-
-
-async def get_or_create_canonical_file(
- source_message: Message,
- copy_media: Callable[[Message], Awaitable[Optional[Message]]]
-) -> Tuple[Optional[Dict[str, Any]], Optional[Message], bool]:
- file_unique_id = get_uniqid(source_message)
- if not file_unique_id:
- return None, None, False
-
- async with file_ingest_lock(file_unique_id):
- for _attempt in range(_MAX_INGEST_RETRIES):
- if _attempt > 0:
- await asyncio.sleep(min(0.5 * (2 ** (_attempt - 1)), 5.0))
-
- reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
-
- claim_acquired = await db.acquire_file_ingest_claim(
- file_unique_id,
- ttl_seconds=_INGEST_CLAIM_TTL_SECONDS
- )
- if not claim_acquired:
- reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
- continue
-
- try:
- reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
-
- stored_message = await copy_media(source_message)
- if not stored_message:
- return None, None, False
-
- record = build_file_record(
- stored_message,
- source_chat_id=source_message.chat.id if source_message.chat else None,
- source_message_id=source_message.id
- )
- if not record:
- return None, stored_message, False
-
- try:
- if stale_record:
- record = _merge_replacement_record(stale_record, record)
- await db.replace_file_record(record)
- else:
- await db.create_file_record(record)
- _remember(record)
- return record, stored_message, False
- except DuplicateKeyError:
- reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, stored_message, True
- if stored_message:
- try:
- await stored_message.delete()
- except Exception as e:
- logger.warning(f"Failed to delete stored message {stored_message.id} in BIN_CHANNEL: {e}", exc_info=True)
- raise
- except FloodWait:
- raise
- except Exception as e:
- logger.error(f"Error creating canonical file for {file_unique_id}: {e}", exc_info=True)
- return None, stored_message, False
- finally:
- await db.release_file_ingest_claim(file_unique_id)
-
- logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}")
- return None, None, False
+
+
+def touch_buffer_stats() -> dict[str, int]:
+ return {
+ "pending": len(_pending_touches),
+ "dropped": _dropped_touches,
+ "cap": _TOUCH_BUFFER_MAX,
+ }
+
+
+async def drain_background_touch_tasks() -> None:
+ if _flush_task and not _flush_task.done():
+ _flush_task.cancel()
+ try:
+ await _flush_task
+ except asyncio.CancelledError:
+ pass
+
+ try:
+ # fresh task + shield: shutdown runs this drain under a bounded timeout,
+ # and a timeout must not abandon the write half-way. The buffer already
+ # holds a copy of everything, so losing this race costs an under-count,
+ # not a dropped delta.
+ await asyncio.shield(asyncio.ensure_future(_bulk_flush()))
+ except asyncio.CancelledError:
+ # Our caller timed us out (or is shutting down). The shielded write
+ # keeps running, but the cancellation MUST be re-raised: swallowing it
+ # makes wait_for return normally, so a hung Mongo would be recorded as
+ # a clean teardown step instead of a failure.
+ logger.warning("Touch drain cancelled; the in-flight flush is still running.")
+ raise
+ except Exception as e:
+ logger.error(f"Failed to flush touch buffer at shutdown: {e}", exc_info=True)
+
+
+async def update_cached_file_id(record: dict[str, Any], file_id: str) -> None:
+ if not record.get("public_hash") or not file_id:
+ return
+ # persist first, then cache. The other order left the cache holding an id
+ # Mongo does not know if the write failed; nothing reads record["file_id"]
+ # for delivery today (custom_dl streams the vault Message, and
+ # stream_routes only uses it to decide a refresh is needed), so the blast
+ # radius was nil -- but ordering it correctly costs nothing and the cache
+ # write is free to redo.
+ await db.update_file_id(record["public_hash"], file_id, raise_on_error=True)
+ record["file_id"] = file_id
+ _remember(record)
+
+
+async def _fetch_canonical_message(record: dict[str, Any], client) -> Message | None:
+ canonical_message_id = record.get("canonical_message_id")
+ if canonical_message_id is None:
+ return None
+
+ try:
+ message = await tg_call(
+ client.get_messages,
+ chat_id=int(Var.BIN_CHANNEL),
+ message_ids=int(canonical_message_id),
+ retries=1,
+ timeout=60,
+ )
+ except Exception as e:
+ logger.warning(
+ f"Error fetching canonical message {canonical_message_id}: {e}", exc_info=True
+ )
+ raise
+
+ if not message or not message.media:
+ return None
+ return message
+
+
+async def _is_canonical_record_valid(record: dict[str, Any], file_unique_id: str, client) -> bool:
+ message = await _fetch_canonical_message(record, client)
+ return bool(message and get_uniqid(message) == file_unique_id)
+
+
+async def _get_reusable_canonical_record(
+ file_unique_id: str,
+ client,
+) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
+ existing = await get_file_by_unique_id(file_unique_id)
+ if not existing:
+ return None, None
+
+ try:
+ is_valid = await _is_canonical_record_valid(existing, file_unique_id, client)
+ except Exception as e:
+ # RPC failure is NOT proof the vault message is gone; only a definitive
+ # None / unique-id mismatch declares staleness (else re-copy storms)
+ logger.warning(
+ f"Canonical validation errored for {file_unique_id}; keeping cached record: {e}",
+ exc_info=True,
+ )
+ return existing, None
+
+ if is_valid:
+ return existing, None
+
+ _forget(existing)
+ return None, existing
+
+
+async def _wait_for_other_worker_canonical_record(
+ file_unique_id: str, client
+) -> dict[str, Any] | None:
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS
+
+ while loop.time() < deadline:
+ reusable_record, _ = await _get_reusable_canonical_record(file_unique_id, client)
+ if reusable_record:
+ return reusable_record
+
+ if not await db.is_file_ingest_claim_active(file_unique_id):
+ break
+
+ await asyncio.sleep(_INGEST_CLAIM_POLL_SECONDS)
+
+ return None
+
+
+def _merge_replacement_record(
+ existing: dict[str, Any], refreshed: dict[str, Any]
+) -> dict[str, Any]:
+ refreshed["created_at"] = existing.get("created_at", refreshed["created_at"])
+ refreshed["seen_count"] = int(existing.get("seen_count", 0)) + 1
+ refreshed["reuse_count"] = int(existing.get("reuse_count", 0))
+ refreshed["first_source_chat_id"] = existing.get(
+ "first_source_chat_id", refreshed.get("first_source_chat_id")
+ )
+ refreshed["first_source_message_id"] = existing.get(
+ "first_source_message_id", refreshed.get("first_source_message_id")
+ )
+ # preserve existing public_hash: re-hashing rewrites legacy 20-char hashes
+ # to 32-hex and breaks published links
+ preserved_hash = existing.get("public_hash")
+ if preserved_hash:
+ refreshed["public_hash"] = preserved_hash
+ return refreshed
+
+
+@asynccontextmanager
+async def file_ingest_lock(file_unique_id: str):
+ async with _upload_locks_guard:
+ lock = _upload_locks.get(file_unique_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ _upload_locks[file_unique_id] = lock
+ _upload_lock_counts[file_unique_id] = 0
+ _upload_lock_counts[file_unique_id] += 1
+
+ acquired = False
+ try:
+ await lock.acquire()
+ acquired = True
+ yield
+ finally:
+ if acquired:
+ lock.release()
+ async with _upload_locks_guard:
+ remaining = _upload_lock_counts.get(file_unique_id, 1) - 1
+ if remaining <= 0:
+ _upload_lock_counts.pop(file_unique_id, None)
+ _upload_locks.pop(file_unique_id, None)
+ else:
+ _upload_lock_counts[file_unique_id] = remaining
+
+
+async def get_or_create_canonical_file(
+ source_message: Message,
+ copy_media: Callable[[Message], Awaitable[Message | None]],
+ client,
+) -> tuple[dict[str, Any] | None, Message | None, bool]:
+ file_unique_id = get_uniqid(source_message)
+ if not file_unique_id:
+ return None, None, False
+
+ for _attempt in range(_MAX_INGEST_RETRIES):
+ if _attempt > 0:
+ await asyncio.sleep(min(0.5 * (2 ** (_attempt - 1)), 5.0))
+
+ reusable_record, _ = await _get_reusable_canonical_record(file_unique_id, client)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+
+ # The lock guards claim+copy ONLY. Sleeping inside it was a liveness
+ # bug: the wait below runs up to _INGEST_CLAIM_WAIT_SECONDS per
+ # attempt, so one contended file could hold a worker for
+ # _MAX_INGEST_RETRIES * _INGEST_CLAIM_WAIT_SECONDS (~15 min). The
+ # executor pool has only EXECUTOR_WORKERS of those, so five requests
+ # naming the same file wedged the entire ingest queue -- with no
+ # cross-process claim involved at all, just five in-process waiters.
+ # Serialising the copy is correct and stays; serialising a sleep is
+ # not. Mongo's claim CAS, not this lock, is the real mutex.
+ async with file_ingest_lock(file_unique_id):
+ claim_owner = await db.acquire_file_ingest_claim(
+ file_unique_id, ttl_seconds=_INGEST_CLAIM_TTL_SECONDS
+ )
+ if not claim_owner:
+ contended = True
+ else:
+ contended = False
+ try:
+ # re-check under the lock: another attempt may have
+ # created the record between our probe and the claim
+ reusable_record, stale_record = await _get_reusable_canonical_record(
+ file_unique_id, client
+ )
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+
+ stored_message = await copy_media(source_message)
+ if not stored_message:
+ return None, None, False
+
+ record = build_file_record(
+ stored_message,
+ source_chat_id=source_message.chat.id if source_message.chat else None,
+ source_message_id=source_message.id,
+ )
+ if not record:
+ return None, stored_message, False
+
+ try:
+ if stale_record:
+ record = _merge_replacement_record(stale_record, record)
+ await db.replace_file_record(record)
+ else:
+ await db.create_file_record(record)
+ _remember(record)
+ return record, stored_message, False
+ except DuplicateKeyError:
+ reusable_record = await _wait_for_other_worker_canonical_record(
+ file_unique_id, client
+ )
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, stored_message, True
+ if stored_message:
+ try:
+ await stored_message.delete()
+ except Exception as e:
+ logger.warning(
+ f"Failed to delete stored message {stored_message.id} in BIN_CHANNEL: {e}",
+ exc_info=True,
+ )
+ raise
+ except Exception as e:
+ logger.error(
+ f"Error creating canonical file for {file_unique_id}: {e}",
+ exc_info=True,
+ )
+ return None, stored_message, False
+ finally:
+ await db.release_file_ingest_claim(file_unique_id, claim_owner)
+
+ if contended:
+ # outside the lock on purpose -- see the note at the lock
+ reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id, client)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+ # Still held after a short poll. Hand the worker back instead of
+ # sleeping through the claim TTL: the limiter requeues us, and by
+ # the time we come back the other worker's record will exist.
+ raise IngestContended(file_unique_id, _INGEST_CLAIM_WAIT_SECONDS)
+
+ logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}")
+ return None, None, False
diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py
index d4831496..0cad9405 100644
--- a/Thunder/utils/commands.py
+++ b/Thunder/utils/commands.py
@@ -1,38 +1,26 @@
-from pyrogram.types import BotCommand
-
-from Thunder.bot import StreamBot
-from Thunder.utils.logger import logger
-from Thunder.vars import Var
-
-def get_commands():
- command_descriptions = {
- "start": "Start the bot and get a welcome message",
- "link": "(Group) Generate a direct link for a file or batch",
- "dc": "Retrieve the data center (DC) information of a user or file",
- "ping": "Check the bot's status and response time",
- "about": "Get information about the bot",
- "help": "Show help and usage instructions",
- "status": "(Admin) View bot details and current workload",
- "stats": "(Admin) View usage statistics and resource consumption",
- "broadcast": "(Admin) Send a message to all users",
- "ban": "(Admin) Ban a user",
- "unban": "(Admin) Unban a user",
- "log": "(Admin) Send bot logs",
- "restart": "(Admin) Update and restart the bot",
- "shell": "(Admin) Execute a shell command",
- "speedtest": "(Admin) Run network speed test",
- "users": "(Admin) Show the total number of users",
- "authorize": "(Admin) Grant permanent access to a user",
- "deauthorize": "(Admin) Remove permanent access from a user",
- "listauth": "(Admin) List all authorized users"
- }
- return [BotCommand(name, desc) for name, desc in command_descriptions.items()]
-
-async def set_commands():
- if Var.SET_COMMANDS:
- try:
- commands = get_commands()
- if commands:
- await StreamBot.set_bot_commands(commands)
- except Exception as e:
- logger.error(f"Failed to set bot commands: {e}", exc_info=True)
+from Thunder.bot import StreamBot
+from Thunder.bot.registry import bot_commands, help_command_rows
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import MSG_HELP_COMMANDS_HEADER, MSG_HELP_INTRO, MSG_HELP_TIPS
+from Thunder.utils.safe_call import tg_call
+from Thunder.vars import Var
+
+
+def build_help_text(max_files: int) -> str:
+ """Assemble /help from its three parts."""
+ return (
+ MSG_HELP_INTRO.format(max_files=max_files)
+ + MSG_HELP_COMMANDS_HEADER
+ + help_command_rows()
+ + MSG_HELP_TIPS
+ )
+
+
+async def set_commands():
+ if Var.SET_COMMANDS:
+ try:
+ commands = bot_commands()
+ if commands:
+ await tg_call(StreamBot.set_bot_commands, commands)
+ except Exception as e:
+ logger.error(f"Failed to set bot commands: {e}", exc_info=True)
diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py
index d1bb0b4d..1615fefd 100644
--- a/Thunder/utils/config_parser.py
+++ b/Thunder/utils/config_parser.py
@@ -1,36 +1,21 @@
-# Thunder/utils/config_parser.py
-
-import os
-from typing import Dict, Optional
-from Thunder.utils.logger import logger
-
-class TokenParser:
- def __init__(self, config_file: Optional[str] = None):
- self.tokens: Dict[int, str] = {}
- self.config_file = config_file
-
- def parse_from_env(self) -> Dict[int, str]:
- try:
- multi_tokens = {
- key: value.strip()
- for key, value in os.environ.items()
- if key.startswith("MULTI_TOKEN") and value.strip()
- }
-
- if not multi_tokens:
- return {}
-
- sorted_tokens = sorted(
- multi_tokens.items(),
- key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
- )
-
- self.tokens = {
- index + 1: token
- for index, (_, token) in enumerate(sorted_tokens)
- }
-
- return self.tokens
- except Exception as e:
- logger.error(f"Error in parse_from_env: {e}", exc_info=True)
- return {}
+import os
+
+
+class TokenParser:
+ def parse_from_env(self) -> dict[int, str]:
+ # sort key cannot raise: digit filter yields "" at worst, `or 0` -> int
+ multi_tokens = {
+ key: value.strip()
+ for key, value in os.environ.items()
+ if key.startswith("MULTI_TOKEN") and value.strip()
+ }
+
+ if not multi_tokens:
+ return {}
+
+ sorted_tokens = sorted(
+ multi_tokens.items(),
+ key=lambda item: int("".join(filter(str.isdigit, item[0])) or 0),
+ )
+
+ return {index + 1: token for index, (_, token) in enumerate(sorted_tokens)}
diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py
index 22b0de46..df66fb23 100644
--- a/Thunder/utils/custom_dl.py
+++ b/Thunder/utils/custom_dl.py
@@ -1,38 +1,52 @@
-# Thunder/utils/custom_dl.py
-
import asyncio
-from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional
+from collections.abc import AsyncGenerator
+from typing import Any
from pyrogram import Client
from pyrogram.errors import FloodWait
from pyrogram.types import Message
-from Thunder.server.exceptions import FileNotFound
+from Thunder.server.exceptions import FileNotFound, TelegramUnavailable
from Thunder.utils.file_properties import get_media
from Thunder.utils.logger import logger
+from Thunder.utils.media_types import ext_and_mime_for_class
+from Thunder.utils.safe_call import tg_call
from Thunder.vars import Var
+# Caps the total FloodWait pinning of one stream handler before a 503.
+_MAX_STREAM_FLOODWAIT_SECONDS = 60.0
+
+# Shared media chunk size (1 MiB): also used by the HTTP streaming routes to
+# align the byte-skip math with stream_file's chunk offsets.
+CHUNK_SIZE = 1024 * 1024
+
class ByteStreamer:
- __slots__ = ('client', 'chat_id')
+ __slots__ = ("client", "chat_id")
def __init__(self, client: Client) -> None:
self.client = client
self.chat_id = int(Var.BIN_CHANNEL)
async def get_message(self, message_id: int) -> Message:
- while True:
- try:
- message = await self.client.get_messages(self.chat_id, message_id)
- break
- except FloodWait as e:
- logger.debug(f"FloodWait: get_message, sleep {e.value}s")
- await asyncio.sleep(e.value)
- except Exception as e:
- logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True)
- raise FileNotFound(f"Message {message_id} not found") from e
+ # bounded FloodWait handling via tg_call; transient failures
+ # raise TelegramUnavailable, NEVER FileNotFound -- the delivery route
+ # self-heals (deletes the record) on FileNotFound.
+ try:
+ message = await tg_call(
+ self.client.get_messages, self.chat_id, message_id, retries=2, timeout=60
+ )
+ except FloodWait as e:
+ raise TelegramUnavailable(
+ f"Telegram temporarily unavailable (FloodWait {e.value}s)"
+ ) from e
+ except Exception as e:
+ logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True)
+ raise TelegramUnavailable(f"Telegram fetch failed for message {message_id}") from e
- if not message or not message.media:
+ # pyrogram's stubs declare `Message | list[Message]`, but a single
+ # id always yields a single Message; a list here means bad input
+ if isinstance(message, list) or not message or not message.media:
raise FileNotFound(f"Message {message_id} not found")
return message
@@ -41,95 +55,72 @@ async def stream_file(
media_ref: int | Message,
offset: int = 0,
limit: int = 0,
- fallback_message_id: int | None = None,
- on_fallback_message: Optional[Callable[[Message], Awaitable[None]]] = None
- ) -> AsyncGenerator[bytes, None]:
- chunk_offset = offset // (1024 * 1024)
+ ) -> AsyncGenerator[bytes]:
+ chunk_offset = offset // CHUNK_SIZE
chunk_limit = 0
if limit > 0:
- chunk_limit = ((limit + (1024 * 1024) - 1) // (1024 * 1024)) + 1
-
- refs: list[int | Message] = [media_ref]
- media_id = media_ref if isinstance(media_ref, int) else None
- if isinstance(media_ref, Message):
- media_id = getattr(media_ref, "id", getattr(media_ref, "message_id", None))
- if fallback_message_id is not None and (media_id is None or fallback_message_id != media_id):
- refs.append(fallback_message_id)
-
- last_error: Exception | None = None
- for ref in refs:
- started_stream = False
- while True:
- try:
- target = await self.get_message(ref) if isinstance(ref, int) else ref
- if (
- on_fallback_message is not None and
- fallback_message_id is not None and
- ref == fallback_message_id and
- isinstance(target, Message)
- ):
- await on_fallback_message(target)
- async for chunk in self.client.stream_media(
- target, offset=chunk_offset, limit=chunk_limit
- ):
- started_stream = True
- yield chunk
- return
- except FloodWait as e:
- logger.debug(f"FloodWait: stream_file, sleep {e.value}s")
- await asyncio.sleep(e.value)
- except Exception as e:
- last_error = e
- logger.debug(f"Error streaming media ref {ref}: {e}", exc_info=True)
- if started_stream:
- raise
- break
-
- raise FileNotFound(f"Unable to stream file: {last_error}")
-
- def get_file_info_sync(self, message: Message) -> Dict[str, Any]:
+ chunk_limit = ((limit + CHUNK_SIZE - 1) // CHUNK_SIZE) + 1
+
+ # fetch the target ONCE, outside the retry loop: per-retry re-fetches
+ # cost an extra RPC per FloodWait and turn hiccups into spurious 404s
+ target = await self.get_message(media_ref) if isinstance(media_ref, int) else media_ref
+
+ chunks_done = 0
+ floodwait_sleep_total = 0.0
+ while True:
+ try:
+ # stream_media is an async generator; stubs union it with
+ # file_ref types, hence the ignore below
+ async for chunk in self.client.stream_media( # type: ignore[union-attr]
+ target, offset=chunk_offset, limit=chunk_limit
+ ):
+ yield chunk
+ chunks_done += 1
+ return
+ except FloodWait as e:
+ # resume from where the consumer is: restarting from the
+ # original offset re-sends bytes and corrupts the download
+ if chunks_done:
+ chunk_offset += chunks_done
+ if chunk_limit:
+ chunk_limit = max(chunk_limit - chunks_done, 0)
+ chunks_done = 0
+ # bound total pinned time on sustained floods
+ if floodwait_sleep_total + e.value > _MAX_STREAM_FLOODWAIT_SECONDS:
+ raise TelegramUnavailable(
+ "Sustained Telegram flood while streaming "
+ f">{_MAX_STREAM_FLOODWAIT_SECONDS:.0f}s; try again shortly"
+ ) from e
+ floodwait_sleep_total += e.value
+ logger.debug(f"FloodWait: stream_file, sleep {e.value}s")
+ await asyncio.sleep(e.value)
+ except Exception as e:
+ logger.debug(f"Error streaming media ref {media_ref}: {e}", exc_info=True)
+ raise TelegramUnavailable(f"Unable to stream file: {e}") from e
+
+ def get_file_info_sync(self, message: Message) -> dict[str, Any]:
media = get_media(message)
if not media:
- return {"message_id": message.id, "error": "No media"}
+ # the delivery ladder maps FileNotFound -> 404; an unreadable
+ # "error" marker dict had no reader anywhere
+ raise FileNotFound(f"Message {message.id} has no media")
media_type = type(media).__name__.lower()
- file_name = getattr(media, 'file_name', None)
- mime_type = getattr(media, 'mime_type', None)
+ file_name = getattr(media, "file_name", None)
+ mime_type = getattr(media, "mime_type", None)
if not file_name:
- ext_map = {
- "photo": "jpg",
- "audio": "mp3",
- "voice": "ogg",
- "video": "mp4",
- "animation": "mp4",
- "videonote": "mp4",
- "sticker": "webp",
- }
- ext = ext_map.get(media_type, "bin")
+ ext, _ = ext_and_mime_for_class(media_type)
file_name = f"Thunder_{message.id}.{ext}"
if not mime_type:
- mime_map = {
- "photo": "image/jpeg",
- "voice": "audio/ogg",
- "videonote": "video/mp4",
- }
- mime_type = mime_map.get(media_type)
+ _, mime_type = ext_and_mime_for_class(media_type)
return {
"message_id": message.id,
- "file_size": getattr(media, 'file_size', 0) or 0,
+ "file_size": getattr(media, "file_size", 0) or 0,
"file_name": file_name,
"mime_type": mime_type,
- "unique_id": getattr(media, 'file_unique_id', None),
- "media_type": media_type
+ "unique_id": getattr(media, "file_unique_id", None),
+ "media_type": media_type,
}
-
- async def get_file_info(self, message_id: int) -> Dict[str, Any]:
- try:
- message = await self.get_message(message_id)
- return self.get_file_info_sync(message)
- except Exception as e:
- logger.debug(f"Error getting file info for {message_id}: {e}", exc_info=True)
- return {"message_id": message_id, "error": str(e)}
diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py
index d0bbe9a7..993d1d5f 100644
--- a/Thunder/utils/database.py
+++ b/Thunder/utils/database.py
@@ -1,453 +1,707 @@
-# Thunder/utils/database.py
-
-import datetime
-from typing import Any, Dict, Optional
-from pymongo import AsyncMongoClient
-from pymongo.asynchronous.collection import AsyncCollection
-from pymongo.errors import DuplicateKeyError
-from Thunder.vars import Var
-from Thunder.utils.logger import logger
-
-class Database:
- def __init__(self, uri: str, database_name: str, *args, **kwargs):
- self._client = AsyncMongoClient(uri, *args, **kwargs)
- self.db = self._client[database_name]
- self.col: AsyncCollection = self.db.users
- self.banned_users_col: AsyncCollection = self.db.banned_users
- self.banned_channels_col: AsyncCollection = self.db.banned_channels
- self.token_col: AsyncCollection = self.db.tokens
- self.authorized_users_col: AsyncCollection = self.db.authorized_users
- self.restart_message_col: AsyncCollection = self.db.restart_message
- self.files_col: AsyncCollection = self.db.files
- self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks
-
- async def _deduplicate_users(self) -> None:
- pipeline = [
- {"$sort": {"join_date": 1}},
- {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}},
- {"$project": {"_id": "$doc_id"}}
- ]
- keep_ids = []
- async for doc in self.col.aggregate(pipeline):
- keep_ids.append(doc["_id"])
- if keep_ids:
- result = await self.col.delete_many({"_id": {"$nin": keep_ids}})
- if result.deleted_count > 0:
- logger.warning(f"Deduplicated {result.deleted_count} duplicate user documents.")
-
- async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool:
- try:
- await self.banned_users_col.create_index("user_id", unique=True)
- await self.banned_channels_col.create_index("channel_id", unique=True)
- await self.token_col.create_index("token", unique=True)
- await self.authorized_users_col.create_index("user_id", unique=True)
- try:
- await self.col.create_index("id", unique=True)
- except DuplicateKeyError:
- logger.warning("Duplicate users found, deduplicating...")
- await self._deduplicate_users()
- await self.col.create_index("id", unique=True)
- await self.token_col.create_index("expires_at", expireAfterSeconds=0)
- await self.token_col.create_index("activated")
- await self.restart_message_col.create_index("message_id", unique=True)
- await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
- await self.files_col.create_index("file_unique_id", unique=True)
- await self.files_col.create_index("public_hash", unique=True)
- await self.files_col.create_index("canonical_message_id", unique=True)
- await self.files_col.create_index("created_at")
- await self.files_col.create_index("last_seen_at")
- await self.file_ingest_locks_col.create_index("expires_at", expireAfterSeconds=0)
-
- logger.debug("Database indexes ensured.")
- return True
- except Exception as e:
- logger.error(f"Error in ensure_indexes: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- def new_user(self, user_id: int) -> dict:
- try:
- return {
- 'id': user_id,
- 'join_date': datetime.datetime.now(datetime.timezone.utc)
- }
- except Exception as e:
- logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True)
- raise
-
- async def add_user(self, user_id: int) -> bool:
- try:
- result = await self.col.update_one(
- {'id': user_id},
- {'$setOnInsert': self.new_user(user_id)},
- upsert=True
- )
- if result.upserted_id:
- logger.debug(f"Added new user {user_id} to database.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in add_user for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def is_user_exist(self, user_id: int) -> bool:
- """Read-only existence check. For user registration, use add_user() instead."""
- try:
- user = await self.col.find_one({'id': user_id}, {'_id': 1})
- return bool(user)
- except Exception as e:
- logger.error(f"Error in is_user_exist for user {user_id}: {e}", exc_info=True)
- raise
-
- async def total_users_count(self) -> int:
- try:
- return await self.col.count_documents({})
- except Exception as e:
- logger.error(f"Error in total_users_count: {e}", exc_info=True)
- return 0
-
- async def get_authorized_users_count(self) -> int:
- try:
- return await self.authorized_users_col.count_documents({})
- except Exception as e:
- logger.error(f"Error in get_authorized_users_count: {e}", exc_info=True)
- return 0
-
- async def get_regular_users_count(self) -> int:
- try:
- auth_ids = await self.authorized_users_col.distinct("user_id")
- return await self.col.count_documents({"id": {"$nin": auth_ids}})
- except Exception as e:
- logger.error(f"Error in get_regular_users_count: {e}", exc_info=True)
- return 0
-
- async def get_all_users(self):
- try:
- return self.col.find({})
- except Exception as e:
- logger.error(f"Error in get_all_users: {e}", exc_info=True)
- return self.col.find({"_id": {"$exists": False}})
-
- async def get_authorized_users_cursor(self):
- try:
- return self.authorized_users_col.find({})
- except Exception as e:
- logger.error(f"Error in get_authorized_users_cursor: {e}", exc_info=True)
- return self.authorized_users_col.find({"_id": {"$exists": False}})
-
- async def get_regular_users_cursor(self):
- try:
- auth_ids = await self.authorized_users_col.distinct("user_id")
- return self.col.find({"id": {"$nin": auth_ids}})
- except Exception as e:
- logger.error(f"Error in get_regular_users_cursor: {e}", exc_info=True)
- return self.col.find({"_id": {"$exists": False}})
-
- async def delete_user(self, user_id: int):
- try:
- await self.col.delete_one({'id': user_id})
- logger.debug(f"Deleted user {user_id}.")
- except Exception as e:
- logger.error(f"Error in delete_user for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def add_banned_user(
- self, user_id: int, banned_by: Optional[int] = None,
- reason: Optional[str] = None
- ):
- try:
- ban_data = {
- "user_id": user_id,
- "banned_at": datetime.datetime.now(datetime.timezone.utc),
- "banned_by": banned_by,
- "reason": reason
- }
- await self.banned_users_col.update_one(
- {"user_id": user_id},
- {"$set": ban_data},
- upsert=True
- )
- logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}")
- except Exception as e:
- logger.error(f"Error in add_banned_user for user {user_id}: {e}", exc_info=True)
- raise
-
- async def remove_banned_user(self, user_id: int) -> bool:
- try:
- result = await self.banned_users_col.delete_one({"user_id": user_id})
- if result.deleted_count > 0:
- logger.debug(f"Removed banned user {user_id}.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in remove_banned_user for user {user_id}: {e}", exc_info=True)
- return False
-
- async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.banned_users_col.find_one({"user_id": user_id})
- except Exception as e:
- logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True)
- return None
-
- async def add_banned_channel(
- self, channel_id: int, banned_by: Optional[int] = None,
- reason: Optional[str] = None
- ):
- try:
- ban_data = {
- "channel_id": channel_id,
- "banned_at": datetime.datetime.now(datetime.timezone.utc),
- "banned_by": banned_by,
- "reason": reason
- }
- await self.banned_channels_col.update_one(
- {"channel_id": channel_id},
- {"$set": ban_data},
- upsert=True
- )
- logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}")
- except Exception as e:
- logger.error(f"Error in add_banned_channel for channel {channel_id}: {e}", exc_info=True)
- raise
-
- async def remove_banned_channel(self, channel_id: int) -> bool:
- try:
- result = await self.banned_channels_col.delete_one({"channel_id": channel_id})
- if result.deleted_count > 0:
- logger.debug(f"Removed banned channel {channel_id}.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True)
- return False
-
- async def is_channel_banned(self, channel_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.banned_channels_col.find_one({"channel_id": channel_id})
- except Exception as e:
- logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True)
- return None
-
- async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None:
- try:
- await self.token_col.update_one(
- {"user_id": user_id, "token": token_value},
- {"$set": {
- "expires_at": expires_at,
- "created_at": created_at,
- "activated": activated
- }
- },
- upsert=True
- )
- logger.debug(f"Saved main token {token_value} for user {user_id} with activated status {activated}.")
- except Exception as e:
- logger.error(f"Error saving main token for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def add_restart_message(self, message_id: int, chat_id: int) -> None:
- try:
- await self.restart_message_col.insert_one({
- "message_id": message_id,
- "chat_id": chat_id,
- "timestamp": datetime.datetime.now(datetime.timezone.utc)
- })
- logger.debug(f"Added restart message {message_id} for chat {chat_id}.")
- except Exception as e:
- logger.error(f"Error adding restart message {message_id}: {e}", exc_info=True)
-
- async def get_restart_message(self) -> Optional[Dict[str, Any]]:
- try:
- return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
- except Exception as e:
- logger.error(f"Error getting restart message: {e}", exc_info=True)
- return None
-
- async def delete_restart_message(self, message_id: int) -> None:
- try:
- await self.restart_message_col.delete_one({"message_id": message_id})
- logger.debug(f"Deleted restart message {message_id}.")
- except Exception as e:
- logger.error(f"Error deleting restart message {message_id}: {e}", exc_info=True)
-
- async def is_user_authorized(self, user_id: int) -> bool:
- try:
- user = await self.authorized_users_col.find_one({'user_id': user_id}, {'_id': 1})
- return bool(user)
- except Exception as e:
- logger.error(f"Error in is_user_authorized for user {user_id}: {e}", exc_info=True)
- return False
-
- async def get_file_by_unique_id(self, file_unique_id: str) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"file_unique_id": file_unique_id})
- except Exception as e:
- logger.error(f"Error getting file by unique_id {file_unique_id}: {e}", exc_info=True)
- return None
-
- async def get_file_by_hash(
- self,
- public_hash: str,
- *,
- raise_on_error: bool = True
- ) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"public_hash": public_hash})
- except Exception as e:
- logger.error(f"Error getting file by hash {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return None
-
- async def get_file_by_message_id(self, canonical_message_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"canonical_message_id": canonical_message_id})
- except Exception as e:
- logger.error(
- f"Error getting file by message_id {canonical_message_id}: {e}",
- exc_info=True
- )
- return None
-
- async def create_file_record(self, file_record: Dict[str, Any]) -> None:
- try:
- await self.files_col.insert_one(file_record)
- except Exception as e:
- logger.error(
- f"Error creating canonical file record for {file_record.get('file_unique_id')}: {e}",
- exc_info=True
- )
- raise
-
- async def replace_file_record(self, file_record: Dict[str, Any]) -> None:
- try:
- await self.files_col.replace_one(
- {"file_unique_id": file_record["file_unique_id"]},
- file_record,
- upsert=True
- )
- except Exception as e:
- logger.error(
- f"Error replacing canonical file record for {file_record.get('file_unique_id')}: {e}",
- exc_info=True
- )
- raise
-
- async def touch_file_record(
- self,
- public_hash: str,
- *,
- reused: bool = False,
- raise_on_error: bool = False
- ) -> bool:
- try:
- update_doc: Dict[str, Any] = {
- "$set": {"last_seen_at": datetime.datetime.now(datetime.timezone.utc)},
- "$inc": {"seen_count": 1}
- }
- if reused:
- update_doc["$inc"]["reuse_count"] = 1
- await self.files_col.update_one({"public_hash": public_hash}, update_doc)
- return True
- except Exception as e:
- logger.error(f"Error touching canonical file {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- async def update_file_id(
- self,
- public_hash: str,
- file_id: str,
- *,
- raise_on_error: bool = False
- ) -> bool:
- try:
- await self.files_col.update_one(
- {"public_hash": public_hash},
- {
- "$set": {
- "file_id": file_id,
- "last_seen_at": datetime.datetime.now(datetime.timezone.utc)
- }
- }
- )
- return True
- except Exception as e:
- logger.error(f"Error updating file_id for {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- async def acquire_file_ingest_claim(
- self,
- file_unique_id: str,
- *,
- ttl_seconds: int = 60
- ) -> bool:
- now = datetime.datetime.now(datetime.timezone.utc)
- claim_fields = {
- "created_at": now,
- "expires_at": now + datetime.timedelta(seconds=ttl_seconds)
- }
- try:
- await self.file_ingest_locks_col.insert_one({
- "_id": file_unique_id,
- **claim_fields
- })
- return True
- except DuplicateKeyError:
- try:
- result = await self.file_ingest_locks_col.find_one_and_update(
- {
- "_id": file_unique_id,
- "$or": [
- {"expires_at": {"$lte": now}},
- {"expires_at": {"$exists": False}}
- ]
- },
- {
- "$set": claim_fields
- },
- return_document=False
- )
- return bool(result)
- except Exception as e:
- logger.error(f"Error updating ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
- except Exception as e:
- logger.error(f"Error acquiring ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
-
- async def release_file_ingest_claim(self, file_unique_id: str) -> bool:
- try:
- await self.file_ingest_locks_col.delete_one({"_id": file_unique_id})
- return True
- except Exception as e:
- logger.error(f"Error releasing ingest claim for {file_unique_id}: {e}", exc_info=True)
- return False
-
- async def is_file_ingest_claim_active(self, file_unique_id: str) -> bool:
- try:
- claim = await self.file_ingest_locks_col.find_one(
- {
- "_id": file_unique_id,
- "expires_at": {"$gt": datetime.datetime.now(datetime.timezone.utc)}
- },
- {"_id": 1}
- )
- return bool(claim)
- except Exception as e:
- logger.error(f"Error checking ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
-
- async def close(self):
- if self._client:
- await self._client.close()
-
-db = Database(Var.DATABASE_URL, Var.NAME)
+import datetime
+import uuid
+from typing import Any
+
+from pymongo import AsyncMongoClient, UpdateOne
+from pymongo.asynchronous.collection import AsyncCollection
+from pymongo.errors import DuplicateKeyError, ExecutionTimeout, OperationFailure
+
+from Thunder.utils.flag_cache import flags
+from Thunder.utils.logger import hash_path_token, logger
+from Thunder.vars import Var
+
+# every Mongo op gets a server-side budget so a brownout cannot pin
+# handlers forever; per-op overrides remain possible at call sites.
+MONGO_TIMEOUT_MS = 5000
+
+
+class Database:
+ def __init__(self, uri: str, database_name: str, **kwargs):
+ # tz_aware=True: pymongo's default returns naive UTC datetimes, which
+ # raise TypeError against aware now(UTC) (broke token activation once)
+ self._client = AsyncMongoClient(uri, timeoutMS=MONGO_TIMEOUT_MS, tz_aware=True, **kwargs)
+ self.db = self._client[database_name]
+ self.col: AsyncCollection = self.db.users
+ self.banned_users_col: AsyncCollection = self.db.banned_users
+ self.banned_channels_col: AsyncCollection = self.db.banned_channels
+ self.token_col: AsyncCollection = self.db.tokens
+ self.authorized_users_col: AsyncCollection = self.db.authorized_users
+ self.restart_message_col: AsyncCollection = self.db.restart_message
+ self.files_col: AsyncCollection = self.db.files
+ self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks
+ # One-shot migration markers (backfill completion bookkeeping)
+ self.migration_flags_col: AsyncCollection = self.db.migration_flags
+
+ async def _deduplicate_users(self) -> None:
+ """Collapse duplicate ``users.id`` documents, keeping the oldest.
+
+ Scoped to the duplicated ids only: the previous version materialised
+ every surviving ``_id`` and deleted with ``{"_id": {"$nin": keep_ids}}``,
+ which embeds one ObjectId per user in a single BSON document (16 MB cap)
+ and sorts the whole collection in memory -- both fatal at scale inside
+ the client's ``timeoutMS``.
+ """
+ pipeline: list[dict[str, Any]] = [
+ {"$sort": {"join_date": 1}},
+ {"$group": {"_id": "$id", "n": {"$sum": 1}, "keep": {"$first": "$_id"}}},
+ {"$match": {"n": {"$gt": 1}}},
+ ]
+ deleted = 0
+ # AsyncCollection.aggregate() is a coroutine in pymongo's async API:
+ # iterate the awaited cursor, never the coroutine itself.
+ cursor = await self.col.aggregate(pipeline, allowDiskUse=True)
+ async for doc in cursor:
+ if doc["_id"] is None:
+ # $group folds every document with a missing id into one null
+ # bucket: deleting that bucket would delete unrelated users.
+ # Leave them; the unique index stays unbuilt until it converges.
+ logger.warning("Skipping users with no id during dedup (left untouched).")
+ continue
+ # one small delete per duplicate group, whatever the user count
+ result = await self.col.delete_many({"id": doc["_id"], "_id": {"$ne": doc["keep"]}})
+ deleted += result.deleted_count
+ if deleted:
+ logger.warning(f"Deduplicated {deleted} duplicate user documents.")
+
+ async def _file_ttl_index_seconds(self) -> int | None:
+ """Current ``expireAfterSeconds`` of the file TTL index, or None when
+ the index is absent (or its options cannot be inspected)."""
+ try:
+ # AsyncCollection.list_indexes() is a coroutine in pymongo's
+ # async API: iterate the awaited cursor, never the coroutine.
+ cursor = await self.files_col.list_indexes()
+ async for idx in cursor:
+ if idx.get("name") == "last_seen_at_1":
+ try:
+ return int(idx.get("expireAfterSeconds", -1))
+ except (TypeError, ValueError):
+ return -1
+ except Exception as e:
+ logger.warning(f"Could not inspect file TTL index: {e}")
+ return None
+
+ async def _backfill_done(self) -> bool:
+ try:
+ return bool(
+ await self.migration_flags_col.find_one({"_id": "file_last_seen_backfill_done"})
+ )
+ except Exception:
+ return False
+
+ async def _mark_backfill_done(self) -> None:
+ try:
+ await self.migration_flags_col.update_one(
+ {"_id": "file_last_seen_backfill_done"},
+ {"$set": {"done_at": datetime.datetime.now(datetime.UTC)}},
+ upsert=True,
+ )
+ except Exception as e:
+ logger.warning(f"Could not record backfill completion marker: {e}")
+
+ async def _get_backfill_cursor(self) -> Any:
+ """Persisted resume position: without it every boot re-walks the
+ already-stamped prefix and the migration never converges on large
+ vaults."""
+ try:
+ doc = await self.migration_flags_col.find_one({"_id": "file_last_seen_backfill_cursor"})
+ return doc.get("last_id") if doc else None
+ except Exception:
+ return None
+
+ async def _save_backfill_cursor(self, last_id: Any) -> None:
+ try:
+ await self.migration_flags_col.update_one(
+ {"_id": "file_last_seen_backfill_cursor"},
+ {"$set": {"last_id": last_id}},
+ upsert=True,
+ )
+ except Exception as e:
+ logger.warning(f"Could not persist backfill cursor: {e}")
+
+ async def _backfill_file_last_seen(self) -> None:
+ """One-off migration: stamp ``last_seen_at`` on legacy rows that lack
+ it, so the TTL index activated right after gives them a full window.
+
+ ``_id``-paged micro-batches filtered to unstamped rows keep each
+ statement inside the 5s ``timeoutMS`` (a single ``update_many`` would
+ COLLSCAN and abort the remaining index ensures). The resume cursor is
+ persisted per batch, so a capped or interrupted run continues where
+ it stopped -- the filter shrinks as rows get stamped, guaranteeing
+ convergence. Must never raise.
+ """
+ batch_size = 500
+ max_batches_per_boot = 100 # 50k unstamped rows per boot; converges across boots
+ last_id: Any = await self._get_backfill_cursor()
+ stamped = 0
+ try:
+ for _ in range(max_batches_per_boot):
+ page_filter: dict[str, Any] = {"last_seen_at": {"$exists": False}}
+ if last_id is not None:
+ page_filter["_id"] = {"$gt": last_id}
+ page = (
+ await self.files_col.find(page_filter, {"_id": 1})
+ .sort("_id", 1)
+ .to_list(batch_size)
+ )
+ if not page:
+ await self._mark_backfill_done()
+ if stamped:
+ logger.info(f"Backfilled last_seen_at on {stamped} legacy file records.")
+ return
+ last_id = page[-1]["_id"]
+ # per-batch stamp: a frozen boot-time value would backdate rows
+ # stamped by later batches (and skew their TTL window start)
+ stamp = datetime.datetime.now(datetime.UTC)
+ await self.files_col.update_many(
+ {"_id": {"$in": [doc["_id"] for doc in page]}},
+ {"$set": {"last_seen_at": stamp}},
+ )
+ stamped += len(page)
+ await self._save_backfill_cursor(last_id)
+ logger.warning(
+ "last_seen_at backfill hit the per-boot batch cap "
+ f"({max_batches_per_boot} batches); continuing from the cursor next boot."
+ )
+ except Exception as e:
+ # The migration must never abort the remaining index ensures.
+ logger.warning(f"last_seen_at backfill interrupted (resumes from cursor): {e}")
+
+ async def _create_file_ttl_index(self, expire_after_seconds: int) -> None:
+ """Create (or recreate after an operator TTL change) the file TTL
+ index without letting its failure modes abort the remaining ensures."""
+ try:
+ await self.files_col.create_index(
+ "last_seen_at", expireAfterSeconds=expire_after_seconds
+ )
+ except ExecutionTimeout:
+ # first build on a large vault can exceed the budget; the
+ # server-side build continues and is idempotent, re-check next boot
+ logger.warning(
+ "File TTL index build exceeded the client timeout budget; "
+ "the server-side build continues and is re-checked on next boot."
+ )
+ except OperationFailure as e:
+ if e.code != 85: # 85 = IndexOptionsConflict
+ logger.warning(f"File TTL index creation failed: {e}")
+ return
+ # Mongo cannot alter TTL via createIndexes; an operator's
+ # FILE_TTL_DAYS change must not abort the remaining unique ensures
+ logger.warning("FILE_TTL_DAYS changed between boots; recreating file TTL index.")
+ try:
+ await self.files_col.drop_index("last_seen_at_1")
+ except Exception:
+ pass
+ try:
+ await self.files_col.create_index(
+ "last_seen_at", expireAfterSeconds=expire_after_seconds
+ )
+ except (ExecutionTimeout, OperationFailure) as e:
+ # a concurrent recreate (or a server still building) must not
+ # abort the remaining unique ensures
+ logger.warning(f"File TTL index rebuild failed; re-checked on next boot: {e}")
+
+ async def _ensure_index(self, col: AsyncCollection, keys: Any, **opts: Any) -> None:
+ """Best-effort: one failed ensure (e.g. a build exceeding the client
+ timeout on a large vault) must not abort the remaining ones -- the
+ server-side build continues and the next boot re-runs it."""
+ try:
+ await col.create_index(keys, **opts)
+ except ExecutionTimeout as e:
+ logger.warning(f"Index ensure on {col.name} skipped; re-checked on next boot: {e}")
+ except OperationFailure as e:
+ logger.error(f"Index ensure on {col.name} failed; re-checked on next boot: {e}")
+ if opts.get("unique"):
+ # files has no dedup recovery path, so an unenforced unique
+ # index silently splits seen/reuse counters across duplicates
+ logger.error(
+ f"Unique index on {col.name} is NOT enforced until it succeeds: "
+ "duplicate records will split per-file counters."
+ )
+
+ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool:
+ try:
+ # backfill before the TTL index exists so pre-existing rows
+ # get a full window instead of vanishing on activation (default off)
+ if Var.FILE_TTL_DAYS > 0:
+ expected_ttl = Var.FILE_TTL_DAYS * 86400
+ current_ttl = await self._file_ttl_index_seconds()
+ backfill_done = await self._backfill_done()
+ if current_ttl == expected_ttl and backfill_done:
+ logger.debug(f"File TTL index already active: {Var.FILE_TTL_DAYS} days")
+ else:
+ if not backfill_done:
+ # stamp legacy rows first so they get a full TTL window
+ await self._backfill_file_last_seen()
+ await self._create_file_ttl_index(expected_ttl)
+ logger.info(f"File TTL index active: {Var.FILE_TTL_DAYS} days")
+ else:
+ # probe first: with FILE_TTL_DAYS=0 a per-boot dropIndexes that
+ # always fails is a wasted round-trip, and its blanket except
+ # used to swallow genuine auth/namespace errors
+ if await self._file_ttl_index_seconds() is not None:
+ try:
+ await self.files_col.drop_index("last_seen_at_1")
+ except OperationFailure as e:
+ logger.warning(f"Could not drop the file TTL index: {e}")
+
+ await self._ensure_index(self.banned_users_col, "user_id", unique=True)
+ await self._ensure_index(self.banned_channels_col, "channel_id", unique=True)
+ await self._ensure_index(self.token_col, "token", unique=True)
+ # /start + generate() look tokens up by user; without this the
+ # per-user scans walk the whole collection
+ await self._ensure_index(
+ self.token_col, [("user_id", 1), ("activated", 1), ("expires_at", -1)]
+ )
+ await self._ensure_index(self.authorized_users_col, "user_id", unique=True)
+ # users.id is the one unique index that may legitimately need a
+ # dedup pass first, so it cannot go through _ensure_index as-is --
+ # but neither a dedup failure nor an auth/namespace error may abort
+ # the ten ensures that follow.
+ try:
+ await self.col.create_index("id", unique=True)
+ except DuplicateKeyError:
+ logger.warning("Duplicate users found, deduplicating...")
+ try:
+ await self._deduplicate_users()
+ except Exception as e:
+ logger.error(
+ f"User dedup failed; users.id stays unindexed until next boot: {e}"
+ )
+ else:
+ await self._ensure_index(self.col, "id", unique=True)
+ except OperationFailure as e:
+ logger.error(f"users.id unique index ensure failed; re-checked on next boot: {e}")
+ await self._ensure_index(self.token_col, "expires_at", expireAfterSeconds=0)
+ await self._ensure_index(self.token_col, "activated")
+ await self._ensure_index(self.restart_message_col, "message_id", unique=True)
+ await self._ensure_index(self.restart_message_col, "timestamp", expireAfterSeconds=3600)
+ await self._ensure_index(self.files_col, "file_unique_id", unique=True)
+ await self._ensure_index(self.files_col, "public_hash", unique=True)
+ await self._ensure_index(self.files_col, "canonical_message_id", unique=True)
+ await self._ensure_index(self.files_col, "created_at")
+ await self._ensure_index(self.file_ingest_locks_col, "expires_at", expireAfterSeconds=0)
+
+ logger.debug("Database indexes ensured.")
+ return True
+ except Exception as e:
+ logger.error(f"Error in ensure_indexes: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return False
+
+ def new_user(self, user_id: int) -> dict:
+ return {"id": user_id, "join_date": datetime.datetime.now(datetime.UTC)}
+
+ async def add_user(self, user_id: int) -> bool:
+ try:
+ result = await self.col.update_one(
+ {"id": user_id}, {"$setOnInsert": self.new_user(user_id)}, upsert=True
+ )
+ if result.upserted_id:
+ logger.debug(f"Added new user {user_id} to database.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"Error in add_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def is_user_exist(self, user_id: int) -> bool:
+ """Read-only existence check. For user registration, use add_user() instead."""
+ try:
+ user = await self.col.find_one({"id": user_id}, {"_id": 1})
+ return bool(user)
+ except Exception as e:
+ logger.error(f"Error in is_user_exist for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def total_users_count(self) -> int:
+ try:
+ return await self.col.count_documents({})
+ except Exception as e:
+ logger.error(f"Error in total_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_authorized_users_count(self) -> int:
+ try:
+ return await self.authorized_users_col.count_documents({})
+ except Exception as e:
+ logger.error(f"Error in get_authorized_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_regular_users_count(self) -> int:
+ try:
+ auth_ids = await self.authorized_users_col.distinct("user_id")
+ return await self.col.count_documents({"id": {"$nin": auth_ids}})
+ except Exception as e:
+ logger.error(f"Error in get_regular_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_all_users(self):
+ # find() only builds a cursor server-side; it cannot fail here
+ return self.col.find({})
+
+ async def get_authorized_users_cursor(self):
+ return self.authorized_users_col.find({})
+
+ async def get_regular_users_cursor(self):
+ auth_ids = await self.authorized_users_col.distinct("user_id")
+ return self.col.find({"id": {"$nin": auth_ids}})
+
+ async def delete_user(self, user_id: int):
+ try:
+ await self.col.delete_one({"id": user_id})
+ logger.debug(f"Deleted user {user_id}.")
+ except Exception as e:
+ logger.error(f"Error in delete_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def add_banned_user(
+ self, user_id: int, banned_by: int | None = None, reason: str | None = None
+ ):
+ try:
+ ban_data = {
+ "user_id": user_id,
+ "banned_at": datetime.datetime.now(datetime.UTC),
+ "banned_by": banned_by,
+ "reason": reason,
+ }
+ await self.banned_users_col.update_one(
+ {"user_id": user_id}, {"$set": ban_data}, upsert=True
+ )
+ # the ban gate is flag-cached: without this invalidation a
+ # fresh ban would not take effect until the 5-min TTL expired
+ flags.invalidate(("banned_user", user_id))
+ logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}")
+ except Exception as e:
+ logger.error(f"Error in add_banned_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def remove_banned_user(self, user_id: int) -> bool:
+ try:
+ result = await self.banned_users_col.delete_one({"user_id": user_id})
+ # invalidate unconditionally (a miss is a no-op): a stale cached
+ # ban entry would keep a re-banned user denied until TTL expiry
+ flags.invalidate(("banned_user", user_id))
+ if result.deleted_count > 0:
+ logger.debug(f"Removed banned user {user_id}.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"Error in remove_banned_user for user {user_id}: {e}", exc_info=True)
+ return False
+
+ async def is_user_banned(
+ self, user_id: int, *, raise_on_error: bool = False
+ ) -> dict[str, Any] | None:
+ """Fetch a ban record. With ``raise_on_error=True`` a Mongo failure
+ raises so fail-closed callers (the ban gate, H7) can deny instead of
+ silently treating the outage as "not banned"."""
+ try:
+ return await self.banned_users_col.find_one({"user_id": user_id})
+ except Exception as e:
+ if raise_on_error:
+ raise
+ logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True)
+ return None
+
+ async def add_banned_channel(
+ self, channel_id: int, banned_by: int | None = None, reason: str | None = None
+ ):
+ try:
+ ban_data = {
+ "channel_id": channel_id,
+ "banned_at": datetime.datetime.now(datetime.UTC),
+ "banned_by": banned_by,
+ "reason": reason,
+ }
+ await self.banned_channels_col.update_one(
+ {"channel_id": channel_id}, {"$set": ban_data}, upsert=True
+ )
+ flags.invalidate(("banned_channel", channel_id))
+ logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}")
+ except Exception as e:
+ logger.error(
+ f"Error in add_banned_channel for channel {channel_id}: {e}", exc_info=True
+ )
+ raise
+
+ async def remove_banned_channel(self, channel_id: int) -> bool:
+ try:
+ result = await self.banned_channels_col.delete_one({"channel_id": channel_id})
+ flags.invalidate(("banned_channel", channel_id))
+ if result.deleted_count > 0:
+ logger.debug(f"Removed banned channel {channel_id}.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(
+ f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True
+ )
+ return False
+
+ async def is_channel_banned(
+ self, channel_id: int, *, raise_on_error: bool = False
+ ) -> dict[str, Any] | None:
+ """Fetch a channel-ban record. ``raise_on_error`` mirrors
+ ``is_user_banned`` for fail-closed callers; the default (swallow →
+ None) is what the auto-leave gate wants, since a Mongo outage must
+ not trigger the destructive leave_chat action."""
+ try:
+ return await self.banned_channels_col.find_one({"channel_id": channel_id})
+ except Exception as e:
+ if raise_on_error:
+ raise
+ logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True)
+ return None
+
+ async def save_main_token(
+ self,
+ user_id: int,
+ token_value: str,
+ expires_at: datetime.datetime,
+ created_at: datetime.datetime,
+ activated: bool,
+ ) -> None:
+ try:
+ await self.token_col.update_one(
+ {"user_id": user_id, "token": token_value},
+ {
+ "$set": {
+ "expires_at": expires_at,
+ "created_at": created_at,
+ "activated": activated,
+ }
+ },
+ upsert=True,
+ )
+ logger.debug(f"Saved main token for user {user_id} with activated status {activated}.")
+ except Exception as e:
+ logger.error(f"Error saving main token for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def add_restart_message(self, message_id: int, chat_id: int) -> None:
+ try:
+ await self.restart_message_col.insert_one(
+ {
+ "message_id": message_id,
+ "chat_id": chat_id,
+ "timestamp": datetime.datetime.now(datetime.UTC),
+ }
+ )
+ logger.debug(f"Added restart message {message_id} for chat {chat_id}.")
+ except Exception as e:
+ logger.error(f"Error adding restart message {message_id}: {e}", exc_info=True)
+
+ async def get_restart_message(self) -> dict[str, Any] | None:
+ try:
+ return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
+ except Exception as e:
+ logger.error(f"Error getting restart message: {e}", exc_info=True)
+ return None
+
+ async def delete_restart_message(self, message_id: int) -> None:
+ try:
+ await self.restart_message_col.delete_one({"message_id": message_id})
+ logger.debug(f"Deleted restart message {message_id}.")
+ except Exception as e:
+ logger.error(f"Error deleting restart message {message_id}: {e}", exc_info=True)
+
+ async def is_user_authorized(self, user_id: int, *, raise_on_error: bool = False) -> bool:
+ """Authorized-user existence check. With ``raise_on_error=True`` a Mongo
+ failure raises so fail-closed callers can deny instead of silently
+ treating the outage as "not authorized" (mirrors ``is_user_banned``)."""
+ try:
+ user = await self.authorized_users_col.find_one({"user_id": user_id}, {"_id": 1})
+ return bool(user)
+ except Exception as e:
+ if raise_on_error:
+ raise
+ logger.error(f"Error in is_user_authorized for user {user_id}: {e}", exc_info=True)
+ return False
+
+ async def get_file_by_unique_id(self, file_unique_id: str) -> dict[str, Any] | None:
+ try:
+ return await self.files_col.find_one({"file_unique_id": file_unique_id})
+ except Exception as e:
+ logger.error(
+ f"Error getting file by unique_id {hash_path_token(file_unique_id)}: {e}",
+ exc_info=True,
+ )
+ return None
+
+ async def get_file_by_hash(
+ self, public_hash: str, *, raise_on_error: bool = True
+ ) -> dict[str, Any] | None:
+ try:
+ return await self.files_col.find_one({"public_hash": public_hash})
+ except Exception as e:
+ # no capability material in logs: public_hash identifies the link
+ logger.error(
+ f"Error getting file by hash {hash_path_token(public_hash)}: {e}", exc_info=True
+ )
+ if raise_on_error:
+ raise
+ return None
+
+ async def create_file_record(self, file_record: dict[str, Any]) -> None:
+ try:
+ await self.files_col.insert_one(file_record)
+ except Exception as e:
+ logger.error(
+ f"Error creating canonical file record for "
+ f"{hash_path_token(str(file_record.get('file_unique_id')))}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def replace_file_record(self, file_record: dict[str, Any]) -> None:
+ try:
+ await self.files_col.replace_one(
+ {"file_unique_id": file_record["file_unique_id"]}, file_record, upsert=True
+ )
+ except Exception as e:
+ logger.error(
+ f"Error replacing canonical file record for "
+ f"{hash_path_token(str(file_record.get('file_unique_id')))}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def bulk_touch_file_records(
+ self, items: list[tuple[str, int, int]], *, raise_on_error: bool = False
+ ) -> bool:
+ """Batched touch: one BulkWrite for the whole flush cycle.
+
+ ``items`` is a list of ``(public_hash, reuse_delta, seen_delta)``
+ triples; deltas accumulate per hash so N touches flush as N, not 1.
+ """
+ if not items:
+ return True
+ now = datetime.datetime.now(datetime.UTC)
+ ops: list[UpdateOne] = []
+ for public_hash, reuse_delta, seen_delta in items:
+ inc: dict[str, int] = {"seen_count": seen_delta}
+ if reuse_delta:
+ inc["reuse_count"] = reuse_delta
+ ops.append(
+ UpdateOne(
+ {"public_hash": public_hash},
+ {"$set": {"last_seen_at": now}, "$inc": inc},
+ )
+ )
+ try:
+ await self.files_col.bulk_write(ops, ordered=False)
+ return True
+ except Exception as e:
+ logger.error(f"Error bulk-touching {len(ops)} file records: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return False
+
+ async def delete_file_record(self, public_hash: str) -> bool:
+ """Remove a stale canonical record."""
+ try:
+ result = await self.files_col.delete_one({"public_hash": public_hash})
+ return result.deleted_count > 0
+ except Exception as e:
+ logger.error(
+ f"Error deleting stale file record {hash_path_token(public_hash)}: {e}",
+ exc_info=True,
+ )
+ return False
+
+ async def update_file_id(
+ self, public_hash: str, file_id: str, *, raise_on_error: bool = False
+ ) -> bool:
+ try:
+ await self.files_col.update_one(
+ {"public_hash": public_hash},
+ {"$set": {"file_id": file_id, "last_seen_at": datetime.datetime.now(datetime.UTC)}},
+ )
+ return True
+ except Exception as e:
+ logger.error(
+ f"Error updating file_id for {hash_path_token(public_hash)}: {e}", exc_info=True
+ )
+ if raise_on_error:
+ raise
+ return False
+
+ async def acquire_file_ingest_claim(
+ self, file_unique_id: str, *, ttl_seconds: int = 60
+ ) -> str | None:
+ """Acquire the ingest claim; returns an opaque owner token, or
+ ``None`` when another worker holds a live claim.
+
+ The owner token makes the matching ``release_file_ingest_claim``
+ refuse to delete a newer worker's claim after this worker's TTL
+ expired mid-copy (which previously caused a redundant third copy).
+ """
+ now = datetime.datetime.now(datetime.UTC)
+ owner = uuid.uuid4().hex
+ claim_fields = {
+ "owner": owner,
+ "created_at": now,
+ "expires_at": now + datetime.timedelta(seconds=ttl_seconds),
+ }
+ try:
+ await self.file_ingest_locks_col.insert_one({"_id": file_unique_id, **claim_fields})
+ return owner
+ except DuplicateKeyError:
+ try:
+ result = await self.file_ingest_locks_col.find_one_and_update(
+ {
+ "_id": file_unique_id,
+ "$or": [{"expires_at": {"$lte": now}}, {"expires_at": {"$exists": False}}],
+ },
+ {"$set": claim_fields},
+ return_document=False,
+ )
+ return owner if result else None
+ except Exception as e:
+ logger.error(
+ f"Error updating ingest claim for {hash_path_token(file_unique_id)}: {e}",
+ exc_info=True,
+ )
+ raise
+ except Exception as e:
+ logger.error(
+ f"Error acquiring ingest claim for {hash_path_token(file_unique_id)}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def release_file_ingest_claim(self, file_unique_id: str, owner: str) -> bool:
+ """Release the claim only if we still own it (owner-checked)."""
+ try:
+ result = await self.file_ingest_locks_col.delete_one(
+ {"_id": file_unique_id, "owner": owner}
+ )
+ return result.deleted_count > 0
+ except Exception as e:
+ logger.error(
+ f"Error releasing ingest claim for {hash_path_token(file_unique_id)}: {e}",
+ exc_info=True,
+ )
+ return False
+
+ async def is_file_ingest_claim_active(self, file_unique_id: str) -> bool:
+ try:
+ claim = await self.file_ingest_locks_col.find_one(
+ {"_id": file_unique_id, "expires_at": {"$gt": datetime.datetime.now(datetime.UTC)}},
+ {"_id": 1},
+ )
+ return bool(claim)
+ except Exception as e:
+ logger.error(
+ f"Error checking ingest claim for {hash_path_token(file_unique_id)}: {e}",
+ exc_info=True,
+ )
+ raise
+
+ async def close(self):
+ if self._client:
+ await self._client.close()
+
+
+db = Database(Var.DATABASE_URL, Var.NAME)
diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py
index b1209f19..b95411af 100644
--- a/Thunder/utils/decorators.py
+++ b/Thunder/utils/decorators.py
@@ -1,181 +1,317 @@
-# Thunder/utils/decorators.py
-
-import asyncio
-from pyrogram.errors import FloodWait
-from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
-
-from Thunder.utils.database import db
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (MSG_DECORATOR_BANNED,
- MSG_ERROR_UNAUTHORIZED, MSG_TOKEN_INVALID)
-from Thunder.utils.shortener import shorten
-from Thunder.utils.tokens import allowed, check, generate
-from Thunder.vars import Var
-
-
-async def check_banned(client, message: Message):
- try:
- if not message.from_user:
- return True
- user_id = message.from_user.id
- if user_id == Var.OWNER_ID:
- return True
-
- ban_details = await db.is_user_banned(user_id)
- if ban_details:
- banned_at = ban_details.get('banned_at')
- ban_time = (
- banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
- if banned_at and hasattr(banned_at, 'strftime')
- else str(banned_at) if banned_at else 'N/A'
- )
- try:
- await message.reply_text(
- MSG_DECORATOR_BANNED.format(
- reason=ban_details.get('reason', 'Not specified'),
- ban_time=ban_time
- ),
- quote=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_DECORATOR_BANNED.format(
- reason=ban_details.get('reason', 'Not specified'),
- ban_time=ban_time
- ),
- quote=True
- )
- logger.debug(f"Blocked banned user {user_id}.")
- return False
- return True
- except Exception as e:
- logger.error(f"Error in check_banned: {e}", exc_info=True)
- return True
-
-async def require_token(client, message: Message):
- try:
- if not message.from_user:
- return True
-
- if not getattr(Var, "TOKEN_ENABLED", False):
- return True
-
- user_id = message.from_user.id
- if user_id == Var.OWNER_ID or await allowed(user_id) or await check(user_id):
- return True
-
- temp_token_string = None
- try:
- temp_token_string = await generate(user_id)
- except Exception as e:
- logger.error(f"Failed to generate temporary token for user {user_id} in require_token: {e}", exc_info=True)
- try:
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- return False
-
- if not temp_token_string:
- logger.error(f"Temporary token generation returned empty for user {user_id} in require_token.", exc_info=True)
- try:
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- return False
-
- try:
- me = await client.get_me()
- except FloodWait as e:
- await asyncio.sleep(e.value)
- me = await client.get_me()
- if not me:
- logger.error(f"Failed to get bot info for user {user_id} in require_token.", exc_info=True)
- try:
- await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
- return False
- deep_link = f"https://t.me/{me.username}?start={temp_token_string}"
- short_url = deep_link
-
- try:
- short_url_result = await shorten(deep_link)
- if short_url_result:
- short_url = short_url_result
- except Exception as e:
- logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.", exc_info=True)
-
- try:
- await message.reply_text(
- MSG_TOKEN_INVALID,
- reply_markup=InlineKeyboardMarkup([
- [InlineKeyboardButton("Activate Access", url=short_url)]
- ]),
- quote=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_TOKEN_INVALID,
- reply_markup=InlineKeyboardMarkup([
- [InlineKeyboardButton("Activate Access", url=short_url)]
- ]),
- quote=True
- )
- logger.debug(f"Sent temporary token activation link to user {user_id}.")
- return False
- except Exception as e:
- logger.error(f"Error in require_token: {e}", exc_info=True)
- try:
- try:
- await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
- except Exception as inner_e:
- logger.error(f"Failed to send error message to user in require_token: {inner_e}", exc_info=True)
- return False
-
-async def get_shortener_status(client, message: Message):
- try:
- user_id = message.from_user.id if message.from_user else None
- use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
- if user_id:
- try:
- if user_id == Var.OWNER_ID or await allowed(user_id):
- use_shortener = False
- except Exception as e:
- logger.warning(f"Error checking allowed status for user {user_id} in get_shortener_status: {e}. Defaulting shortener behavior.", exc_info=True)
- return use_shortener
- except Exception as e:
- logger.error(f"Error in get_shortener_status: {e}", exc_info=True)
- return getattr(Var, "SHORTEN_MEDIA_LINKS", False)
-
-async def owner_only(client, update):
- try:
- user = None
- if hasattr(update, 'from_user'):
- user = update.from_user
- else:
- logger.error(f"Unsupported update type or missing from_user in owner_only: {type(update)}", exc_info=True)
- return False
-
- if not user or user.id != Var.OWNER_ID:
- if hasattr(update, 'answer'):
- await update.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
- logger.warning(f"Unauthorized access attempt by {user.id if user else 'unknown'} to owner_only function.")
- return False
-
- return True
- except Exception as e:
- logger.error(f"Error in owner_only: {e}", exc_info=True)
- try:
- if hasattr(update, 'answer'):
- await update.answer("An error occurred. Please try again.", show_alert=True)
- except Exception as inner_e:
- logger.error(f"Failed to send error answer in owner_only: {inner_e}", exc_info=True)
- return False
+"""Access gates.
+
+One preflight chain replaces the three ad-hoc per-plugin gate orders.
+Documented ordering (see AGENTS.md):
+
+ banned -> private-mode -> token (+ force-sub where applicable;
+ shortener-status is a routing value, not a gate)
+
+* ``preflight`` runs only ``banned -> private-mode -> token`` and returns
+ the shortener status (which is a value, not a gate);
+* force-sub is NOT in ``PREFLIGHT_GATES``: it must be called explicitly
+ after ``preflight`` via :func:`force_sub_gate` (see
+ ``validate_request_common`` in ``bot/plugins/stream.py``);
+* owner bypasses everything; authorized users bypass private-mode + token,
+ but not the ban check or force-sub;
+* /start runs only ``banned + private-mode`` so the activation flow stays
+ reachable;
+* every DB-backed gate is cached (``flag_cache``) and **fail-closed**:
+ a Mongo outage denies access with a temporary-error message instead of
+ silently letting everyone through.
+"""
+
+import html
+from urllib.parse import quote_plus
+
+from pyrogram.enums import ParseMode
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from Thunder.utils.database import db
+from Thunder.utils.flag_cache import flags
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_DECORATOR_BANNED,
+ MSG_ERROR_ANONYMOUS_SENDER,
+ MSG_ERROR_TEMP,
+ MSG_ERROR_TOKEN_LINK_FAILED,
+ MSG_ERROR_UNAUTHORIZED,
+ MSG_ERROR_UNEXPECTED,
+ MSG_PRIVATE_MODE_DENIED,
+ MSG_TOKEN_INVALID,
+)
+from Thunder.utils.safe_call import answer_safe, reply_safe, tg_call
+from Thunder.utils.shortener import shorten
+from Thunder.utils.tokens import allowed, check, generate
+from Thunder.vars import Var
+
+
+async def _reply_ignore_failure(message: Message, text: str, **kwargs) -> None:
+ """Best-effort gate reply.
+
+ A gate must never fail because the *denial* could not be delivered -- the
+ bot has no send permission in most storage channels, and replying is
+ advisory anyway. Every gate denial goes through here so the swallow-everything
+ policy lives in one place instead of eleven copies.
+ """
+ try:
+ await reply_safe(message, text, **kwargs)
+ except Exception as e:
+ logger.debug(f"Gate reply not delivered: {e}")
+
+
+async def check_banned(client, message: Message) -> bool:
+ """Ban gate -- cached, fail-closed."""
+ try:
+ if not message.from_user:
+ # no attributable id -> no ban lookup is possible. Allowing it here
+ # would let a banned user post as the channel or as an anonymous
+ # admin, so fail-closed like the private-mode and token gates.
+ logger.debug("Denied unattributable sender (ban gate, no from_user).")
+ await _reply_ignore_failure(message, MSG_ERROR_ANONYMOUS_SENDER)
+ return False
+ user_id = message.from_user.id
+ if user_id == Var.OWNER_ID:
+ return True
+
+ try:
+ ban_details = await flags.get_or_load(
+ ("banned_user", user_id),
+ # raise_on_error=True: otherwise Mongo outages become None,
+ # negative-cached 5 min as "not banned" -- fail-open
+ lambda: db.is_user_banned(user_id, raise_on_error=True),
+ )
+ except Exception as e:
+ # fail-closed: a Mongo outage must not un-ban everybody
+ logger.error(f"Ban check degraded for user {user_id}: {e}", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_TEMP)
+ return False
+
+ if ban_details:
+ banned_at = ban_details.get("banned_at")
+ ban_time = (
+ banned_at.strftime("%B %d, %Y, %I:%M %p UTC")
+ if banned_at and hasattr(banned_at, "strftime")
+ else str(banned_at)
+ if banned_at
+ else "N/A"
+ )
+ await _reply_ignore_failure(
+ message,
+ MSG_DECORATOR_BANNED.format(
+ # reason is owner-set free text; escaped + explicit HTML
+ # parse mode so it cannot reflow into markup.
+ # Coerced to str first: add_banned_user's signature permits a
+ # null reason and Mongo stores it verbatim, and html.escape
+ # raises AttributeError on None -- which the outer except
+ # would swallow, so the user is banned AND told nothing.
+ reason=html.escape(str(ban_details.get("reason") or "Not specified")),
+ ban_time=ban_time,
+ ),
+ parse_mode=ParseMode.HTML,
+ )
+ logger.debug(f"Blocked banned user {user_id}.")
+ return False
+ return True
+ except Exception as e:
+ logger.error(f"Error in check_banned: {e}", exc_info=True)
+ return False
+
+
+async def check_private_mode(client, message: Message) -> bool:
+ """PRIVATE_MODE allowlist gate: owner + authorized users only."""
+ if not Var.PRIVATE_MODE:
+ return True
+ if not message.from_user:
+ # Channel-posted / anonymous-admin messages have no verifiable user
+ # id, so allowlist membership cannot be checked: fail-closed.
+ logger.debug("Rejected unattributable sender (PRIVATE_MODE, no from_user).")
+ await _reply_ignore_failure(message, MSG_PRIVATE_MODE_DENIED)
+ return False
+ user_id = message.from_user.id
+ if user_id == Var.OWNER_ID:
+ return True
+ try:
+ if await allowed(user_id):
+ return True
+ except Exception as e:
+ logger.error(f"Private-mode auth check failed for {user_id}: {e}", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_TEMP)
+ return False
+ await _reply_ignore_failure(message, MSG_PRIVATE_MODE_DENIED)
+ logger.debug(f"Rejected non-allowlisted user {user_id} (PRIVATE_MODE).")
+ return False
+
+
+async def require_token(client, message: Message) -> bool:
+ """Token-activation gate (cached checks, fail-closed)."""
+ try:
+ # TOKEN_ENABLED short-circuit comes FIRST: with the feature off the
+ # gate must no-op even for anonymous senders, or anonymous /link breaks
+ if not Var.TOKEN_ENABLED:
+ return True
+
+ if not message.from_user:
+ # anonymous senders cannot hold a token: fail-closed
+ logger.debug("Denied unattributable sender (token gate, no from_user).")
+ await _reply_ignore_failure(message, MSG_ERROR_ANONYMOUS_SENDER)
+ return False
+
+ user_id = message.from_user.id
+ if user_id == Var.OWNER_ID:
+ return True
+
+ try:
+ if await allowed(user_id) or await check(user_id):
+ return True
+ except Exception as e:
+ logger.error(f"Token gate degraded for user {user_id}: {e}", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_TEMP)
+ return False
+
+ try:
+ temp_token_string = await generate(user_id)
+ except Exception as e:
+ logger.error(
+ f"Failed to generate temporary token for user {user_id}: {e}", exc_info=True
+ )
+ await _reply_ignore_failure(message, MSG_ERROR_TOKEN_LINK_FAILED)
+ return False
+
+ # generate() returns a token or raises; no empty-string path exists
+
+ try:
+ me = await tg_call(client.get_me)
+ except Exception as e:
+ logger.error(f"Failed to get bot info for user {user_id}: {e}", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_UNEXPECTED)
+ return False
+ if not me:
+ logger.error(f"get_me returned nothing for user {user_id}.", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_UNEXPECTED)
+ return False
+ deep_link = (
+ "https://t.me/" + me.username + "?start=" + quote_plus(temp_token_string, safe="")
+ )
+ short_url = deep_link
+
+ try:
+ short_url_result = await shorten(deep_link)
+ if short_url_result:
+ short_url = short_url_result
+ except Exception as e:
+ logger.warning(
+ f"Failed to shorten token link for user {user_id}: {e}. Using full link.",
+ exc_info=True,
+ )
+
+ await _reply_ignore_failure(
+ message,
+ MSG_TOKEN_INVALID,
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton("Activate Access", url=short_url)]]
+ ),
+ )
+ logger.debug(f"Sent temporary token activation link to user {user_id}.")
+ return False
+ except Exception as e:
+ logger.error(f"Error in require_token: {e}", exc_info=True)
+ await _reply_ignore_failure(message, MSG_ERROR_UNEXPECTED)
+ return False
+
+
+async def get_shortener_status(client, message: Message) -> bool:
+ try:
+ user_id = message.from_user.id if message.from_user else None
+ use_shortener = Var.SHORTEN_MEDIA_LINKS
+ if user_id:
+ try:
+ if user_id == Var.OWNER_ID or await allowed(user_id):
+ use_shortener = False
+ except Exception as e:
+ logger.warning(
+ f"Error checking allowed status for user {user_id}: {e}. Defaulting shortener behavior.",
+ exc_info=True,
+ )
+ return use_shortener
+ except Exception as e:
+ logger.error(f"Error in get_shortener_status: {e}", exc_info=True)
+ return Var.SHORTEN_MEDIA_LINKS
+
+
+# unified preflight chain
+
+# gate registry -- order is the documented contract; adding a new gate is a
+# one-place change here (chain asserted by tests/test_unit/test_preflight.py).
+PREFLIGHT_GATES = {
+ "banned": check_banned,
+ "private_mode": check_private_mode,
+ "token": require_token,
+}
+
+# preset gate chains -- the documented orders, so callers cannot invent
+# their own sequence and a new command cannot forget a gate
+GATES_STANDARD: tuple = ("banned", "private_mode", "token")
+GATES_START: tuple = ("banned", "private_mode")
+# info commands (/help, /about, /dc, /ping) run GATES_START on purpose: the
+# token gate must not block them for token-pending users
+
+
+async def preflight(
+ client,
+ message: Message,
+ *,
+ gates: tuple = GATES_STANDARD,
+) -> bool | None:
+ """Run the standard gate chain in order.
+
+ Returns the final shortener status (last gate's value convention) or
+ ``None`` when any gate rejects the request. Unknown gate ids REJECT
+ (fail-closed) -- a typo'd id must never silently disable a check.
+ """
+ for name in gates:
+ gate = PREFLIGHT_GATES.get(name)
+ if gate is None:
+ logger.error(f"preflight: unknown gate {name!r}; rejecting request (fail-closed)")
+ return None
+ if not await gate(client, message):
+ return None
+ return await get_shortener_status(client, message)
+
+
+async def force_sub_gate(client, message: Message) -> bool:
+ """Force-subscribe gate, kept separate so the rate-limit chain can slot
+ it after the token gate (called explicitly by stream entries)."""
+ from Thunder.utils.force_channel import force_channel_check
+
+ return await force_channel_check(client, message)
+
+
+async def owner_only(client, update) -> bool:
+ try:
+ user = None
+ if hasattr(update, "from_user"):
+ user = update.from_user
+ else:
+ logger.error(
+ f"Unsupported update type or missing from_user in owner_only: {type(update)}",
+ exc_info=True,
+ )
+ return False
+
+ if not user or user.id != Var.OWNER_ID:
+ if hasattr(update, "answer"):
+ await answer_safe(update, MSG_ERROR_UNAUTHORIZED, show_alert=True)
+ logger.warning(
+ f"Unauthorized access attempt by {user.id if user else 'unknown'} to owner_only function."
+ )
+ return False
+
+ return True
+ except Exception as e:
+ logger.error(f"Error in owner_only: {e}", exc_info=True)
+ try:
+ if hasattr(update, "answer"):
+ await answer_safe(update, MSG_ERROR_UNEXPECTED, show_alert=True)
+ except Exception as inner_e:
+ logger.error(f"Failed to send error answer in owner_only: {inner_e}", exc_info=True)
+ return False
diff --git a/Thunder/utils/file_properties.py b/Thunder/utils/file_properties.py
index c83f7ae7..c70eb44d 100644
--- a/Thunder/utils/file_properties.py
+++ b/Thunder/utils/file_properties.py
@@ -1,99 +1,68 @@
-# Thunder/utils/file_properties.py
-
-import asyncio
-from datetime import datetime as dt
-from typing import Any, Optional
-
-from pyrogram.client import Client
-from pyrogram.errors import FloodWait
-from pyrogram.file_id import FileId
-from pyrogram.types import Message
-
-from Thunder.server.exceptions import FileNotFound
-from Thunder.utils.logger import logger
-
-
-def get_media(message: Message) -> Optional[Any]:
- for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"):
- media = getattr(message, attr, None)
- if media:
- return media
- return None
-
-
-def get_uniqid(message: Message) -> Optional[str]:
- media = get_media(message)
- return getattr(media, 'file_unique_id', None)
-
-
-def get_hash(media_msg: Message) -> str:
- uniq_id = get_uniqid(media_msg)
- return uniq_id[:6] if uniq_id else ''
-
-
-def get_fsize(message: Message) -> int:
- media = get_media(message)
- return getattr(media, 'file_size', 0) if media else 0
-
-
-def parse_fid(message: Message) -> Optional[FileId]:
- media = get_media(message)
- if media and hasattr(media, 'file_id'):
- try:
- return FileId.decode(media.file_id)
- except Exception:
- return None
- return None
-
-
-def get_fname(msg: Message) -> str:
- media = get_media(msg)
- fname = getattr(media, 'file_name', None) if media else None
-
- if not fname:
- ext = "bin"
- if media:
- media_types = {
- "photo": "jpg",
- "audio": "mp3",
- "voice": "ogg",
- "video": "mp4",
- "animation": "mp4",
- "video_note": "mp4",
- "sticker": "webp"
- }
-
- # Check which attribute type the message has
- for attr, extension in media_types.items():
- if getattr(msg, attr, None) is not None:
- ext = extension
- break
-
- timestamp = dt.now().strftime("%Y%m%d%H%M%S")
- fname = f"Thunder File To Link_{timestamp}.{ext}"
-
- return fname
-
-
-async def get_fids(client: Client, chat_id: int, message_id: int) -> FileId:
- try:
- try:
- msg = await client.get_messages(chat_id, message_id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- msg = await client.get_messages(chat_id, message_id)
-
- if not msg or getattr(msg, 'empty', False):
- raise FileNotFound("Message not found")
-
- media = get_media(msg)
- if media:
- if not hasattr(media, 'file_id') or not hasattr(media, 'file_unique_id'):
- raise FileNotFound("Media metadata incomplete")
- return FileId.decode(media.file_id)
-
- raise FileNotFound("No media in message")
-
- except Exception as e:
- logger.error(f"Error in get_fids: {e}", exc_info=True)
- raise FileNotFound(str(e))
+from datetime import datetime as dt
+from typing import Any
+
+from pyrogram.file_id import FileId
+from pyrogram.types import Message
+
+from Thunder.utils.media_types import canonical_media_type, ext_for
+
+
+def get_media(message: Message) -> Any | None:
+ for attr in (
+ "audio",
+ "document",
+ "photo",
+ "sticker",
+ "animation",
+ "video",
+ "voice",
+ "video_note",
+ ):
+ media = getattr(message, attr, None)
+ if media:
+ return media
+ return None
+
+
+def get_uniqid(message: Message) -> str | None:
+ media = get_media(message)
+ return getattr(media, "file_unique_id", None)
+
+
+def get_hash(media_msg: Message) -> str:
+ uniq_id = get_uniqid(media_msg)
+ return uniq_id[:6] if uniq_id else ""
+
+
+def get_fsize(message: Message) -> int:
+ media = get_media(message)
+ return getattr(media, "file_size", 0) if media else 0
+
+
+def parse_fid(message: Message) -> FileId | None:
+ media = get_media(message)
+ if media and hasattr(media, "file_id"):
+ try:
+ return FileId.decode(media.file_id)
+ except Exception:
+ return None
+ return None
+
+
+def get_fname(msg: Message) -> str:
+ media = get_media(msg)
+ fname = getattr(media, "file_name", None) if media else None
+
+ if not fname:
+ ext = "bin"
+ if media:
+ # single media-type map: attribute -> canonical key -> ext
+ for attr in ("photo", "audio", "voice", "video", "animation", "video_note", "sticker"):
+ if getattr(msg, attr, None) is not None:
+ ext = ext_for(canonical_media_type(attr=attr))
+ break
+
+ timestamp = dt.now().strftime("%Y%m%d%H%M%S")
+ fname = f"Thunder File To Link_{timestamp}.{ext}"
+
+ return fname
diff --git a/Thunder/utils/flag_cache.py b/Thunder/utils/flag_cache.py
new file mode 100644
index 00000000..ec70f517
--- /dev/null
+++ b/Thunder/utils/flag_cache.py
@@ -0,0 +1,130 @@
+"""Tiny lazy TTL+LRU cache for per-user/per-channel flags.
+
+Mirrors ThunderGo's ``internal/store/cache.go``: values are loaded on first
+access, kept for ``ttl_seconds`` and evicted least-recently-used beyond
+``max_items``. A periodic :meth:`sweep` drops expired entries so memory
+stays bounded even for bots with large user bases.
+
+Loader exceptions deliberately propagate -- callers implement their own
+fail-closed policy (see ``utils/decorators.py``).
+"""
+
+import asyncio
+import time
+from collections import OrderedDict
+from collections.abc import Awaitable, Callable, Hashable
+from typing import Any
+
+from Thunder.utils.logger import logger
+
+DEFAULT_TTL_SECONDS = 300
+DEFAULT_MAX_ITEMS = 4096
+_SWEEP_INTERVAL_SECONDS = 300
+
+
+class FlagCache:
+ def __init__(
+ self,
+ *,
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
+ max_items: int = DEFAULT_MAX_ITEMS,
+ name: str = "flags",
+ ):
+ self.ttl_seconds = ttl_seconds
+ self.max_items = max_items
+ self.name = name
+ self._data: OrderedDict[Hashable, tuple[Any, float]] = OrderedDict()
+ self._inflight: dict[Hashable, asyncio.Task] = {}
+
+ def _prune_expired(self, now: float) -> None:
+ expired = [key for key, (_, ts) in self._data.items() if now - ts > self.ttl_seconds]
+ for key in expired:
+ self._data.pop(key, None)
+
+ async def get_or_load(
+ self,
+ key: Hashable,
+ loader: Callable[[], Awaitable[Any]],
+ ) -> Any:
+ now = time.monotonic()
+ if key in self._data:
+ value, ts = self._data[key]
+ if now - ts <= self.ttl_seconds:
+ self._data.move_to_end(key)
+ return value
+ self._data.pop(key, None)
+
+ # single-flight: concurrent callers of a cold/expired key share one
+ # loader task instead of stampeding the backend with N identical reads
+ #
+ # _inflight needs no cap of its own: _load_and_store pops its own entry
+ # on BOTH success and failure, so the dict only ever holds loaders that
+ # are running right now -- it is bounded by concurrency by construction.
+ # An earlier version capped it at max_items and fell back to loading
+ # inline, which was worse in every way: the store in _load_and_store is
+ # fenced on `self._inflight.get(key) is task`, and an unregistered inline
+ # call never satisfies that, so the value was returned and never cached.
+ # Every later read paid another backend round-trip. Reverted deliberately.
+ task = self._inflight.get(key)
+ if task is None:
+ task = asyncio.create_task(self._load_and_store(key, loader))
+ self._inflight[key] = task
+ return await asyncio.shield(task)
+
+ async def _load_and_store(
+ self,
+ key: Hashable,
+ loader: Callable[[], Awaitable[Any]],
+ ) -> Any:
+ task = asyncio.current_task()
+ try:
+ value = await loader()
+ except BaseException:
+ # only drop our own registration: a successor loader registered
+ # after an invalidate() must survive our failure
+ if self._inflight.get(key) is task:
+ self._inflight.pop(key, None)
+ raise
+ # Fence: invalidate()/clear() may have dropped our registration
+ # while the loader was in flight; re-storing the pre-mutation value
+ # would re-cache a stale gate answer for a full TTL. Store only if we
+ # are still the registered loader for this key.
+ if self._inflight.get(key) is task:
+ self._data[key] = (value, time.monotonic())
+ self._data.move_to_end(key)
+ while len(self._data) > self.max_items:
+ self._data.popitem(last=False)
+ self._inflight.pop(key, None)
+ return value
+
+ def invalidate(self, *keys: Hashable) -> None:
+ for key in keys:
+ self._data.pop(key, None)
+ self._inflight.pop(key, None)
+
+ def clear(self) -> None:
+ self._data.clear()
+ self._inflight.clear()
+
+ def sweep(self) -> int:
+ now = time.monotonic()
+ before = len(self._data)
+ self._prune_expired(now)
+ dropped = before - len(self._data)
+ if dropped:
+ logger.debug(f"flag_cache[{self.name}]: swept {dropped} expired entries")
+ return dropped
+
+ async def run_sweeper(self) -> None:
+ """Background loop; cancel to stop. Registered at startup."""
+ while True:
+ await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
+ try:
+ self.sweep()
+ except Exception as e:
+ logger.error(f"flag_cache[{self.name}] sweeper error: {e}", exc_info=True)
+
+
+flags = FlagCache(name="user_flags")
+
+__all__ = ["FlagCache", "flags", "DEFAULT_TTL_SECONDS", "DEFAULT_MAX_ITEMS"]
diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py
index 698ef180..e2881ab9 100644
--- a/Thunder/utils/force_channel.py
+++ b/Thunder/utils/force_channel.py
@@ -1,89 +1,148 @@
-# Thunder/utils/force_channel.py
-
-import asyncio
-
-from pyrogram import Client
-from pyrogram.errors import FloodWait, UserNotParticipant
-from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
-
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import MSG_COMMUNITY_CHANNEL
-from Thunder.vars import Var
-
-_force_link = None
-_force_title = None
-
-async def get_force_info(bot: Client):
- global _force_link, _force_title
-
- if not Var.FORCE_CHANNEL_ID:
- return None, None
-
- if _force_link is not None and _force_title is not None:
- return _force_link, _force_title
-
- try:
- try:
- chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
- if chat:
- _force_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
- _force_title = chat.title or "Channel"
- return _force_link, _force_title
- except Exception as e:
- logger.error(f"Force channel error: {e}", exc_info=True)
- return None, None
-
-async def force_channel_check(client: Client, message: Message):
- if not Var.FORCE_CHANNEL_ID:
- return True
-
- if message.from_user is None:
- return True
-
- try:
- while True:
- try:
- member = await client.get_chat_member(Var.FORCE_CHANNEL_ID, message.from_user.id)
- if member is None:
- logger.error(f"Failed to get chat member for {message.from_user.id} in force channel {Var.FORCE_CHANNEL_ID} after retries.")
- return False
- return True
- except FloodWait as e:
- logger.debug(f"FloodWait in force_channel_check, sleeping for {e.value}s")
- await asyncio.sleep(e.value)
- except UserNotParticipant:
- link, title = await get_force_info(client)
- if link and title:
- try:
- await message.reply_text(
- MSG_COMMUNITY_CHANNEL.format(channel_title=title),
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton("Join", url=link)
- ]])
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_COMMUNITY_CHANNEL.format(channel_title=title),
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton("Join", url=link)
- ]])
- )
- else:
- try:
- await message.reply_text("You must join the channel to use this bot.")
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("You must join the channel to use this bot.")
- return False
- except Exception as e:
- logger.error(f"Error checking force channel: {e}", exc_info=True)
- try:
- await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
- return False
+import html
+import time
+
+from pyrogram import Client
+from pyrogram.enums import ChatMemberStatus, ParseMode
+from pyrogram.errors import UserNotParticipant
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from Thunder.utils.flag_cache import FlagCache
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_COMMUNITY_CHANNEL,
+ MSG_FORCE_JOIN_BUTTON,
+ MSG_FORCE_SUB_CHECK_FAILED,
+ MSG_FORCE_SUB_REQUIRED,
+)
+from Thunder.utils.safe_call import reply_safe, tg_call
+from Thunder.vars import Var
+
+_force_link = None
+_force_title = None
+_force_resolved = False
+_resolved_at = 0.0
+_RESOLVED_TTL_SECONDS = 300.0
+_negative_until = 0.0
+_NEGATIVE_TTL_SECONDS = 60.0
+
+# Membership cache: one get_chat_member RPC per gated message was the
+# last uncached hot-path read. Short symmetric TTL bounds the RPC volume;
+# either direction is at most 60s stale (a join waits, a leave lingers) --
+# asymmetric caching would only move the fail-open window, not close it.
+_membership_cache = FlagCache(ttl_seconds=60, max_items=4096, name="force_member")
+
+
+async def _is_member(client: Client, user_id: int) -> bool:
+ try:
+ member = await tg_call(
+ client.get_chat_member,
+ Var.FORCE_CHANNEL_ID,
+ user_id,
+ retries=1,
+ )
+ # get_chat_member only raises UserNotParticipant when the user is
+ # absent entirely; banned and left members come back as a ChatMember,
+ # and admitting those would let the people a channel removed from it
+ # skip the join
+ if member.status in (
+ ChatMemberStatus.OWNER,
+ ChatMemberStatus.ADMINISTRATOR,
+ ChatMemberStatus.MEMBER,
+ ):
+ return True
+ return member.status == ChatMemberStatus.RESTRICTED and bool(
+ getattr(member, "is_member", False)
+ )
+ except UserNotParticipant:
+ return False
+ # any other error propagates: the gate stays fail-closed
+
+
+async def get_force_info(bot: Client):
+ global _force_link, _force_title, _force_resolved, _resolved_at, _negative_until
+
+ if not Var.FORCE_CHANNEL_ID:
+ return None, None
+
+ # positive cache with a TTL: invite links/titles can rotate, so a
+ # resolved-once entry would serve stale join buttons forever
+ if _force_resolved and time.monotonic() - _resolved_at <= _RESOLVED_TTL_SECONDS:
+ return _force_link, _force_title
+ if time.monotonic() < _negative_until:
+ return None, None
+
+ try:
+ chat = await tg_call(bot.get_chat, Var.FORCE_CHANNEL_ID, retries=1)
+ if chat:
+ # numeric channel id: get_chat always resolves a full Chat
+ # (ChatPreview only comes from link resolution), hence the ignores
+ _force_link = chat.invite_link or ( # type: ignore[union-attr]
+ f"https://t.me/{chat.username}" if chat.username else None # type: ignore[union-attr]
+ )
+ _force_title = chat.title or "Channel"
+ # cache even the no-link outcome, or it re-resolves per message
+ _force_resolved = True
+ _resolved_at = time.monotonic()
+ return _force_link, _force_title
+ except Exception as e:
+ # transient RPC failure: short negative cache so the gate path does
+ # not hammer get_chat on every message during a Telegram brownout
+ _negative_until = time.monotonic() + _NEGATIVE_TTL_SECONDS
+ logger.error(f"Force channel error: {e}", exc_info=True)
+ return None, None
+
+
+async def force_channel_check(client: Client, message: Message):
+ if not Var.FORCE_CHANNEL_ID:
+ return True
+
+ if message.from_user is not None and message.from_user.id == Var.OWNER_ID:
+ # owner bypasses everything, including force-sub
+ return True
+
+ if message.from_user is None:
+ # fail-closed: channel posts / anonymous admins have no verifiable
+ # user id, so membership cannot be checked -- deny like the token
+ # gate's MSG_ERROR_ANONYMOUS_SENDER path (callers deny on False)
+ logger.debug("Denied unattributable sender (force-sub, no from_user).")
+ return False
+
+ try:
+ is_member = await _membership_cache.get_or_load(
+ (Var.FORCE_CHANNEL_ID, message.from_user.id),
+ lambda: _is_member(client, message.from_user.id),
+ )
+ except Exception as e:
+ logger.error(f"Error checking force channel: {e}", exc_info=True)
+ try:
+ await reply_safe(message, MSG_FORCE_SUB_CHECK_FAILED)
+ except Exception as inner_e:
+ logger.warning(f"Could not send force-sub error notice: {inner_e}")
+ return False
+
+ if is_member:
+ return True
+
+ link, title = await get_force_info(client)
+ if link and title:
+ try:
+ await reply_safe(
+ message,
+ MSG_COMMUNITY_CHANNEL.format(
+ # escaped twin of the /help panel line (common.py);
+ # HTML parse mode skips the markdown pre-pass
+ channel_title=html.escape(title or "Channel")
+ ),
+ parse_mode=ParseMode.HTML,
+ reply_markup=InlineKeyboardMarkup(
+ [[InlineKeyboardButton(MSG_FORCE_JOIN_BUTTON, url=link)]]
+ ),
+ )
+ except Exception as e:
+ logger.warning(f"Could not send force-sub prompt: {e}")
+ else:
+ try:
+ await reply_safe(message, MSG_FORCE_SUB_REQUIRED)
+ except Exception as e:
+ logger.warning(f"Could not send force-sub notice: {e}")
+ return False
diff --git a/Thunder/utils/human_readable.py b/Thunder/utils/human_readable.py
index 0a7ee460..af90a5be 100644
--- a/Thunder/utils/human_readable.py
+++ b/Thunder/utils/human_readable.py
@@ -1,18 +1,18 @@
-# Thunder/utils/human_readable.py
-
-from Thunder.utils.logger import logger
-
-_UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
-
-def humanbytes(size: int, decimal_places: int = 2) -> str:
- try:
- if not size:
- return "0 B"
- n = 0
- while size >= 1024 and n < len(_UNITS) - 1:
- size /= 1024
- n += 1
- return f"{round(size, decimal_places)} {_UNITS[n]}B"
- except Exception as e:
- logger.error(f"Error in humanbytes for size {size}: {e}", exc_info=True)
- return "N/A"
+from Thunder.utils.logger import logger
+
+_UNITS = ("", "K", "M", "G", "T", "P", "E", "Z", "Y")
+
+
+def humanbytes(size: int, decimal_places: int = 2) -> str:
+ try:
+ if not size:
+ return "0 B"
+ n = 0
+ value: float = size
+ while value >= 1024 and n < len(_UNITS) - 1:
+ value /= 1024
+ n += 1
+ return f"{round(value, decimal_places)} {_UNITS[n]}B"
+ except Exception as e:
+ logger.error(f"Error in humanbytes for size {size}: {e}", exc_info=True)
+ return "N/A"
diff --git a/Thunder/utils/keepalive.py b/Thunder/utils/keepalive.py
index d7089c04..5d0de84b 100644
--- a/Thunder/utils/keepalive.py
+++ b/Thunder/utils/keepalive.py
@@ -1,22 +1,57 @@
-# Thunder/utils/keepalive.py
-
-import asyncio
-import aiohttp
-from Thunder.vars import Var
-from Thunder.utils.logger import logger
-
-async def ping_server():
- try:
- async with aiohttp.ClientSession(
- timeout=aiohttp.ClientTimeout(total=10)
- ) as session:
- while True:
- try:
- await asyncio.sleep(Var.PING_INTERVAL)
- async with session.get(Var.URL) as resp:
- if resp.status != 200:
- logger.warning(f"Ping to {Var.URL} returned status {resp.status}.")
- except asyncio.CancelledError:
- break
- except Exception as e:
- logger.error(f"Error in ping_server: {e}", exc_info=True)
+import asyncio
+import os
+
+import aiohttp
+
+from Thunder.utils.logger import logger
+from Thunder.vars import Var
+
+
+def _health_url() -> str:
+ """Ping /health on ourselves.
+
+ The historical implementation GET ``Var.URL``, whose root handler is a
+ 302 to GitHub -- so the keepalive had been validating GitHub, not this
+ bot. We now bind to the configured address explicitly and check the
+ status code.
+ """
+ fqdn = (os.getenv("KEEPALIVE_HOST") or "").strip()
+ if fqdn:
+ if "://" not in fqdn:
+ # host-only value: PaaS platforms serve https on 443, so assume
+ # https rather than rebuilding an http://host:PORT URL that cannot
+ # connect from outside. Include a scheme to override the default.
+ fqdn = f"https://{fqdn}"
+ # full public URL for PaaS anti-sleep: external traffic keeps free
+ # tiers awake, so use it verbatim instead of rebuilding loopback
+ return f"{fqdn.rstrip('/')}/health"
+ fqdn = Var.BIND_ADDRESS
+ if fqdn in ("0.0.0.0", "::"): # nosec B104 -- string check mapping bind-all to loopback
+ fqdn = "127.0.0.1"
+ # loopback unless KEEPALIVE_HOST overrides
+ return f"http://{fqdn}:{Var.PORT}/health"
+
+
+async def ping_server():
+ try:
+ url = _health_url()
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
+ while True:
+ try:
+ await asyncio.sleep(Var.PING_INTERVAL)
+ async with session.get(url) as resp:
+ body = await resp.text()
+ if resp.status != 200:
+ logger.warning(
+ f"Health check to {url} returned status {resp.status}: {body[:120]}"
+ )
+ else:
+ logger.debug("Health check OK")
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.warning(f"Health check failed: {e}")
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logger.error(f"Error in ping_server: {e}", exc_info=True)
diff --git a/Thunder/utils/logger.py b/Thunder/utils/logger.py
index 6609a39b..cbca35df 100644
--- a/Thunder/utils/logger.py
+++ b/Thunder/utils/logger.py
@@ -1,39 +1,115 @@
-# Thunder/utils/logger.py
-
-import logging
-from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener
-import os
-import queue
-import atexit
-import sys
-
-LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
-os.makedirs(LOG_DIR, exist_ok=True)
-LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
-
-logging._srcfile = None
-logging.logThreads = 0
-logging.logProcesses = 0
-
-log_queue = queue.Queue(maxsize=10000)
-
-formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-
-file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8')
-file_handler.setFormatter(formatter)
-
-console_handler = logging.StreamHandler(stream=sys.__stdout__)
-console_handler.setFormatter(formatter)
-console_handler.stream.reconfigure(encoding='utf-8', errors='replace')
-
-listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
-listener.start()
-
-logger = logging.getLogger('ThunderBot')
-logger.setLevel(logging.INFO)
-logger.propagate = False
-logger.addHandler(QueueHandler(log_queue))
-
-atexit.register(listener.stop)
-
-__all__ = ['logger', 'LOG_FILE']
+import atexit
+import hashlib
+import json
+import logging
+import os
+import queue
+import re
+import sys
+from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
+
+LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
+os.makedirs(LOG_DIR, exist_ok=True)
+LOG_FILE = os.path.join(LOG_DIR, "bot.txt")
+
+logging._srcfile = None
+logging.logThreads = False
+logging.logProcesses = False
+
+# shared secret redaction -- the access-log middleware and /log upload
+# both go through these so no token / Mongo URI can leave the machine.
+
+BOT_TOKEN_PATTERN = re.compile(r"\d{8,10}:[A-Za-z0-9_-]{35,}")
+MONGO_URI_PATTERN = re.compile(r"mongodb(\+srv)?://[^:]+:[^@]+@")
+# intentionally broad -- scrubber-only, do not reuse for user-facing text.
+SESSION_TOKEN_PATTERN = re.compile(r"(?i)(authorization:\s*)(Bearer\s+)?[A-Za-z0-9._\-]{20,}")
+# API_HASH assignments (32-hex value; contextual so file hashes still log)
+API_HASH_PATTERN = re.compile(r"(?i)(api_hash['\"]?\s*[:=]\s*['\"]?)([0-9a-f]{32})")
+# pyrogram session strings (long base64url blobs assigned to session vars)
+SESSION_STRING_PATTERN = re.compile(
+ r"(?i)(session_string['\"]?\s*[:=]\s*['\"]?)([A-Za-z0-9_-]{40,})"
+)
+# activation tokens in t.me deep links (?start=<43-char urlsafe token>)
+ACTIVATION_TOKEN_PATTERN = re.compile(r"(\?start=)([A-Za-z0-9_-]{43})")
+
+REDACTED = "***REDACTED***"
+
+
+def redact_secrets(text: str) -> str:
+ """Strip bot tokens, Mongo credentials, API hashes, session strings and
+ activation tokens from a log payload."""
+ if not text:
+ return text
+ text = BOT_TOKEN_PATTERN.sub(REDACTED, text)
+ text = MONGO_URI_PATTERN.sub("mongodb://***:***@", text)
+ text = SESSION_TOKEN_PATTERN.sub(r"\1\2" + REDACTED, text)
+ text = API_HASH_PATTERN.sub(r"\1" + REDACTED, text)
+ text = SESSION_STRING_PATTERN.sub(r"\1" + REDACTED, text)
+ text = ACTIVATION_TOKEN_PATTERN.sub(r"\1" + REDACTED, text)
+ return text
+
+
+def hash_path_token(token: str) -> str:
+ """Stable short pseudonym for a file token in access logs."""
+ return hashlib.sha256(token.encode("utf-8", "ignore")).hexdigest()[:8]
+
+
+class RedactingFormatter(logging.Formatter):
+ """Always redacts. Both call sites want it, so there is no off switch to
+ forget -- a formatter that skipped redaction would leak BOT_TOKEN and the
+ Mongo URI straight into the log file."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ return redact_secrets(super().format(record))
+
+
+class JsonFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ payload = {
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
+ "level": record.levelname,
+ "name": record.name,
+ "msg": redact_secrets(record.getMessage()),
+ }
+ if record.exc_info:
+ payload["exc"] = redact_secrets(self.formatException(record.exc_info))
+ return json.dumps(payload, ensure_ascii=False)
+
+
+_log_level_name = os.getenv("LOG_LEVEL", "INFO").upper()
+_log_level = getattr(logging, _log_level_name, logging.INFO)
+_log_format = os.getenv("LOG_FORMAT", "plain").lower()
+
+log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=10000)
+
+if _log_format == "json":
+ file_formatter: logging.Formatter = JsonFormatter()
+ console_formatter: logging.Formatter = JsonFormatter()
+else:
+ plain = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ file_formatter = RedactingFormatter(plain)
+ console_formatter = RedactingFormatter(plain)
+
+file_handler = RotatingFileHandler(
+ LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
+)
+file_handler.setFormatter(file_formatter)
+
+console_handler = logging.StreamHandler(stream=sys.__stdout__)
+console_handler.setFormatter(console_formatter)
+# reconfigure only exists on io.TextIOWrapper; guard wrapped/replaced streams.
+_stream = console_handler.stream
+if hasattr(_stream, "reconfigure"):
+ _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
+
+listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
+listener.start()
+
+logger = logging.getLogger("ThunderBot")
+logger.setLevel(_log_level)
+logger.propagate = False
+logger.addHandler(QueueHandler(log_queue))
+
+atexit.register(listener.stop)
+
+__all__ = ["logger", "LOG_FILE", "redact_secrets", "hash_path_token"]
diff --git a/Thunder/utils/media_types.py b/Thunder/utils/media_types.py
new file mode 100644
index 00000000..89523aba
--- /dev/null
+++ b/Thunder/utils/media_types.py
@@ -0,0 +1,82 @@
+"""Single source of truth for media-type -> extension / mime maps.
+
+``common.send_file_dc`` keeps its own display-name map (presentation, not a
+mime/ext concern). Both naming families are keyed: pyrogram class names are
+lower-cased with no underscore (``videonote``) while message attribute names
+use ``video_note`` -- both are accepted everywhere.
+"""
+
+from Thunder.utils.logger import logger
+
+# message attribute name -> stable canonical key
+_ATTR_TO_MEDIA_TYPE: dict[str, str] = {
+ "audio": "audio",
+ "document": "document",
+ "photo": "photo",
+ "sticker": "sticker",
+ "animation": "animation",
+ "video": "video",
+ "voice": "voice",
+ "video_note": "video_note",
+}
+
+# pyrogram class-name (lower) -> canonical key
+_CLASS_TO_MEDIA_TYPE: dict[str, str] = {
+ "audio": "audio",
+ "document": "document",
+ "photo": "photo",
+ "sticker": "sticker",
+ "animation": "animation",
+ "video": "video",
+ "voice": "voice",
+ "videonote": "video_note",
+ "video_note": "video_note", # both naming families accepted
+}
+
+# canonical key -> (extension, mime type)
+_MEDIA_EXT_MIME: dict[str, tuple[str, str]] = {
+ "photo": ("jpg", "image/jpeg"),
+ "audio": ("mp3", "audio/mpeg"),
+ "voice": ("ogg", "audio/ogg"),
+ "video": ("mp4", "video/mp4"),
+ "animation": ("mp4", "video/mp4"),
+ "video_note": ("mp4", "video/mp4"),
+ "sticker": ("webp", "image/webp"),
+ "document": ("bin", "application/octet-stream"),
+}
+
+DEFAULT_EXT = "bin"
+DEFAULT_MIME = "application/octet-stream"
+
+
+def canonical_media_type(*, attr: str | None = None, media: object | None = None) -> str:
+ """Resolve a canonical media key from a message attribute or media object."""
+ if attr and attr in _ATTR_TO_MEDIA_TYPE:
+ return _ATTR_TO_MEDIA_TYPE[attr]
+ if media is not None:
+ return _CLASS_TO_MEDIA_TYPE.get(type(media).__name__.lower(), "document")
+ return "document"
+
+
+def ext_for(media_key: str) -> str:
+ return _MEDIA_EXT_MIME.get(media_key, (DEFAULT_EXT, DEFAULT_MIME))[0]
+
+
+def ext_and_mime_for_class(class_name_lower: str) -> tuple[str, str]:
+ """Direct lookup by pyrogram class name (``videonote``, ``photo``, ...)."""
+ key = _CLASS_TO_MEDIA_TYPE.get(class_name_lower)
+ if key is None:
+ # unknown classes fall back instead of raising (download path must
+ # not die on a new pyrogram type); debug-logged for visibility
+ logger.debug(f"Unknown media class for ext/mime lookup: {class_name_lower!r}")
+ return DEFAULT_EXT, DEFAULT_MIME
+ return _MEDIA_EXT_MIME.get(key, (DEFAULT_EXT, DEFAULT_MIME))
+
+
+__all__ = [
+ "canonical_media_type",
+ "ext_for",
+ "ext_and_mime_for_class",
+ "DEFAULT_EXT",
+ "DEFAULT_MIME",
+]
diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py
index feb8f040..9532d5ef 100644
--- a/Thunder/utils/messages.py
+++ b/Thunder/utils/messages.py
@@ -1,399 +1,446 @@
-# Thunder/utils/messages.py
-
-# =====================================================================================
-# ====== ERROR MESSAGES ======
-# =====================================================================================
-
-# ------ General Errors ------
-MSG_ERROR_GENERIC = "⚠️ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
-MSG_ERROR_USER_INFO = "❗ **User Not Found:** Couldn't find user. Please check the ID or Username."
-
-# ------ User Input & Validation Errors ------
-MSG_INVALID_USER_ID = "❌ **Invalid User ID:** Please provide a numeric user ID."
-MSG_ERROR_START_BOT = "⚠️ You need to start the bot in private first to use this command.\n👉 [Click here]({invite_link}) to start a private chat."
-MSG_ERROR_REPLY_FILE = "⚠️ Please use the /link command in reply to a file."
-MSG_ERROR_NO_FILE = "⚠️ The message you're replying to does not contain any file."
-MSG_ERROR_INVALID_NUMBER = "⚠️ **Invalid number specified.**"
-MSG_ERROR_NUMBER_RANGE = "⚠️ **Please specify a number between 1 and {max_files}.**"
-MSG_ERROR_DM_FAILED = "⚠️ I couldn't send you a Direct Message. Please start the bot first."
-
-# ------ File & Media Errors ------
-MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
-
-# ------ Admin Action Errors (Ban, Auth, etc.) ------
-MSG_AUTHORIZE_FAILED = (
- "❌ **Authorization Failed:** "
- "Could not authorize user `{user_id}`."
-)
-MSG_DEAUTHORIZE_FAILED = (
- "❌ **Deauthorization Failed:** "
- "User `{user_id}` was not authorized or an error occurred."
-)
-MSG_TOKEN_FAILED = (
- "⚠️ **Token Activation Failed!**\n\n"
- "> ❗ Reason: {reason}\n\n"
- "🔑 Please check your token or contact support."
-)
-MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
-{error}"""
-
-# ------ System & Bot Errors ------
-MSG_ERROR_NOT_ADMIN = "⚠️ **Admin Required:** I need admin privileges to work here."
-MSG_DC_INVALID_USAGE = "🤔 **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
-MSG_DC_ANON_ERROR = "😥 **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
-MSG_DC_FILE_ERROR = "⚙️ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
-MSG_STATS_ERROR = "❌ **Stats Error:** Could not retrieve system statistics."
-MSG_STATUS_ERROR = "❌ **Status Error:** Could not retrieve system status."
-MSG_DB_ERROR = "❌ **Database Error:** Could not retrieve user count."
-MSG_CRITICAL_ERROR = (
- "🚨 **Critical Media Processing Error** 🚨\n\n"
- "> ⚠️ Details:\n```\n{error}\n```\n\n"
- "Please investigate immediately! (ID: {error_id})"
-)
-
-# =====================================================================================
-# ====== ADMIN MESSAGES ======
-# =====================================================================================
-
-# ------ Ban/Unban ------
-MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
-MSG_BAN_USAGE = "⚠️ **Usage:** /ban [user_id] [reason]"
-MSG_CANNOT_BAN_OWNER = "❌ **Cannot ban an owner.**"
-MSG_ADMIN_USER_BANNED = "✅ **User {user_id} has been banned."
-MSG_BAN_REASON_SUFFIX = "\n📝 **Reason:** {reason}"
-MSG_ADMIN_NO_BAN_REASON = "No reason provided"
-MSG_USER_BANNED_NOTIFICATION = "🚫 **You have been banned from using this bot.**"
-MSG_UNBAN_USAGE = "⚠️ **Usage:** /unban "
-MSG_ADMIN_USER_UNBANNED = "✅ **User {user_id} has been unbanned."
-MSG_USER_UNBANNED_NOTIFICATION = "🎉 **You have been unbanned from using this bot.**"
-MSG_USER_NOT_IN_BAN_LIST = "ℹ️ **User {user_id} was not found in the ban list."
-MSG_CHANNEL_BANNED = "✅ **Channel {channel_id} has been banned.**"
-MSG_CHANNEL_BANNED_REASON_SUFFIX = "\n📝 **Reason:** {reason}"
-MSG_CHANNEL_UNBANNED = "✅ **Channel {channel_id} has been unbanned.**"
-MSG_CHANNEL_NOT_BANNED = "ℹ️ **Channel {channel_id} was not found in the ban list.**"
-
-# ------ Token & Authorization ------
-MSG_AUTHORIZE_USAGE = "🔑 **Usage:** `/authorize `"
-MSG_DEAUTHORIZE_USAGE = "🔒 **Usage:** `/deauthorize `"
-MSG_AUTHORIZE_SUCCESS = (
- "✅ **User Authorized!**\n\n"
- "> 👤 User ID: `{user_id}`\n"
- "> 🔑 Access: Permanent"
-)
-MSG_DEAUTHORIZE_SUCCESS = (
- "✅ **User Deauthorized!**\n\n"
- "> 👤 User ID: `{user_id}`\n"
- "> 🔒 Access: Revoked"
-)
-MSG_TOKEN_ACTIVATED = "✅ Token successfully activated!\n\n⏳ This token is valid for {duration_hours} hours."
-MSG_TOKEN_INVALID = "🚫 **Expired or Invalid Token.** Please click the button below to activate your access token."
-MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
+# ====== LIMITS ======
+
+# Telegram rejects a message longer than 4096 UTF-16 code units. An emoji costs
+# 2 units where len() counts 1, so len() is the wrong measure.
+TELEGRAM_MAX_UNITS = 4096
+
+
+def telegram_len(text: str) -> int:
+ """Length as Telegram measures it: an emoji costs 2 units, len() counts 1."""
+ return len(text.encode("utf-16-le")) // 2
+
+
+def paginate_lines(text: str, budget: int) -> list[str]:
+ """Split text into pages of at most ``budget`` UTF-16 units, on line breaks.
+
+ Never splits mid-line: a line longer than the budget becomes its own page
+ rather than being chopped, because a half-rendered record is worse than one
+ over-long line the caller will notice.
+ """
+ if telegram_len(text) <= budget:
+ return [text]
+ pages: list[str] = []
+ current = ""
+ for line in text.split("\n"):
+ if current and telegram_len(current) + telegram_len(line) + 1 > budget:
+ pages.append(current)
+ current = ""
+ current += line + "\n"
+ if current:
+ pages.append(current)
+ return pages
+
+
+# ====== ERROR MESSAGES ======
+
+MSG_ERROR_GENERIC = (
+ "⚠️ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
+)
+MSG_ERROR_USER_INFO = "❗ **User Not Found:** Couldn't find user. Please check the ID or Username."
+
+MSG_INVALID_USER_ID = "❌ **Invalid User ID:** Please provide a numeric user ID."
+MSG_ERROR_START_BOT = "⚠️ You need to start the bot in private first to use this command.\n👉 [Click here]({invite_link}) to start a private chat."
+MSG_LINK_PRIVATE_HINT = (
+ "ℹ️ **/link is for Groups**\n\n"
+ "In private chat, just send me a file directly — no command needed."
+)
+MSG_ERROR_REPLY_FILE = "⚠️ Please use the /link command in reply to a file."
+MSG_ERROR_NO_FILE = "⚠️ The message you're replying to does not contain any file."
+MSG_ERROR_INVALID_NUMBER = "⚠️ **Invalid number specified.**"
+MSG_ERROR_NUMBER_RANGE = "⚠️ **Please specify a number between 1 and {max_files}.**"
+MSG_ERROR_DM_BATCH_FAILED = (
+ "⚠️ **Partial DM Delivery**\n\n"
+ "> 📭 I couldn't deliver {failed_chunks} of {total_chunks} batch chunk(s) to you in private chat.\n"
+ "> This usually means you haven't started a private chat with me, or you've blocked me."
+)
+MSG_ERROR_BATCH_CHUNK_FAILED = (
+ "⚠️ **Partial delivery**\n\n"
+ "> I couldn't send {failed_chunks} of {total_chunks} batch chunk(s) here — "
+ "those links are missing from the list above.\n"
+ "> Try `/link` again in smaller batches, or pick me up in private."
+)
+
+MSG_ERROR_TEMP = (
+ "⚠️ **Temporary service error.** Access checks are unavailable right now, "
+ "so your request was rejected. Please try again in a few minutes."
+)
+MSG_PRIVATE_MODE_DENIED = (
+ "🔒 **Private bot.** This instance is restricted to authorized users. "
+ "If you believe you should have access, contact the owner."
+)
+MSG_ERROR_ANONYMOUS_SENDER = (
+ "🙈 **Unidentifiable sender.** This command needs a regular user account "
+ "(anonymous admins and channel posts cannot use it)."
+)
+MSG_FORCE_JOIN_BUTTON = "📢 Join"
+MSG_FORCE_SUB_REQUIRED = "You must join the channel to use this bot."
+MSG_FORCE_SUB_CHECK_FAILED = (
+ "An unexpected error occurred while checking channel membership. Please try again."
+)
+
+MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
+
+MSG_AUTHORIZE_FAILED = "❌ **Authorization Failed:** Could not authorize user `{user_id}`."
+MSG_DEAUTHORIZE_FAILED = (
+ "❌ **Deauthorization Failed:** User `{user_id}` was not authorized or an error occurred."
+)
+MSG_TOKEN_FAILED = (
+ "⚠️ Token Activation Failed!\n\n"
+ "❗ Reason: {reason}
\n\n"
+ "🔑 Please check your token or contact support."
+)
+MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
+{error}"""
+
+MSG_ERROR_NOT_ADMIN = "⚠️ **Admin Required:** I need admin privileges to work here."
+MSG_DC_INVALID_USAGE = (
+ "🤔 **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
+)
+MSG_DC_ANON_ERROR = "😥 **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
+MSG_DC_FILE_ERROR = (
+ "⚙️ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
+)
+MSG_STATUS_ERROR = "❌ **Status Error:** Could not retrieve system status."
+MSG_DB_ERROR = "❌ **Database Error:** Could not retrieve user count."
+MSG_CRITICAL_ERROR = (
+ "🚨 **Critical Media Processing Error** 🚨\n\n"
+ "> ⚠️ Details:\n```\n{error}\n```\n\n"
+ "Please investigate immediately! (ID: {error_id})"
+)
+
+# ====== ADMIN MESSAGES ======
+
+# Ban surfaces are HTML: reason is user-controlled, escape at render.
+MSG_DECORATOR_BANNED = (
+ "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
+)
+MSG_BAN_USAGE = "⚠️ **Usage:** /ban [user_id] [reason]"
+MSG_CANNOT_BAN_SELF = "❌ **You cannot ban yourself.**"
+MSG_CANNOT_BAN_OWNER = "❌ **Cannot ban an owner.**"
+MSG_ADMIN_USER_BANNED = "✅ User {user_id} has been banned."
+MSG_BAN_REASON_SUFFIX = "\n📝 Reason: {reason}"
+MSG_ADMIN_NO_BAN_REASON = "No reason provided"
+MSG_USER_BANNED_NOTIFICATION = "🚫 **You have been banned from using this bot.**"
+MSG_UNBAN_USAGE = "⚠️ **Usage:** /unban "
+MSG_ADMIN_USER_UNBANNED = "✅ User {user_id} has been unbanned."
+MSG_USER_UNBANNED_NOTIFICATION = "🎉 **You have been unbanned from using this bot.**"
+MSG_USER_NOT_IN_BAN_LIST = "ℹ️ **User {user_id} was not found in the ban list.**"
+MSG_CHANNEL_BANNED = "✅ Channel {channel_id} has been banned."
+MSG_CHANNEL_BANNED_REASON_SUFFIX = "\n📝 Reason: {reason}"
+MSG_CHANNEL_UNBANNED = "✅ **Channel {channel_id} has been unbanned.**"
+MSG_CHANNEL_NOT_BANNED = "ℹ️ **Channel {channel_id} was not found in the ban list.**"
+
+MSG_AUTHORIZE_USAGE = "🔑 **Usage:** `/authorize `"
+MSG_DEAUTHORIZE_USAGE = "🔒 **Usage:** `/deauthorize `"
+MSG_AUTHORIZE_SUCCESS = (
+ "✅ **User Authorized!**\n\n> 👤 User ID: `{user_id}`\n> 🔑 Access: Permanent"
+)
+MSG_DEAUTHORIZE_SUCCESS = (
+ "✅ **User Deauthorized!**\n\n> 👤 User ID: `{user_id}`\n> 🔒 Access: Revoked"
+)
+MSG_TOKEN_ACTIVATED = (
+ "✅ Token successfully activated!\n\n⏳ This token is valid for {duration_hours} hours."
+)
+MSG_TOKEN_INVALID = (
+ "🚫 **Expired or Invalid Token.** Please click the button below to activate your access token."
+)
+MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
MSG_AUTH_USER_INFO = """{i}. 👤: {display_name}
- • User ID: `{user_id}`
- • Authorized by: `{authorized_by}`
- • Date: `{auth_time}`\n\n"""
-MSG_ADMIN_AUTH_LIST_HEADER = "🔐 **Authorized Users List**\n\n"
-
-# ------ Shell Commands ------
-MSG_SHELL_USAGE = (
- "Usage:\n"
- "/shell \n\n"
- "Example:\n"
- "/shell ls -l"
-)
-MSG_SHELL_EXECUTING = "Executing Command... ⚙️\n{command}"
-MSG_SHELL_OUTPUT = """**Shell Command Output:**
-{output}"""
-MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n{output}"
-MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n{error}"
-MSG_SHELL_NO_OUTPUT = "✅ Command Executed: No output."
-
-# ------ Admin View & Control ------
-
-MSG_WORKLOAD_ITEM = " {bot_name}: {load}\n"
-MSG_ADMIN_RESTART_DONE = "✅ **Restart Successful!**"
-MSG_RESTARTING = "♻️ **Updating and Restarting Bot...**\n\n> ⏳ Please wait a moment."
-MSG_LOG_FILE_CAPTION = "📄 **System Logs**"
-
-MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file."
-MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file."
-
-# =====================================================================================
-# ====== BUTTON TEXTS (User-facing) ======
-# =====================================================================================
-
-MSG_BUTTON_STREAM_NOW = "🖥️ Stream"
-MSG_BUTTON_DOWNLOAD = "🚀 Download"
-MSG_BUTTON_GET_HELP = "📖 Get Help"
-MSG_BUTTON_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
-MSG_BUTTON_VIEW_PROFILE = "👤 View User Profile"
-MSG_BUTTON_ABOUT = "ℹ️ About Bot"
-MSG_BUTTON_JOIN_CHANNEL = "📢 Join {channel_title}"
-MSG_BUTTON_GITHUB = "🛠️ GitHub"
-MSG_BUTTON_START_CHAT = "📩 Start Chat"
-MSG_BUTTON_CLOSE = "✖ Close"
-
-
-# =====================================================================================
-# ====== COMMAND RESPONSES (User-facing) ======
-# =====================================================================================
-
-MSG_WELCOME = (
- "🌟 **Welcome, {user_name}!** 🌟\n\n"
- "I'm **Thunder File to Link Bot** ⚡\n"
- "I generate direct download and streaming links for your files.\n\n"
- "**How to use:**\n"
- "1. Send any file to me for private links.\n"
- "2. In groups, reply to a file with `/link`.\n\n"
- "» Use `/help` for all commands and detailed information.\n\n"
- "🚀 Send a file to begin!"
-)
-
-MSG_HELP = (
- "📘 **Thunder Bot - Help Guide** 📖\n\n"
- "How to get direct download & streaming links:\n\n"
- "**🚀 Private Chat (with me):**\n"
- "> 1. Send me **any file** (document, video, audio, photo, etc.).\n"
- "> 2. I'll instantly reply with your links! ⚡\n\n"
- "**👥 Using in Groups:**\n"
- "> • Reply to any file with `/link`.\n"
- "> • **Batch Mode:** Reply to the **first** file with `/link ` (e.g., `/link 5` for 5 files, up to {max_files}).\n"
- "> • Bot needs administrator rights in the group to function.\n"
- "> • Links are posted in the group & sent to you privately.\n\n"
- "**📢 Using in Channels:**\n"
- "> • Add me as an administrator with necessary permissions.\n"
- "> • I can be configured to auto-detect new media files.\n"
- "> • Inline stream/download buttons can be added to files automatically.\n"
- "> • Files from banned channels (owner configuration) are rejected.\n"
- "> • Auto-posting links if the bot has admin privileges with delete rights.\n\n"
- "**⚙️ Available Commands:**\n"
- "> `/start` 👋 - Welcome message & quick start information.\n"
- "> `/help` 📖 - Shows this help message.\n"
- "> `/link ` 🔗 - (Groups) Generate links. \n"
- "> `/about` ℹ️ - Learn more about me and my features.\n"
- "> `/ping` 📡 - Check my responsiveness and online status.\n"
- "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n"
- "**💡 Pro Tips:**\n"
- "> • You can forward files from other chats directly to me.\n"
- "> • If you encounter a rate limit message, please wait the specified time. ⏳\n"
- "> • For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n"
- "> • Processing batch files might take a bit longer. Please be patient. 🐌\n\n"
- "❓ Questions? Please ask in our support group!"
-)
-
-MSG_ABOUT = (
- "🌟 **About Thunder File to Link Bot** ℹ️\n\n"
- "I'm your go-to bot for **instant download & streaming!** ⚡\n\n"
- "**🚀 Key Features:**\n"
- "> **Instant Links:** Get your links within seconds.\n"
- "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n"
- "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n"
- "> **High-Speed Access:** Optimized for fast link generation and file access.\n"
- "> **Secure & Reliable:** Your files are handled with care during processing.\n"
- "> **User-Friendly Interface:** Designed for ease of use on any device.\n"
- "> **Efficient Processing:** Built for speed and reliability.\n"
- "> **Batch Mode:** Process multiple files at once in groups using `/link `.\n"
- "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n"
- "💖 If you find me useful, please consider sharing me with your friends!"
-)
-
-# ------ Ping ------
-MSG_PING_START = "🛰️ **Pinging...** Please wait."
-MSG_PING_RESPONSE = (
- "☁️ **PONG! Bot is Online!** ⚡\n\n"
- "> ⏱️ **Ping:** {time_taken_ms:.2f} ms\n"
- "> 🤖 **Bot Status:** `Active`"
-)
-
-# ------ DC Info ------
-MSG_DC_USER_INFO = (
- "📍 **Information**\n"
- "> 👤 **User:** [{user_name}](tg://user?id={user_id})\n"
- "> 🆔 **User ID:** `{user_id}`\n"
- "> 🌍 **DC ID:** `{dc_id}`"
-)
-
-MSG_DC_FILE_INFO = (
- "🗂️ **File Information**\n"
- ">`{file_name}`\n"
- "💾 **File Size:** `{file_size}`\n"
- "📁 **File Type:** `{file_type}`\n"
- "🌍 **DC ID:** `{dc_id}`"
-)
-
-MSG_DC_UNKNOWN = "Unknown"
-
-# ------ File Link Generation ------
-MSG_DM_SINGLE_PREFIX = "📬 **From {chat_title}**\n"
-MSG_LINKS = (
- "✨ **Your Links are Ready!** ✨\n\n"
- "> `{file_name}`\n\n"
- "📂 **File Size:** `{file_size}`\n\n"
- "🚀 **Download Link:**\n`{download_link}`\n\n"
- "🖥️ **Stream Link:**\n`{stream_link}`\n\n"
- "⌛️ **Note: Links remain active while the bot is running and the file is accessible.**"
-)
-
-# =====================================================================================
-# ====== USER NOTIFICATIONS ======
-# =====================================================================================
-
-MSG_NEW_USER = (
- "✨ **New User Alert!** ✨\n"
- "> 👤 **Name:** [{first_name}](tg://user?id={user_id})\n"
- "> 🆔 **User ID:** `{user_id}`\n\n"
-)
-MSG_COMMUNITY_CHANNEL = "📢 **{channel_title}:** 🔒 Join this channel to use the bot."
-
-# =====================================================================================
-# ====== PROCESSING MESSAGES ======
-# =====================================================================================
-
-# ------ General File Processing ------
-MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**"
-MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
-MSG_NEW_FILE_REQUEST = (
- "> 👤 **Source:** [{source_info}](tg://user?id={id_})\n"
- "> 🆔 **ID:** `{id_}`\n\n"
- "🚀 **Download:** `{online_link}`\n\n"
- "🖥️ **Stream:** `{stream_link}`"
-)
-
-# ------ Batch Processing ------
-MSG_PROCESSING_BATCH = "♻️ **Processing Batch {batch_number}/{total_batches}** ({file_count} files)"
-MSG_PROCESSING_STATUS = "📊 **Processing Files:** {processed}/{total} complete, {failed} failed"
-MSG_BATCH_LINKS_READY = "🔗 Here are your {count} download links:"
-MSG_DM_BATCH_PREFIX = "📬 **Batch Links from {chat_title}**\n"
-MSG_PROCESSING_RESULT = "✅ **Process Complete:** {processed}/{total} files processed successfully, {failed} failed"
-
-# =====================================================================================
-# ====== BROADCAST MESSAGES ======
-# =====================================================================================
-
-MSG_BROADCAST_START = "📣 **Starting Broadcast...**\n\n> ⏳ Please wait for completion."
-MSG_BROADCAST_COMPLETE = (
- "📢 **Broadcast Completed Successfully!** 📢\n\n"
- "⏱️ **Duration:** `{elapsed_time}`\n"
- "👥 **Total Users:** `{total_users}`\n"
- "✅ **Successful Deliveries:** `{successes}`\n"
- "❌ **Failed Deliveries:** `{failures}`\n"
- "🗑️ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n"
-)
-MSG_BROADCAST_CANCEL = "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..."
-MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast."
-MSG_BROADCAST_USAGE = (
- "📣 **Broadcast Command Usage:**\n\n"
- "`/broadcast` - Broadcast to all users\n"
- "`/broadcast authorized` - Broadcast to authorized users only\n"
- "`/broadcast regular` - Broadcast to regular (non-authorized) users only\n\n"
- "**Note:** Reply to the message you want to broadcast."
-)
-
-# =====================================================================================
-# ====== PERMISSION MESSAGES ======
-# =====================================================================================
-
-MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information."
-MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast."
-MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast."
-MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported."
-
-# =====================================================================================
-# ====== RATE LIMITING MESSAGES ======
-# =====================================================================================
-
-MSG_RATE_LIMIT_QUEUE_PRIORITY = (
- "⚡ You're in the **Priority Queue!**\n\n"
- "> ⏳ **Estimated Wait:** `~{wait_estimate} minute{s}`\n"
- "> 🚀 **Status:** In Queue"
-)
-
-MSG_RATE_LIMIT_QUEUE_REGULAR = (
- "⏳ **Rate Limit Reached!**\n\n"
- "> ⌛ **Estimated Wait:** `~{wait_estimate} minute{s1}`\n"
- "> 📊 **Limit:** `{max_requests} files per {time_window} minute{s2}`\n"
- "> 🔄 **Status:** In Queue"
-)
-
-MSG_RATE_LIMIT_QUEUE_FULL = (
- "⚠️ **Service Busy!** The processing queue is currently full.\n\n"
- "> 🕒 **Please try again in:** `~{wait_estimate} minute{s}`\n"
- "> 💡 **Tip:** Try again later when system load decreases"
-)
-
-
-# =====================================================================================
-# ====== FILE TYPE DESCRIPTIONS ======
-# =====================================================================================
-MSG_FILE_TYPE_DOCUMENT = "📄 Document"
-MSG_FILE_TYPE_PHOTO = "🖼️ Photo"
-MSG_FILE_TYPE_VIDEO = "🎬 Video"
-MSG_FILE_TYPE_AUDIO = "🎵 Audio"
-MSG_FILE_TYPE_VOICE = "🎤 Voice Message"
-MSG_FILE_TYPE_STICKER = "🎨 Sticker"
-MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)"
-MSG_FILE_TYPE_VIDEO_NOTE = "📹 Video Note"
-MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type"
-
-# =====================================================================================
-# ====== SYSTEM & STATUS MESSAGES ======
-# =====================================================================================
-
-MSG_SYSTEM_STATUS = (
- "✅ **System Status:** Operational\n\n"
- "> 🕒 **Uptime:** `{uptime}`\n"
- "> 🤖 **Bot Instances:** `{active_bots}`\n"
- "> 📊 **Total Workload:** `{total_workload}`\n\n"
- "📜 **Workload Distribution:**\n\n"
- "{workload_items}\n"
- "> ♻️ **Version:** `{version}`"
-)
-
-# ------ Speedtest Messages ------
-MSG_SPEEDTEST_INIT = "🚀 **Running Speed Test...**"
-MSG_SPEEDTEST_ERROR = "❌ **Speed Test Failed!**\n\n> Unable to complete the speed test. Please try again later."
-MSG_SPEEDTEST_RESULT = (
- "⚡ **Speed Test Results**\n\n"
- "**SPEEDTEST INFO:**\n"
- "> **Download:** `{download_mbps} Mbps` (`{download_bps}/s`)\n"
- "> **Upload:** `{upload_mbps} Mbps` (`{upload_bps}/s`)\n"
- "> **Ping:** `{ping} ms`\n"
- "> **Timestamp:** `{timestamp}`\n"
- "> **Data Sent:** `{bytes_sent}`\n"
- "> **Data Received:** `{bytes_received}`\n\n"
- "**SERVER INFO:**\n"
- "> **Name:** `{server_name}`\n"
- "> **Country:** `{server_country}`\n"
- "> **Sponsor:** `{server_sponsor}`\n"
- "> **Latency:** `{server_latency} ms`\n"
- "> **Coordinates:** `{server_lat}, {server_lon}`\n\n"
- "**CLIENT DETAILS:**\n"
- "> **IP:** `{client_ip}`\n"
- "> **Coordinates:** `{client_lat}, {client_lon}`\n"
- "> **ISP:** `{client_isp}`\n"
- "> **ISP Rating:** `{client_isprating}`\n"
- "> **Country:** `{client_country}`"
-)
-MSG_SYSTEM_STATS = (
- "📊 **System Statistics**\n\n"
- "> System Uptime: {sys_uptime}\n"
- "> Bot Uptime: {bot_uptime}\n\n"
- "⚙️ **Performance:**\n"
- "> CPU: {cpu_percent}%\n"
- "> CPU Core: {cpu_cores}\n"
- "> Frequency: {cpu_freq} GHz\n\n"
- "💾 **RAM**\n"
- "> Total: {ram_total}\n"
- "> Used: {ram_used}\n"
- "> Free: {ram_free}\n\n"
- "💽 **Storage:**\n"
- "> Disk: `{disk_percent}%`\n"
- "> Total: `{total}`\n"
- "> Used: `{used}`\n"
- "> Free: `{free}`\n\n"
- "📶 **Network:**\n"
- "> 🔺 Upload: `{upload}`\n"
- "> 🔻 Download: `{download}`\n"
-)
-
-MSG_DB_STATS = "📊 **Database Statistics**\n\n> 👥 **Total Users:** `{total_users}`"
+ • User ID: {user_id}
+ • Authorized by: {authorized_by}
+ • Date: {auth_time}\n\n"""
+MSG_ADMIN_AUTH_LIST_HEADER = "🔐 Authorized Users List\n\n"
+MSG_LISTAUTH_PARTIAL = (
+ "⚠️ Partial list\n\n"
+ "> {failed_pages} of {total_pages} page(s) could not be sent.\n"
+ "> The list above is incomplete — re-run /listauth."
+)
+MSG_ADMIN_AUTH_OWNER_FOOTER = (
+ "\n👑 Owner: {owner_id} (implicit, all access)"
+)
+
+MSG_SHELL_USAGE = "Usage:\n/shell \n\nExample:\n/shell ls -l"
+MSG_SHELL_DISABLED = (
+ "⛔ Shell is disabled.\n\n"
+ "Set ENABLE_SHELL=True in the environment to enable this "
+ "owner-only command."
+)
+MSG_SHELL_EXECUTING = "Executing Command... ⚙️\n{command}"
+MSG_SHELL_OUTPUT_CAPTION = "**Shell Command Output** (see attached file):\n{command}"
+MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n{output}"
+MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n{error}"
+MSG_SHELL_NO_OUTPUT = "✅ Command Executed: No output."
+
+MSG_WORKLOAD_ITEM = " {bot_name}: {load}\n"
+MSG_ADMIN_RESTART_DONE = "✅ **Restart Successful!**"
+MSG_RESTARTING = "♻️ **Updating and Restarting Bot...**\n\n> ⏳ Please wait a moment."
+MSG_LOG_FILE_CAPTION_SIZED = "📄 **System Logs** (last {tailed} of {total})"
+
+MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file."
+MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file."
+
+# ====== BUTTON TEXTS (User-facing) ======
+
+MSG_BUTTON_STREAM_NOW = "🖥️ Stream"
+MSG_BUTTON_DOWNLOAD = "🚀 Download"
+MSG_BUTTON_GET_HELP = "📖 Get Help"
+MSG_BUTTON_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
+MSG_BUTTON_VIEW_PROFILE = "👤 View User Profile"
+MSG_BUTTON_ABOUT = "ℹ️ About Bot"
+MSG_BUTTON_JOIN_CHANNEL = "📢 Join {channel_title}"
+MSG_BUTTON_GITHUB = "🛠️ GitHub"
+MSG_BUTTON_START_CHAT = "📩 Start Chat"
+MSG_BUTTON_CLOSE = "✖ Close"
+
+# ====== COMMAND RESPONSES (User-facing) ======
+
+# welcome/help/about are HTML and interpolate html.escape()d values.
+MSG_WELCOME = (
+ "🌟 Welcome, {user_name}! 🌟\n\n"
+ "I'm Thunder File to Link Bot ⚡\n"
+ "I generate direct download and streaming links for your files.\n\n"
+ "How to use:\n"
+ "1. Send any file to me for private links.\n"
+ "2. In groups, reply to a file with /link (up to {max_files} at once: /link 5).\n"
+ "3. Stream links support seeking in any browser.\n\n"
+ "» Use /help for all commands and detailed information.\n\n"
+ "🚀 Send a file to begin!"
+)
+
+MSG_HELP_INTRO = (
+ "📘 Thunder Bot - Help Guide 📖\n\n"
+ "How to get direct download & streaming links:\n\n"
+ "🚀 Private Chat (with me):\n"
+ "> 1. Send me any file (document, video, audio, photo, etc.).\n"
+ "> 2. I'll instantly reply with your links! ⚡\n\n"
+ "👥 Using in Groups:\n"
+ "> • Reply to any file with /link.\n"
+ "> • Batch Mode: Reply to the first file with /link <number> "
+ "(e.g., /link 5 for 5 files, up to {max_files}).\n"
+ "> • Bot needs administrator rights in the group to function.\n"
+ "> • Links are posted in the group & sent to you privately.\n\n"
+ "📢 Using in Channels:\n"
+ "> • Add me as an administrator with necessary permissions.\n"
+ "> • I can be configured to auto-detect new media files.\n"
+ "> • Inline stream/download buttons can be added to files automatically.\n"
+ "> • Files from banned channels (owner configuration) are rejected.\n"
+ "> • Auto-posting links if the bot has admin privileges with delete rights.\n"
+)
+
+# the commands section is generated from bot/registry.py.
+MSG_HELP_COMMANDS_HEADER = "\n⚙️ Available Commands:\n"
+MSG_HELP_COMMAND_ROW = "> /{name} - {description}\n"
+
+MSG_HELP_TIPS = (
+ "\n💡 Pro Tips:\n"
+ "> • You can forward files from other chats directly to me.\n"
+ "> • If you encounter a rate limit message, please wait the specified time. ⏳\n"
+ "> • For /link in groups to work reliably (and for private link delivery), "
+ "ensure you've started a private chat with me first.\n"
+ "> • Processing batch files might take a bit longer. Please be patient. 🐌\n\n"
+ "❓ Questions? Please ask in our support group!"
+)
+
+MSG_ABOUT = (
+ "🌟 About Thunder File to Link Bot ℹ️\n\n"
+ "I'm your go-to bot for instant download & streaming! ⚡\n\n"
+ "🚀 Key Features:\n"
+ "> Instant Links: Get your links within seconds.\n"
+ "> Online Streaming: Watch videos or listen to audio directly (for supported formats).\n"
+ "> Universal File Support: Handles documents, videos, audio, photos, and more.\n"
+ "> High-Speed Access: Optimized for fast link generation and file access.\n"
+ "> Secure & Reliable: Your files are handled with care during processing.\n"
+ "> User-Friendly Interface: Designed for ease of use on any device.\n"
+ "> Efficient Processing: Built for speed and reliability.\n"
+ "> Batch Mode: Process multiple files at once in groups using /link <number>.\n"
+ "> Versatile Usage: Works in private chats, groups, and channels (with admin setup).\n\n"
+ "💖 If you find me useful, please consider sharing me with your friends!"
+)
+
+MSG_PING_START = "🛰️ **Pinging...** Please wait."
+MSG_PING_RESPONSE = (
+ "☁️ **PONG! Bot is Online!** ⚡\n\n"
+ "> ⏱️ **Ping:** {time_taken_ms:.2f} ms\n"
+ "> 🤖 **Bot Status:** `Active`"
+)
+
+MSG_DC_USER_INFO = (
+ "📍 Information\n"
+ '👤 User: {user_name}\n'
+ "🆔 User ID: {user_id}\n"
+ "🌍 DC ID: {dc_id}"
+)
+
+MSG_DC_FILE_INFO = (
+ "🗂️ File Information\n"
+ "{file_name}\n"
+ "💾 File Size: {file_size}\n"
+ "📁 File Type: {file_type}\n"
+ "🌍 DC ID: {dc_id}"
+)
+
+MSG_DC_UNKNOWN = "Unknown"
+
+# Link messages are HTML: file names are user-controlled, escape at render.
+MSG_DM_SINGLE_PREFIX = "📬 From {chat_title}\n"
+MSG_LINKS = (
+ "✨ Your Links are Ready! ✨\n\n"
+ "> {file_name}\n\n"
+ "📂 File Size: {file_size}\n\n"
+ "🚀 Download Link:\n{download_link}\n\n"
+ "🖥️ Stream Link:\n{stream_link}\n\n"
+ "⌛️ Note: Links remain active while the bot is running and the file is accessible."
+)
+
+# appended to link messages only when FILE_TTL_DAYS > 0
+MSG_FILE_EXPIRY_NOTE = "⏳ Files expire after {days} of inactivity."
+MSG_FILE_TTL_DAYS_LABEL = "{days} day(s)"
+
+# ====== USER NOTIFICATIONS ======
+
+MSG_NEW_USER = (
+ "✨ New User Alert! ✨\n"
+ '> 👤 Name: {first_name}\n'
+ "> 🆔 User ID: {user_id}\n\n"
+)
+MSG_COMMUNITY_CHANNEL = "📢 {channel_title}: 🔒 Join this channel to use the bot."
+
+# ====== PROCESSING MESSAGES ======
+
+MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**"
+MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
+MSG_NEW_FILE_REQUEST = (
+ '> 👤 Source: {source_info}\n'
+ "> 🆔 ID: {id_}\n\n"
+ "🚀 Download: {online_link}\n\n"
+ "🖥️ Stream: {stream_link}"
+)
+
+# skipped counts non-media files
+MSG_PROCESSING_BATCH = "♻️ **Processing {file_count} files**"
+MSG_PROCESSING_STATUS = "📊 **Processing Files:** {processed}/{total} complete, {failed} failed"
+MSG_BATCH_LINKS_READY = "🔗 Here are your {count} download links:"
+MSG_DM_BATCH_PREFIX = "📬 Batch Links from {chat_title}\n"
+MSG_PROCESSING_RESULT = (
+ "✅ **Process Complete:** {processed}/{total} files processed successfully, "
+ "{skipped} skipped, {failed} failed"
+)
+
+# ====== BROADCAST MESSAGES ======
+
+MSG_BROADCAST_START = "📣 **Starting Broadcast...**\n\n> ⏳ Please wait for completion."
+MSG_BROADCAST_COMPLETE = (
+ "📢 **Broadcast Completed Successfully!** 📢\n\n"
+ "⏱️ **Duration:** `{elapsed_time}`\n"
+ "📊 **Mode:** `{mode}`\n"
+ "👥 **Total Users:** `{total_users}`\n"
+ "✅ **Successful Deliveries:** `{successes}`\n"
+ "❌ **Failed Deliveries:** `{failures}`\n"
+ "🗑️ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n"
+)
+MSG_BROADCAST_CANCEL = (
+ "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..."
+)
+MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast."
+MSG_BROADCAST_USAGE = (
+ "📣 **Broadcast Command Usage:**\n\n"
+ "`/broadcast` - Broadcast to all users\n"
+ "`/broadcast authorized` - Broadcast to authorized users only\n"
+ "`/broadcast regular` - Broadcast to regular (non-authorized) users only\n\n"
+ "**Note:** Reply to the message you want to broadcast."
+)
+MSG_BROADCAST_FAILED_USERS = "❌ **Broadcast Failed:** Unable to fetch users for mode '{mode}'."
+MSG_BROADCAST_NO_USERS = "ℹ️ **No users found for broadcast mode:** `{mode}`"
+MSG_BROADCAST_CANCELLED_PREFIX = "🛑 **Broadcast Cancelled**\n\n"
+MSG_BROADCAST_INTERRUPTED_PREFIX = (
+ "⚠️ **Broadcast Interrupted** (worker failure; counts cover processed users only)\n\n"
+)
+MSG_BROADCAST_PROGRESS = "📣 **Broadcasting...** ✅ {success} / {total} delivered"
+
+# ====== PERMISSION MESSAGES ======
+
+MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information."
+MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported."
+MSG_ERROR_CLOSE_NOT_ALLOWED = (
+ "⚠️ Only the person who triggered this panel (or the owner) can close it."
+)
+
+# ====== RATE LIMITING MESSAGES ======
+
+MSG_RATE_LIMIT_QUEUE_PRIORITY = (
+ "⚡ You're in the **Priority Queue!**\n\n"
+ "> ⏳ **Estimated Wait:** `~{wait_estimate} minute{s}`\n"
+ "> 🚀 **Status:** In Queue"
+)
+
+MSG_RATE_LIMIT_QUEUE_REGULAR = (
+ "⏳ **Rate Limit Reached!**\n\n"
+ "> ⌛ **Estimated Wait:** `~{wait_estimate} minute{s1}`\n"
+ "> 📊 **Limit:** `{max_requests} files per {time_window} minute{s2}`\n"
+ "> 🔄 **Status:** In Queue"
+)
+
+MSG_RATE_LIMIT_QUEUE_FULL = (
+ "⚠️ **Service Busy!** The processing queue is currently full.\n\n"
+ "> 🕒 **Please try again in:** `~{wait_estimate} minute{s}`\n"
+ "> 💡 **Tip:** Try again later when system load decreases"
+)
+
+MSG_RATE_LIMIT_DROPPED = (
+ "⚠️ Service is busy and your request could not be completed. Please try again in a few minutes."
+)
+
+# decorator gate failures (user-facing, shared by all entry points)
+MSG_ERROR_TOKEN_LINK_FAILED = (
+ "Sorry, could not generate an access token link. Please try again later."
+)
+MSG_ERROR_UNEXPECTED = "Sorry, an unexpected error occurred. Please try again later."
+
+# ====== FILE TYPE DESCRIPTIONS ======
+MSG_FILE_TYPE_DOCUMENT = "📄 Document"
+MSG_FILE_TYPE_PHOTO = "🖼️ Photo"
+MSG_FILE_TYPE_VIDEO = "🎬 Video"
+MSG_FILE_TYPE_AUDIO = "🎵 Audio"
+MSG_FILE_TYPE_VOICE = "🎤 Voice Message"
+MSG_FILE_TYPE_STICKER = "🎨 Sticker"
+MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)"
+MSG_FILE_TYPE_VIDEO_NOTE = "📹 Video Note"
+MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type"
+
+# ====== SYSTEM & STATUS MESSAGES ======
+
+MSG_SYSTEM_STATUS = (
+ "✅ **System Status:** Operational\n\n"
+ "> 🕒 **Uptime:** `{uptime}`\n"
+ "> 🤖 **Bot:** `@{bot_username}`\n"
+ "> 🤖 **Bot Instances:** `{active_bots}`\n"
+ "> 📊 **Total Workload:** `{total_workload}`\n\n"
+ "📜 **Workload Distribution:**\n\n"
+ "{workload_items}\n"
+ "> ♻️ **Version:** `{version}`"
+)
+
+MSG_SYSTEM_STATS = (
+ "📊 **System Statistics**\n\n"
+ "> System Uptime: {sys_uptime}\n"
+ "> Bot Uptime: {bot_uptime}\n\n"
+ "⚙️ **Performance:**\n"
+ "> CPU: {cpu_percent}%\n"
+ "> CPU Core: {cpu_cores}\n"
+ "> Frequency: {cpu_freq} GHz\n\n"
+ "💾 **RAM**\n"
+ "> Total: {ram_total}\n"
+ "> Used: {ram_used}\n"
+ "> Free: {ram_free}\n\n"
+ "💽 **Storage:**\n"
+ "> Disk: `{disk_percent}%`\n"
+ "> Total: `{total}`\n"
+ "> Used: `{used}`\n"
+ "> Free: `{free}`\n\n"
+ "📶 **Network:**\n"
+ "> 🔺 Upload: `{upload}`\n"
+ "> 🔻 Download: `{download}`\n\n"
+ "🚦 **Limiter:** {limiter}"
+)
+
+MSG_DB_STATS = "📊 **Database Statistics**\n\n> 👥 **Total Users:** `{total_users}`"
diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py
index e7ca1176..81039d67 100644
--- a/Thunder/utils/rate_limiter.py
+++ b/Thunder/utils/rate_limiter.py
@@ -1,437 +1,811 @@
-# Thunder/utils/rate_limiter.py
-
-import time
-import math
-import asyncio
-from collections import deque
-from typing import Callable, Dict, Optional, Tuple
-from pyrogram import Client
-from pyrogram.types import Message
-from pyrogram.errors import FloodWait, RPCError
-from Thunder.utils.logger import logger
-from Thunder.utils.database import db
-from Thunder.utils.messages import (
- MSG_RATE_LIMIT_QUEUE_PRIORITY,
- MSG_RATE_LIMIT_QUEUE_REGULAR,
- MSG_RATE_LIMIT_QUEUE_FULL
-)
-from Thunder.vars import Var
-
-
-class QueueFullError(Exception):
- pass
-
-
-class RateLimiter:
- def __init__(self):
- self.request_queue: deque = deque()
- self.priority_queue: deque = deque()
- self.user_queue_counts: Dict[int, int] = {}
-
- self.request_event: asyncio.Event = asyncio.Event()
- self.request_lock: asyncio.Lock = asyncio.Lock()
-
- self.user_requests: Dict[int, deque] = {}
- self.global_requests: deque = deque()
-
- self.processing_times: deque = deque(maxlen=100)
- self.file_processing_times: Dict[str, deque] = {}
- self.average_processing_time: float = 1.0
-
- self.auth_cache: Dict[int, Tuple[bool, float]] = {}
- self.auth_cache_ttl_seconds: int = 300
-
- self._initialization_error = False
- self._load_configuration()
-
- def _load_configuration(self):
- try:
- self.max_requests_per_period = Var.MAX_FILES_PER_PERIOD
- self.rate_limit_period_seconds = Var.RATE_LIMIT_PERIOD_MINUTES * 60
- self.max_queue_size = Var.MAX_QUEUE_SIZE
- self.enabled = Var.RATE_LIMIT_ENABLED
- self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT
- self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE
-
- if not self._validate_configuration():
- logger.warning("Rate limiter disabled due to invalid configuration.")
- self.enabled = False
- else:
- logger.debug(f"Rate limiter initialized: enabled={self.enabled}, "
- f"max_requests={self.max_requests_per_period}, "
- f"period={self.rate_limit_period_seconds}s, "
- f"queue_size={self.max_queue_size}, "
- f"global_enabled={self.global_rate_limit_enabled}, "
- f"max_global_requests={self.max_global_requests_per_minute}")
- except Exception as e:
- logger.critical(f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True)
- self.max_requests_per_period = 5
- self.rate_limit_period_seconds = 60
- self.max_queue_size = 100
- self.enabled = False
- self.global_rate_limit_enabled = False
- self.max_global_requests_per_minute = 60
- self._initialization_error = True
-
- def _validate_configuration(self) -> bool:
- is_valid = True
- if self.max_requests_per_period <= 0:
- logger.error("Invalid MAX_FILES_PER_PERIOD: must be > 0.")
- is_valid = False
- if self.rate_limit_period_seconds <= 0:
- logger.error("Invalid RATE_LIMIT_PERIOD_MINUTES: must be > 0.")
- is_valid = False
- if self.max_queue_size <= 0:
- logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.")
- is_valid = False
- if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0:
- logger.error("Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled.")
- is_valid = False
- return is_valid
-
- def is_owner(self, user_id: int) -> bool:
- return user_id == Var.OWNER_ID
-
- async def is_authorized_user(self, user_id: int) -> bool:
- current_time = time.time()
- if user_id in self.auth_cache:
- is_auth, timestamp = self.auth_cache[user_id]
- if current_time - timestamp < self.auth_cache_ttl_seconds:
- return is_auth
-
- try:
- authorized_user = await db.authorized_users_col.find_one({"user_id": user_id})
- is_auth = bool(authorized_user)
- self.auth_cache[user_id] = (is_auth, current_time)
- return is_auth
- except Exception as e:
- logger.error(f"Database error checking authorized user {user_id}: {e}")
- return False
-
- async def get_user_priority(self, user_id: int) -> str:
- if self.is_owner(user_id):
- return 'owner'
- if await self.is_authorized_user(user_id):
- return 'authorized'
- return 'regular'
-
- async def check_limits(self, user_id: int, record: bool = True) -> bool:
- if not self.enabled or self._initialization_error or self.is_owner(user_id):
- return True
-
- current_time = time.time()
-
- if self.global_rate_limit_enabled:
- while self.global_requests and self.global_requests[0] <= current_time - 60:
- self.global_requests.popleft()
- if len(self.global_requests) >= self.max_global_requests_per_minute:
- return False
-
- user_timestamps = self.user_requests.setdefault(user_id, deque())
- while user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds:
- user_timestamps.popleft()
- if len(user_timestamps) >= self.max_requests_per_period:
- return False
-
- if record:
- if self.global_rate_limit_enabled:
- self.global_requests.append(current_time)
- user_timestamps.append(current_time)
- return True
-
- async def _requeue_request(self, request_data: dict, queue_type: str):
- async with self.request_lock:
- if queue_type == "priority":
- self.priority_queue.appendleft(request_data)
- else:
- self.request_queue.appendleft(request_data)
- self.request_event.set()
- logger.debug(f"Re-queued request for user {request_data['user_id']} to {queue_type} queue.")
-
- async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Optional[str] = None, *args, **kwargs):
- if not self.enabled:
- await func(*args, **kwargs)
- return
-
- request_data = {
- 'func': func, 'user_id': user_id, 'args': args, 'kwargs': kwargs,
- 'timestamp': time.time(), 'user_priority': await self.get_user_priority(user_id),
- 'file_identifier': file_identifier
- }
-
- async with self.request_lock:
- total_queued = len(self.request_queue) + len(self.priority_queue)
- if total_queued >= self.max_queue_size:
- raise QueueFullError("Queue is full")
-
- if request_data['user_priority'] == 'authorized':
- self.priority_queue.append(request_data)
- queue_name = "priority"
- else:
- self.request_queue.append(request_data)
- queue_name = "regular"
-
- self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1
- logger.debug(f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}")
- self.request_event.set()
-
- async def request_executor(self):
- logger.debug("Request executor started.")
- while True:
- try:
- await self.request_event.wait()
-
- async with self.request_lock:
- queue, queue_type = (self.priority_queue, "priority") if self.priority_queue else (self.request_queue, "regular")
- if not queue:
- self.request_event.clear()
- continue
- request_data = queue.popleft()
-
- user_id = request_data['user_id']
- processed = False
- if not self.is_owner(user_id):
- if not await self.check_limits(user_id, record=True):
- await self._requeue_request(request_data, queue_type)
- await asyncio.sleep(0.5)
- continue
-
- logger.debug(f"Processing request for user {user_id} from {queue_type} queue.")
- start_time = time.time()
- try:
- await request_data['func'](*request_data['args'], **request_data['kwargs'])
- processing_time = time.time() - start_time
- self.processing_times.append(processing_time)
- if self.processing_times:
- self.average_processing_time = sum(self.processing_times) / len(self.processing_times)
-
- file_identifier = request_data.get('file_identifier')
- if file_identifier:
- file_times = self.file_processing_times.setdefault(file_identifier, deque(maxlen=100))
- file_times.append(processing_time)
-
- processed = True
-
- except FloodWait as e:
- logger.warning(f"FloodWait for user {user_id}, waiting {e.value}s before re-queuing.")
- await asyncio.sleep(e.value)
- await self._requeue_request(request_data, queue_type)
- except Exception as e:
- logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True)
- processed = True
- finally:
- async with self.request_lock:
- if processed and user_id in self.user_queue_counts:
- self.user_queue_counts[user_id] -= 1
- if self.user_queue_counts[user_id] <= 0:
- self.user_queue_counts.pop(user_id, None)
-
- except asyncio.CancelledError:
- logger.debug("Request executor cancelled, shutting down.")
- break
- except Exception as e:
- logger.critical(f"Critical error in request executor: {e}", exc_info=True)
- await asyncio.sleep(5)
-
- async def shutdown(self):
- logger.debug("Shutting down rate limiter and clearing queues...")
- async with self.request_lock:
- self.request_queue.clear()
- self.priority_queue.clear()
- self.user_queue_counts.clear()
- self.request_event.clear()
- logger.debug("Rate limiter queues cleared.")
-
- def get_queue_status(self) -> dict:
- return {
- 'regular_queue_size': len(self.request_queue),
- 'priority_queue_size': len(self.priority_queue),
- 'total_queued': len(self.request_queue) + len(self.priority_queue),
- 'max_queue_size': self.max_queue_size,
- 'active_users_in_queue': len(self.user_queue_counts),
- 'enabled': self.enabled,
- }
-
- async def get_user_queue_position(self, user_id: int) -> dict:
- user_priority = await self.get_user_priority(user_id)
- position = -1
- queue_to_search = self.priority_queue if user_priority == 'authorized' else self.request_queue
-
- for idx, req in enumerate(queue_to_search):
- if req.get('user_id') == user_id:
- position = idx + 1
- break
-
- effective_position = position
- if user_priority == 'regular' and position > -1:
- effective_position += len(self.priority_queue)
-
- return {
- 'user_priority': user_priority,
- 'position_in_own_queue': position if position > -1 else None,
- 'effective_position': effective_position if effective_position > -1 else None,
- 'priority_queue_size': len(self.priority_queue),
- 'regular_queue_size': len(self.request_queue),
- 'bypasses_rate_limit': user_priority == 'owner'
- }
-
- def _get_base_processing_time(self, file_identifier: Optional[str]) -> float:
- if file_identifier and file_identifier in self.file_processing_times:
- file_times = self.file_processing_times[file_identifier]
- if file_times:
- return sum(file_times) / len(file_times)
- return self.average_processing_time
-
- async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float:
- pos_info = await self.get_user_queue_position(user_id)
- items_ahead = (pos_info['effective_position'] - 1) if pos_info['effective_position'] else 0
- return items_ahead * effective_processing_time
-
- def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float:
- user_timestamps = self.user_requests.get(user_id, deque())
- future_user_timestamps = deque(ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds)
-
- if len(future_user_timestamps) >= self.max_requests_per_period:
- reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds
- return max(0.0, reset_time - future_time)
- return 0.0
-
- def _calculate_global_rate_limit_wait(self, future_time: float) -> float:
- if not self.global_rate_limit_enabled:
- return 0.0
-
- future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60)
-
- if len(future_global_requests) >= self.max_global_requests_per_minute:
- oldest_request_time = future_global_requests[0]
- reset_time = oldest_request_time + 60
- return max(0.0, reset_time - future_time)
- return 0.0
-
- async def estimate_wait_time(self, user_id: int, file_identifier: Optional[str] = None) -> float:
- if self.is_owner(user_id):
- return 0.0
-
- base_processing_time = self._get_base_processing_time(file_identifier)
- min_time_per_request = self.rate_limit_period_seconds / self.max_requests_per_period if self.max_requests_per_period > 0 else 0
- effective_processing_time = max(base_processing_time, min_time_per_request)
-
- if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0:
- min_time_per_global = 60 / self.max_global_requests_per_minute
- effective_processing_time = max(effective_processing_time, min_time_per_global)
-
- queue_wait = await self._calculate_queue_wait(user_id, effective_processing_time)
- future_time = time.time() + queue_wait
-
- rate_limit_wait = self._calculate_user_rate_limit_wait(user_id, future_time)
- global_wait = self._calculate_global_rate_limit_wait(future_time)
-
- return queue_wait + rate_limit_wait + global_wait
-
-
-rate_limiter = RateLimiter()
-
-
-async def request_executor():
- await rate_limiter.request_executor()
-
-
-async def handle_rate_limited_request(bot: Client, message: Message, handler: Callable, *args, **kwargs):
- rl_user_id = kwargs.pop('rl_user_id', None)
- user_id = rl_user_id if rl_user_id is not None else (message.from_user.id if message and message.from_user else None)
- if not isinstance(user_id, int):
- logger.error(f"Invalid user_id provided for rate limiting: {user_id}")
- return
-
- file_identifier = message.document.file_unique_id if message and message.document else None
-
- if rate_limiter.is_owner(user_id):
- logger.debug(f"Owner {user_id} bypassing rate limit.")
- await handler(bot, message, *args, **kwargs)
- return
-
- if await rate_limiter.check_limits(user_id, record=True):
- logger.debug(f"User {user_id} within rate limits, executing immediately.")
- await handler(bot, message, *args, **kwargs)
- return
-
- is_channel = rl_user_id is not None and rl_user_id < 0
-
- if not is_channel:
- try:
- user_priority = await rate_limiter.get_user_priority(user_id)
- notification_msg = await send_queue_notification(
- bot, message, is_priority=(user_priority == 'authorized'), file_identifier=file_identifier
- )
- kwargs['notification_msg'] = notification_msg
- except Exception as e:
- logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True)
-
- try:
- await rate_limiter.add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs)
- logger.debug(f"Request for user {user_id} queued.")
- except QueueFullError:
- logger.warning(f"Queue full, request for user {user_id} rejected.")
- if not is_channel:
- await send_queue_full_message(bot, message, file_identifier)
- except Exception as e:
- logger.error(f"Error adding request to queue for user {user_id}: {e}", exc_info=True)
- if not is_channel:
- await send_queue_full_message(bot, message, file_identifier)
-
-
-async def _send_notification(bot: Client, message: Message, template: str, file_identifier: Optional[str], **format_kwargs):
- try:
- if message.from_user:
- user_id = message.from_user.id
- wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier)
- wait_estimate = max(1, math.ceil(wait_seconds / 60))
-
- text = template.format(wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs)
-
- try:
- return await bot.send_message(
- chat_id=message.chat.id,
- text=text,
- reply_to_message_id=message.id
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await bot.send_message(
- chat_id=message.chat.id,
- text=text,
- reply_to_message_id=message.id
- )
- else:
- logger.debug("Skipping notification for channel message (no from_user)")
- return None
- except (FloodWait, RPCError) as e:
- user_id = message.from_user.id if message.from_user else "channel"
- logger.warning(f"Error sending notification to user {user_id}: {e}")
- except Exception as e:
- logger.error(f"Unexpected error sending notification: {e}", exc_info=True)
- return None
-
-
-async def send_queue_notification(bot: Client, message: Message, is_priority: bool, file_identifier: Optional[str]):
- if is_priority:
- template = MSG_RATE_LIMIT_QUEUE_PRIORITY
- params = {}
- else:
- template = MSG_RATE_LIMIT_QUEUE_REGULAR
- time_window = rate_limiter.rate_limit_period_seconds // 60
- params = {
- "max_requests": rate_limiter.max_requests_per_period,
- "time_window": time_window,
- "s1": "s" if rate_limiter.max_requests_per_period > 1 else "",
- "s2": "s" if time_window > 1 else ""
- }
- user_id = message.from_user.id if message.from_user else "channel"
- logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}")
- return await _send_notification(bot, message, template, file_identifier, **params)
-
-
-async def send_queue_full_message(bot: Client, message: Message, file_identifier: Optional[str]):
- user_id = message.from_user.id if message.from_user else "channel"
- logger.debug(f"Sending queue full message to user {user_id}")
- await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier)
+"""Queue + rate limiting; users keep the queue / wait-estimate UX.
+
+Bounded bookkeeping + periodic sweep. A worker pool executes queued
+requests -- the sliding window is charged at execution time (not enqueue),
+and FloodWait requeues the request instead of sleeping the worker. A global
+RPS token-bucket breaker shapes bursts through the queue: a dry bucket
+routes the request into the queue (workers consume tokens at exec time)
+instead of dropping it.
+"""
+
+import asyncio
+import math
+import time
+from collections import deque
+from collections.abc import Callable
+
+from pyrogram import Client
+from pyrogram.errors import FloodWait, RPCError
+from pyrogram.types import Message
+
+from Thunder.utils.canonical_files import IngestContended
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_RATE_LIMIT_DROPPED,
+ MSG_RATE_LIMIT_QUEUE_FULL,
+ MSG_RATE_LIMIT_QUEUE_PRIORITY,
+ MSG_RATE_LIMIT_QUEUE_REGULAR,
+)
+from Thunder.utils.safe_call import edit_safe, send_safe
+from Thunder.utils.tokens import allowed
+from Thunder.vars import Var
+
+# max FloodWait requeues before a request is dropped and the user notified;
+# breaker/user-limit requeues are bounded by their own wait math instead
+MAX_REQUEST_ATTEMPTS = 5
+# Contention is not an error: the other worker usually finishes. But a crashed
+# worker's claim outlives the TTL with nothing to show for it, so this must be
+# bounded too or the user waits forever on a file that will never appear.
+MAX_CONTENDED_ATTEMPTS = 20
+# Hard ceiling on per-user windows held in memory. The sweep prunes on a timer,
+# which bounds the steady state, not what can be created between sweeps -- so
+# admission enforces it too, not just the sweep.
+MAX_TRACKED_USERS = 4096
+MAX_TRACKED_FILES = 1024
+
+
+class QueueFullError(Exception):
+ pass
+
+
+class TokenBucket:
+ """Non-blocking RPS token bucket (global circuit breaker)."""
+
+ def __init__(self, rate_per_second: float, burst_multiplier: float = 2.0):
+ self.rate = max(rate_per_second, 0.0)
+ self.burst = max(self.rate * burst_multiplier, 1.0)
+ self._tokens = self.burst
+ self._updated = time.monotonic()
+
+ def _refill(self) -> None:
+ now = time.monotonic()
+ self._tokens = min(self.burst, self._tokens + (now - self._updated) * self.rate)
+ self._updated = now
+
+ def allow(self) -> bool:
+ if self.rate <= 0:
+ return True
+ self._refill()
+ if self._tokens >= 1.0:
+ self._tokens -= 1.0
+ return True
+ return False
+
+ def retry_after(self) -> float:
+ if self.rate <= 0:
+ return 0.0
+ self._refill()
+ if self._tokens >= 1.0:
+ return 0.0
+ return (1.0 - self._tokens) / self.rate
+
+ def available(self) -> float:
+ """Current token count (for /stats occupancy), without consuming."""
+ self._refill()
+ return max(self._tokens, 0.0)
+
+
+class RateLimiter:
+ def __init__(self):
+ self.request_queue: deque[dict] = deque()
+ self.priority_queue: deque[dict] = deque()
+ # fairness toggle: see _process_one. Not persisted, not swept -- one bool.
+ self._prefer_regular = False
+
+ self.request_event: asyncio.Event = asyncio.Event()
+ self.request_lock: asyncio.Lock = asyncio.Lock()
+
+ self.user_requests: dict[int, deque[float]] = {}
+ self.global_requests: deque[float] = deque()
+ self._deferred_timer: asyncio.TimerHandle | None = None
+
+ self.processing_times: deque[float] = deque(maxlen=100)
+ self.file_processing_times: dict[str, deque[float]] = {}
+ self.average_processing_time: float = 1.0
+
+ self._initialization_error = False
+ self._load_configuration()
+ self.breaker = TokenBucket(self._breaker_rate())
+
+ def _breaker_rate(self) -> float:
+ if Var.GLOBAL_RPS_LIMIT and Var.GLOBAL_RPS_LIMIT > 0:
+ return Var.GLOBAL_RPS_LIMIT
+ if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0:
+ return self.max_global_requests_per_minute / 60.0
+ return 0.0
+
+ def _load_configuration(self):
+ try:
+ self.max_requests_per_period = Var.MAX_FILES_PER_PERIOD
+ self.rate_limit_period_seconds = Var.RATE_LIMIT_PERIOD_MINUTES * 60
+ self.max_queue_size = Var.MAX_QUEUE_SIZE
+ self.enabled = Var.RATE_LIMIT_ENABLED
+ self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT
+ self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE
+ if Var.GLOBAL_RPS_LIMIT and not self.global_rate_limit_enabled:
+ # surface partially-wired config: _breaker_rate returns
+ # GLOBAL_RPS_LIMIT ahead of the per-minute window, so the
+ # per-second cap is enforced on its own
+ logger.warning(
+ "GLOBAL_RPS_LIMIT is set but GLOBAL_RATE_LIMIT is disabled; "
+ "the per-second cap is enforced, the per-minute window is not."
+ )
+
+ if not self._validate_configuration():
+ logger.warning("Rate limiter disabled due to invalid configuration.")
+ self.enabled = False
+ else:
+ logger.debug(
+ f"Rate limiter initialized: enabled={self.enabled}, "
+ f"max_requests={self.max_requests_per_period}, "
+ f"period={self.rate_limit_period_seconds}s, "
+ f"queue_size={self.max_queue_size}, "
+ f"global_enabled={self.global_rate_limit_enabled}, "
+ f"max_global_requests={self.max_global_requests_per_minute}"
+ )
+ except Exception as e:
+ logger.critical(
+ f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True
+ )
+ self.max_requests_per_period = 5
+ self.rate_limit_period_seconds = 60
+ self.max_queue_size = 100
+ self.enabled = False
+ self.global_rate_limit_enabled = False
+ self.max_global_requests_per_minute = 60
+ self._initialization_error = True
+
+ def _validate_configuration(self) -> bool:
+ is_valid = True
+ if self.max_requests_per_period <= 0:
+ logger.error("Invalid MAX_FILES_PER_PERIOD: must be > 0.")
+ is_valid = False
+ if self.rate_limit_period_seconds <= 0:
+ logger.error("Invalid RATE_LIMIT_PERIOD_MINUTES: must be > 0.")
+ is_valid = False
+ if self.max_queue_size <= 0:
+ logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.")
+ is_valid = False
+ if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0:
+ logger.error(
+ "Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled."
+ )
+ is_valid = False
+ return is_valid
+
+ def is_owner(self, user_id: int) -> bool:
+ return user_id == Var.OWNER_ID
+
+ async def is_authorized_user(self, user_id: int) -> bool:
+ try:
+ return await allowed(user_id)
+ except Exception as e:
+ # live: allowed() raises on DB failure (raise_on_error=True default),
+ # so an outage lands here and is treated as unauthorized (fail-closed)
+ logger.error(f"Database error checking authorized user {user_id}: {e}")
+ return False
+
+ async def get_user_priority(self, user_id: int) -> str:
+ if self.is_owner(user_id):
+ return "owner"
+ if await self.is_authorized_user(user_id):
+ return "authorized"
+ return "regular"
+
+ async def check_limits(self, user_id: int, record: bool = True) -> bool:
+ if not self.enabled or self._initialization_error or self.is_owner(user_id):
+ return True
+
+ current_time = time.time()
+
+ if self.global_rate_limit_enabled:
+ while self.global_requests and self.global_requests[0] <= current_time - 60:
+ self.global_requests.popleft()
+ if len(self.global_requests) >= self.max_global_requests_per_minute:
+ return False
+
+ user_timestamps = self.user_requests.get(user_id)
+ if user_timestamps is None:
+ # hard bound, not only sweep-bounded (H6a): between the 5-minute
+ # sweeps a flood of distinct user ids would add a deque each
+ self._evict_inactive_users(MAX_TRACKED_USERS - 1)
+ user_timestamps = self.user_requests[user_id] = deque()
+ while (
+ user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds
+ ):
+ user_timestamps.popleft()
+ if len(user_timestamps) >= self.max_requests_per_period:
+ return False
+
+ if record:
+ if self.global_rate_limit_enabled:
+ self.global_requests.append(current_time)
+ user_timestamps.append(current_time)
+ return True
+
+ # ---------------- sweep ----------------
+
+ def _evict_inactive_users(self, below: int) -> int:
+ """Drop the least-recently-active entries until at most `below` remain.
+
+ Recency, NOT dict insertion order. Keys are only inserted when absent,
+ so dict order is first-EVER-SEEN order: evicting by it would drop a
+ currently-active user -- someone sitting on a full 5/5 window -- as soon
+ as enough fresh ids arrived, silently handing them a fresh window. An
+ earlier admission-time bound did exactly that.
+
+ One helper for both admission and the sweep, because they previously
+ disagreed: the sweep sorted by recency (with a comment explaining why
+ dict order was wrong) while admission used dict order anyway.
+
+ Sorted ONCE, then the prefix is dropped. The obvious
+ ``min(...)``-per-victim loop is O(n*k) and was measured at 66 s of
+ blocking CPU for a table at 8x the cap -- and this runs inside an async
+ sweep() the sweeper calls directly, so the whole bot freezes for that
+ long, under exactly the id-flood this bound exists to survive. The
+ sorted-once form is ~0.02 s for the same work.
+ """
+ excess = len(self.user_requests) - below
+ if excess <= 0:
+ return 0
+ by_recency = sorted(
+ self.user_requests.items(),
+ key=lambda kv: kv[1][-1] if kv[1] else 0.0,
+ )
+ for user_id, _ in by_recency[:excess]:
+ self.user_requests.pop(user_id, None)
+ return excess
+
+ async def sweep(self) -> dict[str, int]:
+ """Prune stale bookkeeping; called every 5 min from the sweeper task."""
+ now = time.time()
+ dropped_users = 0
+ for user_id in list(self.user_requests.keys()):
+ stamps = self.user_requests[user_id]
+ while stamps and stamps[0] <= now - self.rate_limit_period_seconds:
+ stamps.popleft()
+ if not stamps:
+ self.user_requests.pop(user_id, None)
+ dropped_users += 1
+ if len(self.user_requests) > MAX_TRACKED_USERS:
+ dropped_users += self._evict_inactive_users(MAX_TRACKED_USERS)
+
+ dropped_global = 0
+ while self.global_requests and self.global_requests[0] <= now - 60:
+ self.global_requests.popleft()
+ dropped_global += 1
+
+ dropped_files = 0
+ # insertion-order (not recency like users): file entries carry no
+ # timestamps, only estimates -- dropping the oldest estimator is fine
+ if len(self.file_processing_times) > MAX_TRACKED_FILES:
+ for key in list(self.file_processing_times.keys()):
+ if len(self.file_processing_times) <= MAX_TRACKED_FILES:
+ break
+ self.file_processing_times.pop(key, None)
+ dropped_files += 1
+
+ return {
+ "user_windows": dropped_users,
+ "global_entries": dropped_global,
+ "file_entries": dropped_files,
+ }
+
+ def occupancy(self) -> dict[str, float | int]:
+ """Limiter occupancy for /stats."""
+ return {
+ "queued": len(self.request_queue) + len(self.priority_queue),
+ "tracked_users": len(self.user_requests),
+ "global_window": len(self.global_requests),
+ "breaker_tokens": round(self.breaker.available(), 2),
+ }
+
+ # ---------------- queueing ----------------
+
+ async def _requeue_request(self, request_data: dict, queue_type: str, delay: float = 0.0):
+ if delay > 0:
+ request_data["not_before"] = time.time() + delay
+ async with self.request_lock:
+ # Requeues always carry a future not_before (FloodWait/breaker/
+ # user-limit waits); _process_one rotates deferred items to the
+ # back, so queue order is start order by not_before.
+ if queue_type == "priority":
+ self.priority_queue.append(request_data)
+ else:
+ self.request_queue.append(request_data)
+ self.request_event.set()
+ # a pure requeue must re-park the pool or workers spin until not_before
+ if delay > 0:
+ self._park_if_all_deferred()
+ logger.debug(
+ f"Re-queued request for user {request_data['user_id']} to {queue_type} queue (delay={delay:.2f}s)."
+ )
+
+ async def add_to_queue(
+ self, func: Callable, user_id: int, file_identifier: str | None = None, *args, **kwargs
+ ):
+ """Queue a request. The sliding window is NOT charged here -- it is
+ charged at execution time (H6b charge-at-exec)."""
+ if not self.enabled:
+ await func(*args, **kwargs)
+ return
+
+ request_data = {
+ "func": func,
+ "user_id": user_id,
+ "args": args,
+ "kwargs": kwargs,
+ "timestamp": time.time(),
+ "user_priority": await self.get_user_priority(user_id),
+ "file_identifier": file_identifier,
+ "attempts": 0,
+ "not_before": 0.0,
+ }
+
+ async with self.request_lock:
+ total_queued = len(self.request_queue) + len(self.priority_queue)
+ if total_queued >= self.max_queue_size:
+ raise QueueFullError("Queue is full")
+
+ if request_data["user_priority"] == "authorized":
+ self.priority_queue.append(request_data)
+ queue_name = "priority"
+ else:
+ self.request_queue.append(request_data)
+ queue_name = "regular"
+
+ logger.debug(
+ f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}"
+ )
+ self.request_event.set()
+
+ # ---------------- executor ----------------
+
+ async def _process_one(self) -> bool:
+ """Pop and process a single request. Returns True when something was
+ handled (so the worker loop does not spin on an empty event)."""
+ async with self.request_lock:
+ have_priority = bool(self.priority_queue)
+ have_regular = bool(self.request_queue)
+ if have_priority and have_regular:
+ # Fairness: alternate while both tiers are populated, so a
+ # continuous owner/authorized stream cannot starve regular
+ # users indefinitely. The cost is bounded on both sides -- if
+ # the item we hand over is deferred it is rotated straight back
+ # and re-parked, costing one rotation (H6a), never a lost item.
+ serve_priority = not self._prefer_regular
+ self._prefer_regular = not self._prefer_regular
+ else:
+ serve_priority = have_priority
+ self._prefer_regular = False
+
+ if serve_priority:
+ queue, queue_type = self.priority_queue, "priority"
+ elif have_regular:
+ queue, queue_type = self.request_queue, "regular"
+ else:
+ self.request_event.clear()
+ return False
+ request_data = queue.popleft()
+
+ now = time.time()
+ if request_data.get("not_before", 0.0) > now:
+ # deferred: rotate to the right so other requests can proceed
+ async with self.request_lock:
+ queue.append(request_data)
+ self._park_if_all_deferred()
+ return True
+
+ user_id = request_data["user_id"]
+
+ # charge-at-exec: the sliding window is charged exactly once, here.
+ # Retries must not re-charge, or one upload can burn a user's entire
+ # window on server-side failures. Decide advisory-first, then charge
+ # only when admission is certain (breaker state cannot change the
+ # window in between: both checks are synchronous).
+ if not self.is_owner(user_id):
+ record = not request_data.get("charged")
+ if not await self.check_limits(user_id, record=False):
+ wait = self._calculate_user_rate_limit_wait(user_id, now)
+ if self.global_rate_limit_enabled:
+ wait = max(wait, self._calculate_global_rate_limit_wait(now))
+ wait = min(max(wait, 1.0), self.rate_limit_period_seconds)
+ await self._requeue_request(request_data, queue_type, delay=wait)
+ return True
+ if self.breaker.rate > 0 and not self.breaker.allow():
+ # breaker active via GLOBAL_RPS_LIMIT or the derived per-minute rate:
+ # same exec-time shaping and requeue discipline for both sources
+ retry = max(self.breaker.retry_after(), 0.5)
+ await self._requeue_request(request_data, queue_type, delay=retry)
+ return True
+ if record:
+ await self.check_limits(user_id, record=True)
+ request_data["charged"] = True
+
+ logger.debug(f"Processing request for user {user_id} from {queue_type} queue.")
+ start_time = time.time()
+ try:
+ await request_data["func"](*request_data["args"], **request_data["kwargs"])
+ processing_time = time.time() - start_time
+ self.processing_times.append(processing_time)
+ if self.processing_times:
+ self.average_processing_time = sum(self.processing_times) / len(
+ self.processing_times
+ )
+
+ file_identifier = request_data.get("file_identifier")
+ if file_identifier:
+ file_times = self.file_processing_times.setdefault(
+ file_identifier, deque(maxlen=100)
+ )
+ file_times.append(processing_time)
+
+ except IngestContended as e:
+ # allowed: pool path -- another worker holds the ingest claim for
+ # this exact file and is producing the record we want. Requeue
+ # instead of sleeping: the alternative is occupying a worker for
+ # the claim TTL, and the pool has only EXECUTOR_WORKERS of those.
+ # A separate counter from FloodWait's, so one cannot mask the other.
+ attempts = request_data.get("contended_attempts", 0) + 1
+ request_data["contended_attempts"] = attempts
+ if attempts > MAX_CONTENDED_ATTEMPTS:
+ logger.warning(
+ f"Dropping request for user {user_id} after {attempts} ingest "
+ f"contention requeues; {e.file_unique_id} never appeared."
+ )
+ await self._notify_drop(request_data)
+ else:
+ logger.debug(
+ f"Ingest claim held elsewhere for {e.file_unique_id}; "
+ f"requeueing for user {user_id} (attempt {attempts})."
+ )
+ await self._requeue_request(request_data, queue_type, delay=max(e.retry_after, 1.0))
+ except FloodWait as e:
+ # allowed: pool path -- tg_call already retried inside the queued
+ # handler, so requeue with an attempt counter instead of stalling a worker.
+ attempts = request_data.get("attempts", 0) + 1
+ request_data["attempts"] = attempts
+ if attempts > MAX_REQUEST_ATTEMPTS:
+ logger.warning(
+ f"Dropping request for user {user_id} after {attempts} "
+ f"FloodWait requeues (last wait {e.value}s)."
+ )
+ await self._notify_drop(request_data)
+ else:
+ logger.warning(f"FloodWait for user {user_id}, requeueing (attempt {attempts}).")
+ await self._requeue_request(request_data, queue_type, delay=min(e.value, 300.0))
+ except asyncio.CancelledError:
+ # cancellation: propagate; the item is already popped, nothing to release.
+ raise
+ except Exception as e:
+ logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True)
+ # never leave the user on a stale "In Queue" notice for a dead item
+ await self._notify_drop(request_data)
+ return True
+
+ async def _notify_drop(self, request_data: dict) -> None:
+ notification_msg = request_data["kwargs"].get("notification_msg")
+ if notification_msg is None:
+ return
+ try:
+ await edit_safe(notification_msg, MSG_RATE_LIMIT_DROPPED)
+ except Exception:
+ logger.debug("Could not notify user about dropped request", exc_info=True)
+
+ def _park_if_all_deferred(self) -> None:
+ """Park the pool when every queued request is deferred.
+
+ Python 3.13 pitfall: ``Event.wait()`` on a set event and an
+ uncontended ``Lock.acquire()`` return WITHOUT yielding, so a pool
+ rotating only deferred items freezes the loop until the earliest
+ ``not_before``. Parking clears the wakeup event and arms a timer for
+ the earliest deferred request; any new enqueue re-sets the event.
+ Caller must hold ``request_lock``.
+ """
+ now = time.time()
+ earliest: float | None = None
+ for q in (self.priority_queue, self.request_queue):
+ for item in q:
+ nb = item.get("not_before", 0.0)
+ if nb <= now:
+ # something is runnable -- (re-)wake the pool and keep going
+ self.request_event.set()
+ return
+ if earliest is None or nb < earliest:
+ earliest = nb
+ if earliest is None:
+ return
+ self.request_event.clear()
+ if self._deferred_timer is not None:
+ self._deferred_timer.cancel()
+ self._deferred_timer = asyncio.get_running_loop().call_later(
+ min(earliest - now, 300.0), self._deferred_wakeup
+ )
+
+ def _deferred_wakeup(self) -> None:
+ """Clear our own handle when the timer fires.
+
+ The handle was left dangling after firing: it kept a completed
+ TimerHandle alive for the process's lifetime, and the next
+ _park_if_all_deferred then cancelled an already-fired timer."""
+ self._deferred_timer = None
+ self.request_event.set()
+
+ async def request_executor(self):
+ """One consumer; start :data:`Var.EXECUTOR_WORKERS` -- see start_executors()."""
+ logger.debug("Request executor worker started.")
+ while True:
+ try:
+ await self.request_event.wait()
+ handled = await self._process_one()
+ if not handled:
+ await asyncio.sleep(0.05)
+ except asyncio.CancelledError:
+ logger.debug("Request executor worker cancelled, shutting down.")
+ break
+ except Exception as e:
+ logger.critical(f"Critical error in request executor: {e}", exc_info=True)
+ await asyncio.sleep(5)
+
+ async def shutdown(self):
+ logger.debug("Shutting down rate limiter and clearing queues...")
+ async with self.request_lock:
+ self.request_queue.clear()
+ self.priority_queue.clear()
+ self.request_event.clear()
+ if self._deferred_timer is not None:
+ self._deferred_timer.cancel()
+ self._deferred_timer = None
+ logger.debug("Rate limiter queues cleared.")
+
+ # ---------------- estimates (protected UX) ----------------
+
+ async def get_user_queue_position(self, user_id: int) -> dict:
+ user_priority = await self.get_user_priority(user_id)
+ position = -1
+ queue_to_search = (
+ self.priority_queue if user_priority == "authorized" else self.request_queue
+ )
+
+ for idx, req in enumerate(queue_to_search):
+ if req.get("user_id") == user_id:
+ position = idx + 1
+ break
+
+ effective_position = position
+ if user_priority == "regular" and position > -1:
+ effective_position += len(self.priority_queue)
+
+ return {
+ "user_priority": user_priority,
+ "position_in_own_queue": position if position > -1 else None,
+ "effective_position": effective_position if effective_position > -1 else None,
+ "priority_queue_size": len(self.priority_queue),
+ "regular_queue_size": len(self.request_queue),
+ "bypasses_rate_limit": user_priority == "owner",
+ }
+
+ def _get_base_processing_time(self, file_identifier: str | None) -> float:
+ if file_identifier and file_identifier in self.file_processing_times:
+ file_times = self.file_processing_times[file_identifier]
+ if file_times:
+ return sum(file_times) / len(file_times)
+ return self.average_processing_time
+
+ async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float:
+ pos_info = await self.get_user_queue_position(user_id)
+ items_ahead = (pos_info["effective_position"] - 1) if pos_info["effective_position"] else 0
+ return items_ahead * effective_processing_time
+
+ def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float:
+ user_timestamps = self.user_requests.get(user_id, deque())
+ future_user_timestamps = deque(
+ ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds
+ )
+
+ if len(future_user_timestamps) >= self.max_requests_per_period:
+ reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds
+ return max(0.0, reset_time - future_time)
+ return 0.0
+
+ def _calculate_global_rate_limit_wait(self, future_time: float) -> float:
+ if not self.global_rate_limit_enabled:
+ return 0.0
+
+ future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60)
+
+ if len(future_global_requests) >= self.max_global_requests_per_minute:
+ oldest_request_time = future_global_requests[0]
+ reset_time = oldest_request_time + 60
+ return max(0.0, reset_time - future_time)
+ return 0.0
+
+ async def estimate_wait_time(self, user_id: int, file_identifier: str | None = None) -> float:
+ if self.is_owner(user_id):
+ return 0.0
+
+ base_processing_time = self._get_base_processing_time(file_identifier)
+ min_time_per_request = (
+ self.rate_limit_period_seconds / self.max_requests_per_period
+ if self.max_requests_per_period > 0
+ else 0
+ )
+ effective_processing_time = max(base_processing_time, min_time_per_request)
+
+ if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0:
+ min_time_per_global = 60 / self.max_global_requests_per_minute
+ effective_processing_time = max(effective_processing_time, min_time_per_global)
+
+ queue_wait = await self._calculate_queue_wait(user_id, effective_processing_time)
+ future_time = time.time() + queue_wait
+
+ rate_limit_wait = self._calculate_user_rate_limit_wait(user_id, future_time)
+ global_wait = self._calculate_global_rate_limit_wait(future_time)
+
+ return queue_wait + rate_limit_wait + global_wait
+
+
+rate_limiter = RateLimiter()
+
+
+def start_executors() -> list[asyncio.Task]:
+ """Start the worker pool -- callers keep the tasks for shutdown."""
+ workers: list[asyncio.Task] = []
+ for i in range(Var.EXECUTOR_WORKERS):
+ workers.append(
+ asyncio.create_task(
+ rate_limiter.request_executor(), name=f"request_executor_worker_{i}"
+ )
+ )
+ return workers
+
+
+async def handle_rate_limited_request(
+ bot: Client, message: Message, handler: Callable, *args, **kwargs
+):
+ rl_user_id = kwargs.pop("rl_user_id", None)
+ user_id = (
+ rl_user_id
+ if rl_user_id is not None
+ else (message.from_user.id if message and message.from_user else None)
+ )
+ if not isinstance(user_id, int):
+ logger.error(f"Invalid user_id provided for rate limiting: {user_id}")
+ return
+
+ file_identifier = message.document.file_unique_id if message and message.document else None
+
+ if rate_limiter.is_owner(user_id):
+ logger.debug(f"Owner {user_id} bypassing rate limit.")
+ await handler(bot, message, *args, **kwargs)
+ return
+
+ # the immediate path consumes a breaker token too -- bursts of
+ # within-window users never touched the bucket; a dry bucket queues, not drops.
+ # Decide first (no consumption), then charge the window only for requests
+ # that actually execute now.
+ immediate = await rate_limiter.check_limits(user_id, record=False)
+ if immediate and rate_limiter.breaker.rate > 0:
+ immediate = rate_limiter.breaker.allow() # consumes a token on success
+ if immediate:
+ await rate_limiter.check_limits(user_id, record=True)
+ logger.debug(f"User {user_id} within rate limits, executing immediately.")
+ await handler(bot, message, *args, **kwargs)
+ return
+
+ is_channel = rl_user_id is not None and rl_user_id < 0
+ notification_msg = None
+
+ if not is_channel:
+ try:
+ user_priority = await rate_limiter.get_user_priority(user_id)
+ notification_msg = await send_queue_notification(
+ bot,
+ message,
+ is_priority=(user_priority == "authorized"),
+ file_identifier=file_identifier,
+ )
+ kwargs["notification_msg"] = notification_msg
+ except Exception as e:
+ logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True)
+
+ try:
+ await rate_limiter.add_to_queue(
+ handler, user_id, file_identifier, bot, message, *args, **kwargs
+ )
+ logger.debug(f"Request for user {user_id} queued.")
+ except QueueFullError:
+ logger.warning(f"Queue full, request for user {user_id} rejected.")
+ if not is_channel:
+ # edit the notice we already sent: a second message left the stale
+ # "In Queue" status on screen permanently. The wait estimate must
+ # come from user_id -- notification_msg was sent BY the bot, so
+ # its from_user is the bot, not the requester.
+ if notification_msg is not None:
+ await replace_queue_notification(bot, notification_msg, file_identifier, user_id)
+ else:
+ await send_queue_full_message(bot, message, file_identifier)
+ except Exception as e:
+ logger.error(f"Error adding request to queue for user {user_id}: {e}", exc_info=True)
+ if not is_channel:
+ await send_queue_full_message(bot, message, file_identifier)
+
+
+async def _send_notification(
+ bot: Client, message: Message, template: str, file_identifier: str | None, **format_kwargs
+):
+ try:
+ if message.from_user:
+ user_id = message.from_user.id
+ wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier)
+ wait_estimate = max(1, math.ceil(wait_seconds / 60))
+
+ text = template.format(
+ wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs
+ )
+
+ return await send_safe(bot, message.chat.id, text=text, reply_to_message_id=message.id)
+ else:
+ logger.debug("Skipping notification for channel message (no from_user)")
+ return None
+ except (FloodWait, RPCError) as e:
+ # allowed: catch-and-log after send_safe/tg_call already retried --
+ # a notification failure must never fail the queued request itself.
+ who: int | str = message.from_user.id if message.from_user else "channel"
+ logger.warning(f"Error sending notification to user {who}: {e}")
+ except Exception as e:
+ logger.error(f"Unexpected error sending notification: {e}", exc_info=True)
+ return None
+
+
+async def send_queue_notification(
+ bot: Client, message: Message, is_priority: bool, file_identifier: str | None
+):
+ if is_priority:
+ template = MSG_RATE_LIMIT_QUEUE_PRIORITY
+ params = {}
+ else:
+ template = MSG_RATE_LIMIT_QUEUE_REGULAR
+ time_window = rate_limiter.rate_limit_period_seconds // 60
+ params = {
+ "max_requests": rate_limiter.max_requests_per_period,
+ "time_window": time_window,
+ "s1": "s" if rate_limiter.max_requests_per_period > 1 else "",
+ "s2": "s" if time_window > 1 else "",
+ }
+ user_id = message.from_user.id if message.from_user else "channel"
+ logger.debug(
+ f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}"
+ )
+ return await _send_notification(bot, message, template, file_identifier, **params)
+
+
+async def send_queue_full_message(bot: Client, message: Message, file_identifier: str | None):
+ user_id = message.from_user.id if message.from_user else "channel"
+ logger.debug(f"Sending queue full message to user {user_id}")
+ await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier)
+
+
+async def replace_queue_notification(
+ bot: Client,
+ notification_msg: Message,
+ file_identifier: str | None,
+ user_id: int,
+) -> None:
+ """Swap the "In Queue" notice for the queue-full one, in place.
+
+ Sending a second message left the first one saying "In Queue" forever, so
+ the user never learned the request had been rejected.
+
+ ``user_id`` is passed explicitly: ``notification_msg`` was sent by the bot,
+ so reading ``from_user`` off it would estimate the wait for the bot's own
+ (empty) window and always say "less than a minute".
+ """
+ wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier)
+ wait_estimate = max(1, math.ceil(wait_seconds / 60))
+ await edit_safe(
+ notification_msg,
+ MSG_RATE_LIMIT_QUEUE_FULL.format(
+ wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else ""
+ ),
+ )
diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py
index 5c6ef1a1..9f917b63 100644
--- a/Thunder/utils/render_template.py
+++ b/Thunder/utils/render_template.py
@@ -1,54 +1,175 @@
-# Thunder/utils/render_template.py
-
-import asyncio
-import urllib.parse
+import contextlib
+import time
+from collections import OrderedDict
+from pathlib import Path
+from aiohttp import web
from jinja2 import Environment, FileSystemLoader, select_autoescape
-from pyrogram.errors import FloodWait
-from Thunder.bot import StreamBot
-from Thunder.server.exceptions import InvalidHash
-from Thunder.utils.file_properties import get_fname, get_uniqid
+from Thunder.utils.bot_utils import quote_media_name
+from Thunder.utils.file_properties import get_fname, get_fsize, get_media, get_uniqid
+from Thunder.utils.human_readable import humanbytes
from Thunder.utils.logger import logger
+from Thunder.utils.media_types import ext_and_mime_for_class
+from Thunder.utils.safe_call import tg_call
from Thunder.vars import Var
+# NOTE: lazy import of Thunder.server.exceptions inside render_page() avoids
+# a circular import (server/__init__ -> stream_routes -> here).
+
+# resolve templates relative to the package, not the process CWD.
+_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "template"
+
template_env = Environment(
- loader=FileSystemLoader('Thunder/template'),
+ loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(enabled_extensions=("html",), default_for_string=True),
enable_async=True,
cache_size=200,
auto_reload=False,
- optimized=True
+ optimized=True,
)
-async def render_media_page(file_name: str, src: str, requested_action: str | None = None) -> str:
+
+def _page_kind(mime_type: str | None, file_name: str) -> str:
+ """Typed player page -- derive the layout from the mime type.
+
+ A specific mime type is authoritative; extension sniffing only applies
+ when the mime type is missing or generic (application/octet-stream).
+ """
+ mime = (mime_type or "").lower()
+ if mime.startswith("audio/"):
+ return "audio"
+ if mime.startswith("image/"):
+ return "image"
+ if mime.startswith("video/"):
+ return "video"
+ if mime and mime != "application/octet-stream":
+ return "other"
+ # fall back to extension sniffing when the mime type is missing/generic
+ ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
+ if ext in {"mp3", "m4a", "ogg", "opus", "wav", "flac", "aac"}:
+ return "audio"
+ if ext in {"jpg", "jpeg", "png", "gif", "webp", "bmp"}:
+ return "image"
+ if ext in {"mp4", "mkv", "webm", "mov", "avi", "m4v"}:
+ return "video"
+ return "other"
+
+
+async def render_media_page(
+ file_name: str,
+ src: str,
+ mime_type: str | None = None,
+ size_bytes: int | None = None,
+) -> str:
# NOTE: src must be a pre-encoded URL. Templates use |safe to avoid double-encoding.
- if requested_action == 'stream':
- template = template_env.get_template('req.html')
- context = {
- 'heading': f"View {file_name}",
- 'file_name': file_name,
- 'src': f"{src}?disposition=inline"
- }
- else:
- template = template_env.get_template('dl.html')
- context = {
- 'file_name': file_name,
- 'src': src
- }
+ template = template_env.get_template("req.html")
+ context = {
+ "heading": f"View {file_name}",
+ "file_name": file_name,
+ "src": f"{src}?disposition=inline",
+ "download_src": f"{src}?disposition=attachment",
+ "kind": _page_kind(mime_type, file_name),
+ "mime_type": mime_type or "application/octet-stream",
+ "size_formatted": humanbytes(size_bytes) if size_bytes else None,
+ }
return await template.render_async(**context)
-async def render_page(message_id: int, secure_hash: str, requested_action: str | None = None) -> str:
+# TTL+LRU cache so repeat legacy /watch views don't re-fetch the vault message.
+_legacy_cache: "OrderedDict[tuple[int, str], tuple[float, str, str | None, int]]" = OrderedDict()
+_LEGACY_CACHE_TTL_SECONDS = 600
+_LEGACY_CACHE_MAX_ITEMS = 1024
+
+# Admission control for the vault lookup in render_page().
+#
+# The capability hash is a prefix of the file unique id stored in the very
+# message we must fetch to check it, so the RPC necessarily happens BEFORE the
+# hash is verified. A request whose hash does not match can never be cached,
+# so an unauthenticated caller holding one shape-valid 6-hex hash can turn
+# every request into a fresh Telegram RPC -- an amplifier, not a lookup.
+#
+# A plain counter, not asyncio.Semaphore: the check and the increment have no
+# await between them, so asyncio cannot interleave, and a module-level
+# Semaphore would bind to the first event loop that touches it and then reject
+# every other loop (pytest-asyncio creates one per test).
+_PREVIEW_RPC_CONCURRENCY = 4
+_preview_rpc_inflight = 0
+
+
+@contextlib.contextmanager
+def _preview_rpc_slot():
+ global _preview_rpc_inflight
+ if _preview_rpc_inflight >= _PREVIEW_RPC_CONCURRENCY:
+ # lazy: importing any Thunder.server submodule runs the package __init__
+ # -> stream_routes -> this module
+ from Thunder.server.exceptions import unavailable
+
+ raise unavailable("Server is busy rendering pages. Please retry shortly.", retry_after="2")
+ _preview_rpc_inflight += 1
+ try:
+ yield
+ finally:
+ _preview_rpc_inflight -= 1
+
+
+def _legacy_cache_get(key) -> tuple[str, str | None, int] | None:
+ cached = _legacy_cache.get(key)
+ if not cached:
+ return None
+ ts, file_name, mime_type, size_bytes = cached
+ if time.monotonic() - ts > _LEGACY_CACHE_TTL_SECONDS:
+ _legacy_cache.pop(key, None)
+ return None
+ _legacy_cache.move_to_end(key)
+ return file_name, mime_type, size_bytes
+
+
+def _legacy_cache_put(
+ key, file_name: str, mime_type: str | None = None, size_bytes: int = 0
+) -> None:
+ _legacy_cache[key] = (time.monotonic(), file_name, mime_type, size_bytes)
+ _legacy_cache.move_to_end(key)
+ while len(_legacy_cache) > _LEGACY_CACHE_MAX_ITEMS:
+ _legacy_cache.popitem(last=False)
+
+
+async def render_page(message_id: int, secure_hash: str) -> str:
+ key = (int(message_id), str(secure_hash))
+ cached = _legacy_cache_get(key)
+ if cached is not None:
+ file_name, mime_type, size_bytes = cached
+ quoted_filename = quote_media_name(file_name)
+ src = f"{Var.URL.rstrip('/')}/{secure_hash}{message_id}/{quoted_filename}"
+ return await render_media_page(
+ file_name, src, mime_type=mime_type, size_bytes=size_bytes or None
+ )
+
try:
- try:
- message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id)
+ from Thunder.bot import StreamBot # layering break: lazy import
+ from Thunder.server.exceptions import InvalidHash, TelegramUnavailable
+
+ # every miss costs an RPC, so shed load here rather than queueing it
+ with _preview_rpc_slot():
+ try:
+ message = await tg_call(
+ StreamBot.get_messages,
+ chat_id=int(Var.BIN_CHANNEL),
+ message_ids=int(message_id),
+ retries=1,
+ timeout=60,
+ )
+ except TimeoutError as e:
+ # vault timeout is transient, never absence: map to 503 (not
+ # FileNotFound self-heal, not ladder 500) like the delivery path
+ raise TelegramUnavailable(f"vault lookup timed out: {e}") from e
if not message:
raise InvalidHash("Message not found")
+ if isinstance(message, list): # defensive: pyrogram returns a list for list inputs
+ if not message:
+ raise InvalidHash("Message not found")
+ message = message[0]
file_unique_id = get_uniqid(message)
file_name = get_fname(message)
@@ -56,12 +177,33 @@ async def render_page(message_id: int, secure_hash: str, requested_action: str |
if not file_unique_id or file_unique_id[:6] != secure_hash:
raise InvalidHash("File unique ID or secure hash mismatch during rendering.")
- quoted_filename = urllib.parse.quote(file_name.replace('/', '_'), safe="")
- src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{message_id}/{quoted_filename}')
- return await render_media_page(file_name, src, requested_action)
+ media = get_media(message)
+ mime_type = getattr(media, "mime_type", None) or None
+ if not mime_type and media is not None:
+ mime_type = ext_and_mime_for_class(type(media).__name__.lower())[1]
+
+ _legacy_cache_put(key, file_name, mime_type, file_size := get_fsize(message))
+
+ quoted_filename = quote_media_name(file_name)
+ src = f"{Var.URL.rstrip('/')}/{secure_hash}{message_id}/{quoted_filename}"
+ return await render_media_page(
+ file_name, src, mime_type=mime_type, size_bytes=file_size or None
+ )
+ except InvalidHash as e:
+ # a 404 is the expected answer for a wrong capability hash, and the
+ # route ladder logs it at debug. Logging it as an ERROR with a
+ # traceback let an unauthenticated caller fill bot.txt with one
+ # traceback per probe -- the same amplifier, aimed at the log file.
+ logger.debug(f"render_page: {type(e).__name__} for message_id {message_id}")
+ raise
+ except web.HTTPException:
+ # load shed, not a fault: the caller already gets the right status and
+ # logging a traceback for every rejected request would be its own outage
+ raise
except Exception as e:
+ # the capability hash is a credential: never log it (bot.txt is uploaded via /log)
logger.error(
- f"Error in render_page for message_id {message_id} and hash {secure_hash}: {e}",
- exc_info=True
+ f"Error in render_page for message_id {message_id} (hash redacted): {e}",
+ exc_info=True,
)
raise
diff --git a/Thunder/utils/safe_call.py b/Thunder/utils/safe_call.py
new file mode 100644
index 00000000..5bda3717
--- /dev/null
+++ b/Thunder/utils/safe_call.py
@@ -0,0 +1,165 @@
+"""Central FloodWait-safe call helpers.
+
+Every Telegram RPC goes through :func:`tg_call` or a thin wrapper. On
+``FloodWait`` the call sleeps ``min(e.value, cap)`` -- where the cap is
+30 s for lightweight RPCs and :data:`MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS`
+(600 s, the pre-branch pyrogram auto-sleep ceiling) for file-transfer
+shapes -- and retries at most ``retries`` times, then the exception
+propagates unchanged. Wall-clock budgets (H8): lightweight RPCs get a
+default timeout so a hung call cannot pin a handler forever;
+file-transfer paths default to *no* timeout (large media legitimately
+takes minutes) -- pass ``timeout=`` explicitly where a budget is known.
+"""
+
+import asyncio
+from collections.abc import Awaitable, Callable
+from typing import Any, TypeVar
+
+from pyrogram.errors import FloodWait
+
+from Thunder.utils.logger import logger
+
+T = TypeVar("T")
+
+# Default wall-clock budget for lightweight RPCs; env-overridable via TG_RPC_TIMEOUT_SECONDS.
+DEFAULT_RPC_TIMEOUT_SECONDS = 30.0
+
+# cap FloodWait sleeps so a lightweight RPC stays near its advertised wall-clock
+# budget (worst case per attempt: budget + cap + a final budgeted attempt).
+MAX_FLOODWAIT_SLEEP_SECONDS = 30.0
+
+# File-transfer shapes may sleep the full FloodWait up to this ceiling (the
+# pyrogram auto-sleep budget): sustained throttling on the ingest path must
+# ride out long waits instead of hard-failing; beyond it, surface to caller.
+MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS = 600.0
+
+# Media shapes allowed to run unbounded (large transfers legitimately take
+# minutes, and H4a's lightweight budget would abort them mid-upload).
+#
+# Both entries are load-bearing and correspond to real call sites:
+# * Message.copy -- the ingest forward (plugins fwd_media) and the
+# broadcast copy
+# * Message.reply_document -- /log (a 5 MiB tail) and /shell output
+#
+# Everything else that moves media -- stream_media, download_media, the
+# send_* family -- is called directly, not via tg_call, so it has no budget
+# to inherit. A new media call site must be added here or pass ``timeout=0``;
+# silently inheriting the lightweight budget is a production upload failure.
+# test_safe_call_budgets pins this set.
+UNBOUNDED_SHAPES = frozenset({"copy", "reply_document"})
+
+
+def _call_name(fn: Callable[..., Awaitable[T]]) -> str:
+ """Name used to pick a budget.
+
+ ``functools.partial`` hides the wrapped callable behind ``func``; without
+ this a partial-wrapped media call would look unnamed and silently inherit
+ the lightweight budget.
+ """
+ wrapped = getattr(fn, "func", None) # functools.partial
+ if wrapped is not None:
+ fn = wrapped
+ return getattr(fn, "__name__", "")
+
+
+_env_timeout_cache: float | None = None
+
+
+def _env_timeout() -> float:
+ # static per process: resolve once instead of re-reading on every RPC
+ global _env_timeout_cache
+ if _env_timeout_cache is None:
+ try:
+ from Thunder.vars import Var # lazy: avoids any import-order coupling
+
+ _env_timeout_cache = float(Var.TG_RPC_TIMEOUT_SECONDS)
+ except Exception:
+ _env_timeout_cache = DEFAULT_RPC_TIMEOUT_SECONDS
+ return _env_timeout_cache
+
+
+def _default_timeout(fn: Callable[..., Awaitable[T]]) -> float | None:
+ if _call_name(fn) in UNBOUNDED_SHAPES:
+ return None
+ return _env_timeout()
+
+
+async def tg_call(
+ fn: Callable[..., Awaitable[T]],
+ *args: Any,
+ retries: int = 1,
+ timeout: float | None = None,
+ max_flood_sleep: float | None = None,
+ **kwargs: Any,
+) -> T:
+ """Call ``fn(*args, **kwargs)`` sleeping through ``FloodWait``.
+
+ ``timeout`` forces a wall-clock budget (``None`` = auto: unbounded for the
+ :data:`UNBOUNDED_SHAPES` media call, :data:`DEFAULT_RPC_TIMEOUT_SECONDS`
+ otherwise; ``0`` or negative disables the budget entirely, which is how a
+ new media call site opts out explicitly). ``max_flood_sleep`` overrides the
+ shape-based sleep cap (e.g. fan-out callers that must fail-count fast
+ instead of riding out a long throttle).
+ """
+ attempt = 0
+ while True:
+ try:
+ budget = timeout if timeout is not None else _default_timeout(fn)
+ coro = fn(*args, **kwargs)
+ if budget and budget > 0:
+ return await asyncio.wait_for(coro, timeout=budget)
+ return await coro
+ except FloodWait as e:
+ attempt += 1
+ if attempt > retries:
+ raise
+ if max_flood_sleep is not None:
+ cap = max_flood_sleep
+ else:
+ cap = (
+ MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS
+ if _call_name(fn) in UNBOUNDED_SHAPES
+ else MAX_FLOODWAIT_SLEEP_SECONDS
+ )
+ sleep_for = min(e.value, cap)
+ logger.debug(
+ f"FloodWait in {_call_name(fn) or fn}, "
+ f"sleeping {sleep_for}s (asked {e.value}s, attempt {attempt}/{retries})"
+ )
+ await asyncio.sleep(sleep_for)
+ # every other exception propagates unchanged (fail-closed callers
+ # classify the error; broadcast counts it; routes map it)
+
+
+async def reply_safe(msg: Any, text: str, retries: int = 1, **kwargs: Any):
+ return await tg_call(msg.reply_text, text, quote=True, retries=retries, **kwargs)
+
+
+async def send_safe(cli: Any, chat_id: Any, retries: int = 1, **kwargs: Any):
+ return await tg_call(cli.send_message, chat_id=chat_id, retries=retries, **kwargs)
+
+
+async def edit_safe(msg: Any, text: str, retries: int = 1, **kwargs: Any):
+ return await tg_call(msg.edit_text, text, retries=retries, **kwargs)
+
+
+async def delete_safe(msg: Any, retries: int = 1):
+ return await tg_call(msg.delete, retries=retries)
+
+
+async def answer_safe(query: Any, text: str = "", retries: int = 1, **kwargs: Any):
+ return await tg_call(query.answer, text, retries=retries, **kwargs)
+
+
+__all__ = [
+ "tg_call",
+ "reply_safe",
+ "send_safe",
+ "edit_safe",
+ "delete_safe",
+ "answer_safe",
+ "UNBOUNDED_SHAPES",
+ "DEFAULT_RPC_TIMEOUT_SECONDS",
+ "MAX_FLOODWAIT_SLEEP_SECONDS",
+ "MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS",
+]
diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py
index 84061335..83409c92 100644
--- a/Thunder/utils/shortener.py
+++ b/Thunder/utils/shortener.py
@@ -1,13 +1,26 @@
-# Thunder/utils/shortener.py
+"""URL shortener.
+
+aiohttp HTTP layer. Hardening: LRU cache + per-URL singleflight, https-only
+endpoints, redirects never followed, and the returned short URL's host must
+match the configured site's host (anti redirect-to-attacker). API-key
+placement is provider-mandated (Bitly: Bearer header; path/query-key
+providers keep their documented schemes).
+"""
import asyncio
-import cloudscraper
from abc import ABC, abstractmethod
from base64 import b64encode
-from random import random, choice
-from urllib.parse import quote
-from Thunder.vars import Var
+from collections import OrderedDict
+from random import choice, random
+from urllib.parse import quote, urlparse
+
+import aiohttp
+
from Thunder.utils.logger import logger
+from Thunder.vars import Var
+
+SHORTEN_TIMEOUT_SECONDS = 10
+CACHE_MAX_ITEMS = 10_000
class ShortenerPlugin(ABC):
@@ -17,67 +30,122 @@ def matches(cls, domain: str) -> bool:
pass
@abstractmethod
- async def shorten(self, url: str, api_key: str) -> str:
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
pass
+ @staticmethod
+ def _validate_short_url(short_url: str, domain: str) -> bool:
+ """The response host must match the configured site."""
+ try:
+ # trailing-dot tolerant, matching _host_matches semantics
+ result_host = (urlparse(short_url).hostname or "").removesuffix(".")
+ site_host = (urlparse(f"https://{domain}").hostname or "").removesuffix(".")
+ return result_host == site_host
+ except ValueError:
+ return False
+
+ @staticmethod
+ def _host_matches(domain: str, *bases: str) -> bool:
+ """Exact-host or subdomain match against the provider's hostnames.
+
+ Substring checks accept lookalikes (``bitly.com.evil.com``); parsing
+ the hostname closes them. A trailing root dot (FQDN form) is tolerated.
+ """
+ try:
+ host = urlparse(f"https://{domain}").hostname or ""
+ except ValueError:
+ return False
+ host = host.removesuffix(".")
+ return any(host == base or host.endswith(f".{base}") for base in bases)
+
class LinkvertisePlugin(ShortenerPlugin):
+ """Offline constructor: no HTTP call involved, host check not needed."""
+
@classmethod
def matches(cls, domain: str) -> bool:
- return "linkvertise" in domain
+ return cls._host_matches(domain, "linkvertise.com")
- async def shorten(self, url: str, api_key: str) -> str:
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
encoded_url = quote(b64encode(url.encode("utf-8")))
- return choice([
- f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
- f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
- f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
- f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
- ])
+ return choice(
+ [
+ f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
+ f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
+ f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
+ f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
+ ]
+ )
class BitlyPlugin(ShortenerPlugin):
@classmethod
def matches(cls, domain: str) -> bool:
- return "bitly.com" in domain
+ return cls._host_matches(domain, "bitly.com", "bit.ly")
- async def shorten(self, url: str, api_key: str) -> str:
- response = await asyncio.to_thread(
- self.session.post,
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
+ async with session.post(
"https://api-ssl.bit.ly/v4/shorten",
json={"long_url": url},
headers={"Authorization": f"Bearer {api_key}"},
- )
- if response.status_code == 200:
- return response.json()["link"]
+ allow_redirects=False, # a 30x can never pass for a short URL
+ ) as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ short = data.get("link")
+ # Bitly answers on its own hosts (bit.ly / bitly.com), never on
+ # the configured site: validate against the provider hosts
+ if (
+ short
+ and short != url
+ and any(self._validate_short_url(short, d) for d in ("bit.ly", "bitly.com"))
+ ):
+ return short
return url
class OuoIoPlugin(ShortenerPlugin):
@classmethod
def matches(cls, domain: str) -> bool:
- return "ouo.io" in domain
-
- async def shorten(self, url: str, api_key: str) -> str:
- response = await asyncio.to_thread(
- self.session.get, f"http://ouo.io/api/{api_key}?s={url}"
- )
- if response.status_code == 200 and response.text:
- return response.text
+ return cls._host_matches(domain, "ouo.io")
+
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
+ async with session.get(
+ f"https://ouo.io/api/{api_key}", params={"s": url}, allow_redirects=False
+ ) as resp:
+ if resp.status == 200:
+ text = (await resp.text()).strip()
+ if text and self._validate_short_url(text, domain):
+ return text
return url
class CuttLyPlugin(ShortenerPlugin):
@classmethod
def matches(cls, domain: str) -> bool:
- return "cutt.ly" in domain
-
- async def shorten(self, url: str, api_key: str) -> str:
- response = await asyncio.to_thread(
- self.session.get, f"http://cutt.ly/api/api.php?key={api_key}&short={url}"
- )
- if response.status_code == 200:
- return response.json()["url"]["shortLink"]
+ return cls._host_matches(domain, "cutt.ly")
+
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
+ async with session.get(
+ "https://cutt.ly/api/api.php",
+ params={"key": api_key, "short": url},
+ allow_redirects=False,
+ ) as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ short = (data.get("url") or {}).get("shortLink")
+ if short and self._validate_short_url(short, domain):
+ return short
return url
@@ -86,80 +154,156 @@ class GenericShortenerPlugin(ShortenerPlugin):
def matches(cls, domain: str) -> bool:
return True
- async def shorten(self, url: str, api_key: str) -> str:
- response = await asyncio.to_thread(
- self.session.get, f"https://{self.domain}/api?api={api_key}&url={quote(url)}"
- )
- if response.status_code == 200:
- return response.json().get("shortenedUrl", url)
+ async def shorten(
+ self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str
+ ) -> str:
+ # provider-mandated wire format: shrinkme-style generic APIs take the
+ # key as a query param (cuttly/ouo keep their own documented schemes)
+ async with session.get(
+ f"https://{domain}/api",
+ params={"api": api_key, "url": url},
+ allow_redirects=False,
+ ) as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ short = data.get("shortenedUrl", url)
+ if short != url and not self._validate_short_url(short, domain):
+ logger.warning(f"Shortener returned foreign host {short!r}; rejecting.")
+ return url
+ return short
return url
class ShortenerSystem:
def __init__(self):
- self.session = None
- self.plugin = None
+ self.session: aiohttp.ClientSession | None = None
+ self.plugin: ShortenerPlugin | None = None
+ self.domain: str = ""
self.ready = False
- self._lock = asyncio.Lock()
+ self._cache: OrderedDict[str, str] = OrderedDict()
+ self._inflight: dict[str, asyncio.Future] = {}
+ self._init_lock = asyncio.Lock()
+
+ @staticmethod
+ def _normalize_site(site: str) -> str:
+ """Operators paste full URLs; plugins need a bare host (port kept)."""
+ cleaned = site.strip()
+ if "://" not in cleaned:
+ cleaned = f"https://{cleaned}"
+ parsed = urlparse(cleaned)
+ host = parsed.hostname or ""
+ if parsed.port:
+ host = f"{host}:{parsed.port}"
+ return host
def _get_plugin_class(self, domain: str):
for plugin_class in ShortenerPlugin.__subclasses__():
- if plugin_class.matches(domain):
+ if plugin_class is not GenericShortenerPlugin and plugin_class.matches(domain):
return plugin_class
-
return GenericShortenerPlugin
async def initialize(self) -> bool:
- if self.ready:
- return True
+ # lock: concurrent first use would otherwise build two sessions and leak one
+ async with self._init_lock:
+ if self.ready:
+ return True
- if not (getattr(Var, "SHORTEN_ENABLED", False) or
- getattr(Var, "SHORTEN_MEDIA_LINKS", False)):
- return False
+ if not (Var.SHORTEN_ENABLED or Var.SHORTEN_MEDIA_LINKS):
+ return False
- site = getattr(Var, "URL_SHORTENER_SITE", "")
- api_key = getattr(Var, "URL_SHORTENER_API_KEY", "")
+ site = ShortenerSystem._normalize_site(Var.URL_SHORTENER_SITE)
+ api_key = Var.URL_SHORTENER_API_KEY
- if not (site and api_key):
- return False
+ if not (site and api_key):
+ return False
+ try:
+ timeout = aiohttp.ClientTimeout(total=SHORTEN_TIMEOUT_SECONDS)
+ self.session = aiohttp.ClientSession(
+ timeout=timeout,
+ headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) FileToLink/shortener"},
+ )
+ # NOTE: redirects are disabled per-request -- aiohttp rejects
+ # allow_redirects on the session constructor (TypeError).
+ self.domain = site
+ plugin_class = self._get_plugin_class(site)
+ self.plugin = plugin_class()
+ self.ready = True
+ logger.info(f"Shortener ready (plugin={plugin_class.__name__}, site={site})")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True)
+ return False
+
+ async def _shorten_uncached(self, url: str) -> str:
+ if self.session is None or self.plugin is None:
+ return url
try:
- self.session = await asyncio.to_thread(
- cloudscraper.create_scraper,
- browser={
- 'browser': 'chrome',
- 'platform': 'windows',
- 'desktop': True,
- 'mobile': False
- },
- delay=1
+ short = await self.plugin.shorten(
+ self.session, url, Var.URL_SHORTENER_API_KEY, self.domain
)
-
- plugin_class = self._get_plugin_class(site)
- self.plugin = plugin_class()
- self.plugin.session = self.session
- self.plugin.domain = site
- self.ready = True
- return True
+ if short and short != url:
+ self._cache[url] = short
+ self._cache.move_to_end(url)
+ while len(self._cache) > CACHE_MAX_ITEMS:
+ self._cache.popitem(last=False)
+ return short or url
except Exception as e:
- logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True)
- return False
+ logger.error(f"Error shortening URL {url}: {e}", exc_info=True)
+ return url
async def short_url(self, url: str) -> str:
if not self.ready:
return url
- async with self._lock:
- try:
- return await self.plugin.shorten(url, Var.URL_SHORTENER_API_KEY)
- except Exception as e:
- logger.error(f"Error shortening URL {url}: {e}", exc_info=True)
- return url
+ cached = self._cache.get(url)
+ if cached is not None:
+ self._cache.move_to_end(url)
+ return cached
+
+ future = self._inflight.get(url)
+ if future is not None:
+ return await asyncio.shield(future)
+
+ loop = asyncio.get_running_loop()
+ future = loop.create_future()
+ self._inflight[url] = future
+ try:
+ result = await self._shorten_uncached(url)
+ if not future.done():
+ future.set_result(result)
+ return result
+ except BaseException as e:
+ # CancelledError is BaseException: without this, a cancelled runner hangs every waiter.
+ if not future.done():
+ future.set_exception(
+ e
+ if isinstance(e, Exception)
+ else RuntimeError(f"shortening of {url!r} aborted: {e!r}")
+ )
+ raise
+ finally:
+ self._inflight.pop(url, None)
+
+ async def close(self) -> None:
+ if self.session and not self.session.closed:
+ await self.session.close()
+ # drop everything: a post-close call must re-initialize, not reuse a
+ # dead session or silently serve stale cache entries
+ self.session = None
+ self.plugin = None
+ self.domain = ""
+ self.ready = False
_system = ShortenerSystem()
+async def close_shortener() -> None:
+ """Shutdown hook: close the shared aiohttp session."""
+ await _system.close()
+
+
async def shorten(url: str) -> str:
if not _system.ready:
await _system.initialize()
diff --git a/Thunder/utils/speedtest.py b/Thunder/utils/speedtest.py
deleted file mode 100644
index bdd94cc3..00000000
--- a/Thunder/utils/speedtest.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Thunder/utils/speedtest.py
-
-import asyncio
-from typing import Optional, Tuple, Dict, Any
-
-import speedtest
-from Thunder.utils.logger import logger
-
-
-async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
- try:
- return await asyncio.to_thread(_perform_speedtest)
- except Exception as e:
- logger.error(f"Speedtest failed: {e}", exc_info=True)
- return None, None
-
-
-def _perform_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
- try:
- st = speedtest.Speedtest(timeout=15, secure=True)
- st.get_best_server()
- st.download()
- st.upload(pre_allocate=False)
-
- results = st.results.dict()
- download_mbps = st.results.download / 1_000_000
- upload_mbps = st.results.upload / 1_000_000
-
- results['download_mbps'] = download_mbps
- results['upload_mbps'] = upload_mbps
- results['download_bps'] = st.results.download / 8
- results['upload_bps'] = st.results.upload / 8
-
- logger.debug(f"Download: {download_mbps:.2f} Mbps | Upload: {upload_mbps:.2f} Mbps")
-
- try:
- return results, st.results.share()
- except Exception:
- return results, None
-
- except Exception as e:
- logger.error(f"Speedtest failed: {e}")
- return None, None
diff --git a/Thunder/utils/time_format.py b/Thunder/utils/time_format.py
index 6900312d..d50fd21d 100644
--- a/Thunder/utils/time_format.py
+++ b/Thunder/utils/time_format.py
@@ -1,17 +1,16 @@
-# Thunder/utils/time_format.py
-
-from Thunder.utils.logger import logger
-
-_TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1))
-
-def get_readable_time(seconds: int) -> str:
- try:
- result = []
- for suffix, period in _TIME_PERIODS:
- if seconds >= period:
- value, seconds = divmod(int(seconds), period)
- result.append(f"{int(value)}{suffix}")
- return ' '.join(result) if result else '0s'
- except Exception as e:
- logger.error(f"Error in get_readable_time: {e}", exc_info=True)
- return "N/A"
+from Thunder.utils.logger import logger
+
+_TIME_PERIODS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1))
+
+
+def get_readable_time(seconds: int | float) -> str:
+ try:
+ result = []
+ for suffix, period in _TIME_PERIODS:
+ if seconds >= period:
+ value, seconds = divmod(int(seconds), period)
+ result.append(f"{int(value)}{suffix}")
+ return " ".join(result) if result else "0s"
+ except Exception as e:
+ logger.error(f"Error in get_readable_time: {e}", exc_info=True)
+ return "N/A"
diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py
index 76026804..155eb442 100644
--- a/Thunder/utils/tokens.py
+++ b/Thunder/utils/tokens.py
@@ -1,160 +1,232 @@
-# Thunder/utils/tokens.py
-
-import secrets
-from datetime import datetime, timedelta
-from typing import Optional, Dict, Any, List
-import asyncio
-import random
-import pyrogram.errors
-from Thunder.utils.database import db
-from Thunder.vars import Var
-from Thunder.utils.logger import logger
-
-async def check(user_id: int) -> bool:
- try:
- logger.debug(f"Token validation started for user: {user_id}")
- if not getattr(Var, "TOKEN_ENABLED", False):
- logger.debug("Token system disabled - access granted")
- return True
- if user_id == Var.OWNER_ID:
- logger.debug("Owner access granted")
- return True
- current_time = datetime.utcnow()
- auth_result = await db.authorized_users_col.find_one(
- {"user_id": user_id},
- {"_id": 1}
- )
- if auth_result:
- return True
- token_result = await db.token_col.find_one(
- {"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True},
- {"_id": 1}
- )
- access_granted = bool(token_result)
- logger.debug(f"Token validation {'SUCCESS' if access_granted else 'FAILURE'} for user: {user_id}")
- return access_granted
- except Exception as e:
- logger.error(f"Error in check for user {user_id}: {e}", exc_info=True)
- raise
-
-async def generate(user_id: int) -> str:
- try:
- logger.debug(f"Token generation started for user: {user_id}")
- existing_token_doc = await db.token_col.find_one(
- {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}},
- {"token": 1}
- )
- if existing_token_doc:
- logger.debug(f"Returning existing unactivated token for user: {user_id}")
- return existing_token_doc["token"]
- token_str = secrets.token_urlsafe(32)
- masked_token = f"{token_str[:4]}...{token_str[-4:]}"
- logger.debug(f"Generated new token: {masked_token}")
- max_retries = 3
- base_delay = 0.5
- for attempt in range(max_retries):
- try:
- ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24)
- created_at = datetime.utcnow()
- expires_at = created_at + timedelta(hours=ttl_hours)
- await db.save_main_token(
- user_id=user_id,
- token_value=token_str,
- expires_at=expires_at,
- created_at=created_at,
- activated=False
- )
- logger.debug(f"New token generated and saved successfully for user: {user_id}")
- return token_str
- except pyrogram.errors.RPCError as e:
- logger.error(f"Telegram API error while generating new token for user {user_id}: {e}", exc_info=True)
- raise
- except Exception as e:
- if attempt < max_retries - 1:
- delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
- logger.warning(f"Database error (attempt {attempt+1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", exc_info=True)
- await asyncio.sleep(delay)
- else:
- logger.error(f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}", exc_info=True)
- raise
- return ""
- except Exception as e:
- logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True)
- raise
-
-async def allowed(user_id: int) -> bool:
- try:
- result = await db.authorized_users_col.find_one(
- {"user_id": user_id},
- {"_id": 1}
- )
- return bool(result)
- except Exception as e:
- logger.error(f"Error in allowed for user {user_id}: {e}", exc_info=True)
- raise
-
-async def authorize(user_id: int, authorized_by: int) -> bool:
- try:
- auth_data = {
- "user_id": user_id,
- "authorized_by": authorized_by,
- "authorized_at": datetime.utcnow()
- }
- await db.authorized_users_col.update_one(
- {"user_id": user_id},
- {"$set": auth_data},
- upsert=True
- )
- return True
- except Exception as e:
- logger.error(f"Error in authorize for user {user_id}: {e}", exc_info=True)
- raise
-
-async def deauthorize(user_id: int) -> bool:
- try:
- result = await db.authorized_users_col.delete_one({"user_id": user_id})
- return result.deleted_count > 0
- except Exception as e:
- logger.error(f"Error in deauthorize for user {user_id}: {e}", exc_info=True)
- raise
-
-async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await db.token_col.find_one({"user_id": user_id})
- except Exception as e:
- logger.error(f"Error in get_user for user {user_id}: {e}", exc_info=True)
- return None
-
-async def list_allowed() -> List[Dict[str, Any]]:
- try:
- cursor = db.authorized_users_col.find(
- {},
- {"user_id": 1, "authorized_by": 1, "authorized_at": 1}
- )
- return await cursor.to_list(length=None)
- except Exception as e:
- logger.error(f"Error in list_allowed: {e}", exc_info=True)
- return []
-
-async def list_tokens() -> List[Dict[str, Any]]:
- try:
- current_time = datetime.utcnow()
- cursor = db.token_col.find(
- {"expires_at": {"$gt": current_time}},
- {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1}
- )
- return await cursor.to_list(length=None)
- except Exception as e:
- logger.error(f"Error in list_tokens: {e}", exc_info=True)
- return []
-
-async def cleanup_expired_tokens() -> int:
- try:
- current_time = datetime.utcnow()
- logger.debug("Cleaning up expired tokens")
- result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}})
- logger.debug(f"Cleaned up {result.deleted_count} expired tokens")
- return result.deleted_count
- except Exception as e:
- logger.error(f"Error in cleanup_expired_tokens: {e}", exc_info=True)
- return 0
+import asyncio
+import random
+import secrets
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from Thunder.utils.database import db
+from Thunder.utils.flag_cache import flags
+from Thunder.utils.logger import logger
+from Thunder.vars import Var
+
+
+def _invalidate_user_flags(user_id: int) -> None:
+ flags.invalidate(("allowed", user_id), ("token_ok", user_id))
+
+
+def _as_aware_utc(value: Any) -> datetime | None:
+ """Normalize a DB-loaded expiry to aware UTC.
+
+ Legacy rows may carry naive datetimes (pre-tz_aware driver decodes of
+ UTC instants) or non-datetime junk. Naive → assume UTC (BSON stores UTC
+ millis); anything else → None so the caller fails closed instead of
+ raising TypeError mid-activation (no operator data migration needed).
+ """
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ return value.replace(tzinfo=UTC)
+ return value
+ return None
+
+
+async def check(user_id: int) -> bool:
+ """Token/authorization gate (cached, fail-closed)."""
+ try:
+ if not Var.TOKEN_ENABLED:
+ return True
+ if user_id == Var.OWNER_ID:
+ return True
+ # cached authorized-user lookup (5 min TTL)
+ if await allowed(user_id):
+ return True
+ return await flags.get_or_load(
+ ("token_ok", user_id),
+ lambda: _load_token_ok(user_id),
+ )
+ except Exception as e:
+ logger.error(f"Error in check for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+async def _load_token_ok(user_id: int) -> bool:
+ """Loader for the activated-token flag. Raises on DB failure so the
+ caller can apply its fail-closed policy."""
+ token_result = await db.token_col.find_one(
+ {"user_id": user_id, "expires_at": {"$gt": datetime.now(UTC)}, "activated": True},
+ {"_id": 1},
+ )
+ return bool(token_result)
+
+
+async def generate(user_id: int) -> str:
+ try:
+ logger.debug(f"Token generation started for user: {user_id}")
+ existing_token_doc = await db.token_col.find_one(
+ {
+ "user_id": user_id,
+ "activated": False,
+ "expires_at": {"$gt": datetime.now(UTC)},
+ },
+ {"token": 1},
+ )
+ if existing_token_doc:
+ logger.debug(f"Returning existing unactivated token for user: {user_id}")
+ return existing_token_doc["token"]
+ token_str = secrets.token_urlsafe(32)
+ max_retries = 3
+ base_delay = 0.5
+ for attempt in range(max_retries):
+ try:
+ ttl_hours = Var.TOKEN_TTL_HOURS
+ created_at = datetime.now(UTC)
+ expires_at = created_at + timedelta(hours=ttl_hours)
+ await db.save_main_token(
+ user_id=user_id,
+ token_value=token_str,
+ expires_at=expires_at,
+ created_at=created_at,
+ activated=False,
+ )
+ logger.debug(f"New token generated and saved successfully for user: {user_id}")
+ return token_str
+ except Exception as e:
+ if attempt < max_retries - 1:
+ delay = base_delay * (2**attempt) + random.uniform(0, 0.1)
+ logger.warning(
+ f"Database error (attempt {attempt + 1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.",
+ exc_info=True,
+ )
+ await asyncio.sleep(delay)
+ else:
+ logger.error(
+ f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}",
+ exc_info=True,
+ )
+ raise
+ # The loop always returns or raises on its final attempt; crash loudly
+ # instead of returning "", which callers would treat as a real token value.
+ raise RuntimeError("token save retry loop exited without success")
+ except Exception as e:
+ logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+async def consume(token: str, user_id: int) -> tuple[str, float]:
+ """Atomically activate a token.
+
+ ``find_one_and_update`` conditioned on ``activated != True``: exactly one
+ concurrent activation can win. Returns ``(status, hours_valid)`` with
+ status one of ``"ok" | "already" | "wrong_user" | "invalid"``.
+ """
+ now = datetime.now(UTC)
+ try:
+ doc = await db.token_col.find_one({"token": token})
+ if not doc:
+ return "invalid", 0.0
+ if doc.get("user_id") != user_id:
+ return "wrong_user", 0.0
+ if doc.get("activated"):
+ return "already", 0.0
+ if doc.get("expires_at"):
+ expires_at_loaded = _as_aware_utc(doc["expires_at"])
+ if expires_at_loaded is None:
+ # corrupt expiry shape: fail closed, never activate, never 500
+ logger.warning(f"Ignoring token with non-datetime expires_at for user {user_id}.")
+ return "invalid", 0.0
+ if expires_at_loaded <= now:
+ # a stale deep-link must not activate a token that expired before the CAS
+ return "invalid", 0.0
+
+ expires_at = now + timedelta(hours=Var.TOKEN_TTL_HOURS)
+ activated_doc = await db.token_col.find_one_and_update(
+ {
+ "token": token,
+ "user_id": user_id,
+ "activated": {"$ne": True},
+ "expires_at": {"$gt": now},
+ },
+ {
+ "$set": {
+ "activated": True,
+ "activated_at": now,
+ "created_at": now,
+ "expires_at": expires_at,
+ }
+ },
+ return_document=True,
+ )
+ if activated_doc is None:
+ # Lost the CAS race: distinguish a concurrent activation (doc now
+ # activated) from a doc that expired between pre-check and CAS;
+ # both used to surface as the misleading "already".
+ current = await db.token_col.find_one({"token": token}, {"activated": 1})
+ if current and current.get("activated"):
+ return "already", 0.0
+ return "invalid", 0.0
+ _invalidate_user_flags(user_id)
+ hours = round((expires_at - now).total_seconds() / 3600, 1)
+ logger.debug(f"Token atomically activated for user {user_id} ({hours}h)")
+ return "ok", hours
+ except Exception as e:
+ logger.error(f"Error in consume for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+async def allowed(user_id: int, *, raise_on_error: bool = True) -> bool:
+ """Cached authorized-user check. With ``raise_on_error=True`` (default)
+ a DB failure raises so fail-closed callers deny; with False it returns False."""
+ return await flags.get_or_load(
+ ("allowed", user_id),
+ # delegate: two copies of the same existence check would drift
+ lambda: db.is_user_authorized(user_id, raise_on_error=raise_on_error),
+ )
+
+
+async def authorize(user_id: int, authorized_by: int) -> bool:
+ try:
+ auth_data = {
+ "user_id": user_id,
+ "authorized_by": authorized_by,
+ "authorized_at": datetime.now(UTC),
+ }
+ await db.authorized_users_col.update_one(
+ {"user_id": user_id}, {"$set": auth_data}, upsert=True
+ )
+ _invalidate_user_flags(user_id)
+ return True
+ except Exception as e:
+ logger.error(f"Error in authorize for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+async def deauthorize(user_id: int) -> bool:
+ try:
+ result = await db.authorized_users_col.delete_one({"user_id": user_id})
+ _invalidate_user_flags(user_id)
+ return result.deleted_count > 0
+ except Exception as e:
+ logger.error(f"Error in deauthorize for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+async def list_allowed() -> list[dict[str, Any]]:
+ try:
+ cursor = db.authorized_users_col.find(
+ {}, {"user_id": 1, "authorized_by": 1, "authorized_at": 1}
+ )
+ return await cursor.to_list(length=None)
+ except Exception as e:
+ logger.error(f"Error in list_allowed: {e}", exc_info=True)
+ return []
+
+
+async def cleanup_expired_tokens() -> int:
+ try:
+ current_time = datetime.now(UTC)
+ logger.debug("Cleaning up expired tokens")
+ result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}})
+ logger.debug(f"Cleaned up {result.deleted_count} expired tokens")
+ return result.deleted_count
+ except Exception as e:
+ logger.error(f"Error in cleanup_expired_tokens: {e}", exc_info=True)
+ return 0
diff --git a/Thunder/vars.py b/Thunder/vars.py
index 75090ac1..654fe9be 100644
--- a/Thunder/vars.py
+++ b/Thunder/vars.py
@@ -1,102 +1,296 @@
-# Thunder/vars.py
-
-import os
-
-from dotenv import load_dotenv
-from typing import Set, Optional
-from Thunder.utils.logger import logger
-
-load_dotenv("config.env")
-
-def str_to_bool(val: str) -> bool:
- return val.lower() in ("true", "1", "t", "y", "yes")
-
-def str_to_int_set(val: str) -> Set[int]:
- if not val:
- return set()
- result: Set[int] = set()
- for x in val.split():
- try:
- result.add(int(x))
- except (TypeError, ValueError):
- continue
- return result
-
-
-
-class Var:
- API_ID: int = int(os.getenv("API_ID", "0"))
- API_HASH: str = os.getenv("API_HASH", "")
- BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
-
- if not all([API_ID, API_HASH, BOT_TOKEN]):
- logger.critical("Missing required Telegram API configuration")
- raise ValueError("Missing required Telegram API configuration")
-
- NAME: str = os.getenv("NAME", "ThunderF2L")
- SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "600"))
- WORKERS: int = int(os.getenv("WORKERS", "8"))
-
- BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
-
- if not BIN_CHANNEL:
- logger.critical("BIN_CHANNEL is required")
- raise ValueError("BIN_CHANNEL is required")
-
- PORT: int = int(os.getenv("PORT", "8080"))
- BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0")
- PING_INTERVAL: int = int(os.getenv("PING_INTERVAL", "840"))
- NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True"))
-
- OWNER_ID: int = int(os.getenv("OWNER_ID", "0"))
-
- if not OWNER_ID:
- logger.warning("WARNING: OWNER_ID is not set. No user will be granted owner access.")
-
- FQDN: str = os.getenv("FQDN", "") or BIND_ADDRESS
- HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "True"))
- PROTOCOL: str = "https" if HAS_SSL else "http"
- PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}"
- URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/"
-
- SET_COMMANDS: bool = str_to_bool(os.getenv("SET_COMMANDS", "True"))
-
- DATABASE_URL: str = os.getenv("DATABASE_URL", "")
-
- if not DATABASE_URL:
- logger.critical("DATABASE_URL is required")
- raise ValueError("DATABASE_URL is required")
-
- MAX_BATCH_FILES: int = int(os.getenv("MAX_BATCH_FILES", "50"))
-
- CHANNEL: bool = str_to_bool(os.getenv("CHANNEL", "False"))
-
- BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
-
- MULTI_CLIENT: bool = False
-
- FORCE_CHANNEL_ID: Optional[int] = None
-
- force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip()
-
- if force_channel_env:
- try:
- FORCE_CHANNEL_ID = int(force_channel_env)
- except ValueError:
- logger.warning(f"Invalid FORCE_CHANNEL_ID '{force_channel_env}' in environment; must be an integer.")
-
- TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False"))
- TOKEN_TTL_HOURS: int = int(os.getenv("TOKEN_TTL_HOURS", "24"))
-
- SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False"))
- SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False"))
- URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "")
- URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "")
-
- GLOBAL_RATE_LIMIT: bool = str_to_bool(os.getenv("GLOBAL_RATE_LIMIT", "False"))
- MAX_GLOBAL_REQUESTS_PER_MINUTE: int = int(os.getenv("MAX_GLOBAL_REQUESTS_PER_MINUTE", "4"))
-
- RATE_LIMIT_ENABLED: bool = str_to_bool(os.getenv("RATE_LIMIT_ENABLED", "False"))
- MAX_FILES_PER_PERIOD: int = int(os.getenv("MAX_FILES_PER_PERIOD", "2"))
- RATE_LIMIT_PERIOD_MINUTES: int = int(os.getenv("RATE_LIMIT_PERIOD_MINUTES", "1"))
- MAX_QUEUE_SIZE: int = int(os.getenv("MAX_QUEUE_SIZE", "100"))
+"""Central configuration.
+
+Loads ``config.env`` then ``config.env.local`` (local layer wins), validates
+all variables and reports every problem together before failing, enforces
+bounds, and hard-fails on missing ``OWNER_ID`` (H7). The ``Var`` facade is
+kept so no import site changes.
+"""
+
+import os
+from urllib.parse import unquote, urlsplit
+
+from dotenv import dotenv_values
+
+from Thunder.utils.logger import logger
+
+
+def _load_env_layers() -> None:
+ """Load ``config.env`` then ``config.env.local`` with real precedence.
+
+ Precedence: real environment > config.env.local > config.env.
+
+ ``THUNDER_SKIP_CONFIG_FILES=1`` disables both files so test tiers stay
+ hermetic against a developer's own config.env.
+ """
+ if os.environ.get("THUNDER_SKIP_CONFIG_FILES") == "1":
+ return
+ merged: dict[str, str | None] = {}
+ for path in ("config.env", "config.env.local"):
+ # dotenv_values swallows missing/unreadable files and returns {}
+ merged.update(dotenv_values(path))
+ for key, value in merged.items():
+ if value is not None:
+ os.environ.setdefault(key, value)
+ if "APP_VERSION" in merged:
+ # __init__ snapshots APP_VERSION at import, before this loader runs --
+ # a file value is silently ignored, so say so instead of lying
+ logger.warning("APP_VERSION in a config file is ignored; set it in the real environment.")
+
+
+_load_env_layers()
+
+
+def str_to_bool(val: str) -> bool:
+ # strip: raw env values can carry trailing whitespace/CR
+ return str(val).strip().lower() in ("true", "1", "t", "y", "yes")
+
+
+_config_errors: list[str] = []
+_config_warnings: list[str] = []
+# vars whose parse already failed: _require must not pile a second,
+# misleading error onto the fallback value
+_parse_failed: set[str] = set()
+
+
+def str_to_int_set(val: str) -> set[int]:
+ if not val:
+ return set()
+ result: set[int] = set()
+ bad: list[str] = []
+ for x in val.split():
+ try:
+ result.add(int(x))
+ except (TypeError, ValueError):
+ # collect-all-errors: junk tokens surface as one error, not N
+ bad.append(x)
+ continue
+ if bad:
+ _config_errors.append(f"BANNED_CHANNELS={val!r} has non-integer entries: {bad!r}")
+ return result
+
+
+def _get_int(
+ name: str, default: str, *, min_val: int | None = None, max_val: int | None = None
+) -> int:
+ raw = os.getenv(name, default)
+ try:
+ value = int(str(raw).strip())
+ except (TypeError, ValueError):
+ _config_errors.append(f"{name}={raw!r} is not a valid integer")
+ _parse_failed.add(name)
+ return int(default)
+ if min_val is not None and value < min_val:
+ _config_errors.append(f"{name}={value} must be >= {min_val}")
+ if max_val is not None and value > max_val:
+ _config_errors.append(f"{name}={value} must be <= {max_val}")
+ return value
+
+
+def _get_optional_int(name: str) -> int | None:
+ raw = os.getenv(name, "").strip()
+ if not raw:
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ _config_errors.append(f"{name}={raw!r} must be an integer")
+ return None
+
+
+def _get_float(name: str, default: str, *, min_val: float | None = None) -> float:
+ raw = os.getenv(name, default)
+ try:
+ value = float(str(raw).strip())
+ except (TypeError, ValueError):
+ _config_errors.append(f"{name}={raw!r} is not a valid number")
+ _parse_failed.add(name)
+ return float(default)
+ if min_val is not None and value < min_val:
+ _config_errors.append(f"{name}={value} must be >= {min_val}")
+ return value
+
+
+def _require(value: object, name: str, what: str) -> None:
+ if name in _parse_failed:
+ return # parse error already reported; don't double-count the fallback
+ if not value:
+ _config_errors.append(f"{name} is required ({what})")
+
+
+class Var:
+ # ---- Required Telegram configuration ----
+ API_ID: int = _get_int("API_ID", "0", min_val=1)
+ API_HASH: str = os.getenv("API_HASH", "")
+ BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
+ _require(API_HASH, "API_HASH", "app hash from my.telegram.org")
+ _require(BOT_TOKEN, "BOT_TOKEN", "bot token from @BotFather")
+
+ # NAME is NOT the bot's display name -- the banner prints
+ # bot_info.first_name straight from Telegram. NAME is used in exactly one
+ # place, Database(Var.DATABASE_URL, Var.NAME), and selects the Mongo
+ # database. config_sample.env used to call it "Bot application name",
+ # which is how an operator ends up renaming the bot and silently pointing
+ # it at a different, empty database.
+ NAME: str = os.getenv("NAME", "ThunderF2L")
+ # pyrofork auto-sleeps FloodWait <= this threshold inside the RPC; waits
+ # above it surface to tg_call's bounded retry. Keep well below
+ # TG_RPC_TIMEOUT_SECONDS or auto-slept waits blow the per-RPC budget.
+ SLEEP_THRESHOLD: int = _get_int("SLEEP_THRESHOLD", "10", min_val=0)
+ WORKERS: int = _get_int("WORKERS", "8", min_val=1, max_val=64)
+
+ BIN_CHANNEL: int = _get_int("BIN_CHANNEL", "0")
+ _require(BIN_CHANNEL, "BIN_CHANNEL", "storage channel id, e.g. -1001234567890")
+
+ PORT: int = _get_int("PORT", "8080", min_val=1, max_val=65535)
+ BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") # nosec B104 -- user-configured listen address
+ PING_INTERVAL: int = _get_int("PING_INTERVAL", "840", min_val=30)
+ NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True"))
+
+ # missing OWNER_ID is fatal -- every owner check would match nobody.
+ OWNER_ID: int = _get_int("OWNER_ID", "0", min_val=1)
+
+ FQDN: str = os.getenv("FQDN", "").strip() or BIND_ADDRESS
+ if not os.getenv("FQDN", "").strip():
+ _config_warnings.append(
+ "FQDN is not set; generated links will use the bind address and "
+ "will not be reachable from outside this machine."
+ )
+ HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "True"))
+ PROTOCOL: str = "https" if HAS_SSL else "http"
+ PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}"
+ URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/"
+
+ SET_COMMANDS: bool = str_to_bool(os.getenv("SET_COMMANDS", "True"))
+
+ DATABASE_URL: str = os.getenv("DATABASE_URL", "")
+ _require(DATABASE_URL, "DATABASE_URL", "MongoDB connection string")
+
+ # The database NAME comes from NAME, not from the path in DATABASE_URL.
+ # config_sample.env's example ("mongodb+srv://user:pass@host/db") reads as
+ # though the path selects the database, so an operator pointing DATABASE_URL
+ # at a new cluster expects a new database and instead gets the old NAME's,
+ # which looks exactly like total data loss. Verified against a live cluster:
+ # a URI whose path said "frdeewf" was served from "ThunderF2L".
+ #
+ # Warn rather than silently honour the path: switching which one wins would
+ # repoint every existing deployment at an empty database.
+ _uri_db = ""
+ try:
+ _path = urlsplit(DATABASE_URL).path.strip("/")
+ if _path:
+ _uri_db = unquote(_path.split("/")[0])
+ except Exception:
+ _uri_db = ""
+ if _uri_db and _uri_db != NAME:
+ _config_warnings.append(
+ f"DATABASE_URL names database {_uri_db!r} but NAME selects "
+ f"{NAME!r} -- NAME wins and the path is IGNORED. Set NAME="
+ f"{_uri_db!r} to use the URI's database, or drop the path to silence "
+ "this. Silently using the other one looks like total data loss."
+ )
+
+ MAX_BATCH_FILES: int = _get_int("MAX_BATCH_FILES", "50", min_val=1, max_val=100)
+
+ CHANNEL: bool = str_to_bool(os.getenv("CHANNEL", "False"))
+ BANNED_CHANNELS: set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
+
+ FORCE_CHANNEL_ID: int | None = _get_optional_int("FORCE_CHANNEL_ID")
+
+ TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False"))
+ TOKEN_TTL_HOURS: int = _get_int("TOKEN_TTL_HOURS", "24", min_val=1)
+
+ SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False"))
+ SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False"))
+ URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "")
+ URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "")
+ if (SHORTEN_ENABLED or SHORTEN_MEDIA_LINKS) and not (
+ URL_SHORTENER_SITE and URL_SHORTENER_API_KEY
+ ):
+ _config_warnings.append(
+ "Shortener enabled but URL_SHORTENER_SITE/URL_SHORTENER_API_KEY "
+ "missing; links will not be shortened."
+ )
+
+ GLOBAL_RATE_LIMIT: bool = str_to_bool(os.getenv("GLOBAL_RATE_LIMIT", "False"))
+ MAX_GLOBAL_REQUESTS_PER_MINUTE: int = _get_int("MAX_GLOBAL_REQUESTS_PER_MINUTE", "4", min_val=1)
+ GLOBAL_RPS_LIMIT: float = _get_float(
+ "GLOBAL_RPS_LIMIT", "0", min_val=0
+ ) # 0 = derive from per-minute value
+
+ RATE_LIMIT_ENABLED: bool = str_to_bool(os.getenv("RATE_LIMIT_ENABLED", "False"))
+ MAX_FILES_PER_PERIOD: int = _get_int("MAX_FILES_PER_PERIOD", "2", min_val=1)
+ RATE_LIMIT_PERIOD_MINUTES: int = _get_int("RATE_LIMIT_PERIOD_MINUTES", "1", min_val=1)
+ MAX_QUEUE_SIZE: int = _get_int("MAX_QUEUE_SIZE", "100", min_val=1)
+
+ # ---- Feature knobs ----
+
+ # allowlist mode -- only owner + authorized users may use the bot.
+ PRIVATE_MODE: bool = str_to_bool(os.getenv("PRIVATE_MODE", "False"))
+
+ # legacy /watch/{hash}{id} URL family (default on; removal planned).
+ ENABLE_LEGACY_LINKS: bool = str_to_bool(os.getenv("ENABLE_LEGACY_LINKS", "True"))
+
+ # /shell kill-switch -- powerful owner command is opt-in.
+ ENABLE_SHELL: bool = str_to_bool(os.getenv("ENABLE_SHELL", "False"))
+
+ # optional expiry for file records (0 = keep forever).
+ FILE_TTL_DAYS: int = _get_int("FILE_TTL_DAYS", "0", min_val=0, max_val=3650)
+
+ # queue executor worker pool.
+ EXECUTOR_WORKERS: int = _get_int("EXECUTOR_WORKERS", "5", min_val=1, max_val=32)
+
+ # worker pools for broadcast / batch.
+ BROADCAST_WORKERS: int = _get_int("BROADCAST_WORKERS", "4", min_val=1, max_val=16)
+ BATCH_WORKERS: int = _get_int("BATCH_WORKERS", "5", min_val=1, max_val=16)
+
+ # per-client admission cap for the streaming server.
+ MAX_CONCURRENT_STREAMS: int = _get_int("MAX_CONCURRENT_STREAMS", "8", min_val=1)
+
+ # touch-buffer flush cadence + cap.
+ TOUCH_FLUSH_SECONDS: int = _get_int("TOUCH_FLUSH_SECONDS", "3", min_val=1, max_val=60)
+ TOUCH_BUFFER_MAX: int = _get_int("TOUCH_BUFFER_MAX", "1000", min_val=100)
+
+ # default wall-clock budget for lightweight Telegram RPCs.
+ TG_RPC_TIMEOUT_SECONDS: float = _get_float("TG_RPC_TIMEOUT_SECONDS", "30", min_val=0)
+
+
+if Var.TG_RPC_TIMEOUT_SECONDS > 0 and Var.SLEEP_THRESHOLD >= Var.TG_RPC_TIMEOUT_SECONDS:
+ _config_warnings.append(
+ "SLEEP_THRESHOLD should stay below TG_RPC_TIMEOUT_SECONDS or auto-slept "
+ "waits blow the per-RPC budget."
+ )
+
+
+# with both gates on, the private-mode gate rejects a token activation
+# deep-link before /start's consume path runs -- token access becomes unreachable.
+if Var.PRIVATE_MODE and Var.TOKEN_ENABLED:
+ _config_errors.append(
+ "PRIVATE_MODE and TOKEN_ENABLED cannot both be enabled: token "
+ "activation links are unreachable behind the private-mode allowlist."
+ )
+
+if _config_errors:
+ logger.critical(f"Invalid configuration -- {len(_config_errors)} problem(s) found:")
+ for err in _config_errors:
+ logger.critical(f" ✖ {err}")
+ raise SystemExit(
+ f"Configuration invalid: fix {len(_config_errors)} problem(s) listed above "
+ "in config.env / environment and start again."
+ )
+
+if _config_warnings:
+ for warn in _config_warnings:
+ logger.warning(f" ⚠ {warn}")
+
+mode_note = "PRIVATE" if Var.PRIVATE_MODE else "public"
+logger.debug(f"Gate mode: {mode_note}; legacy links: {'on' if Var.ENABLE_LEGACY_LINKS else 'off'}")
+
+# README promises the bot "infers them and logs what it chose" for the PaaS
+# companions. It inferred them fine but only ever logged at DEBUG, which is off
+# by default -- so the promise was invisible exactly when an operator needed it.
+# These four decide the public URL, so say them at INFO.
+logger.info(
+ f"Serving at {Var.URL or '(unset -- see above)'} "
+ f"(FQDN={Var.FQDN or 'unset'}, port={'none' if Var.NO_PORT else Var.PORT}, "
+ f"ssl={'on' if Var.HAS_SSL else 'off'})"
+)
diff --git a/app.json b/app.json
new file mode 100644
index 00000000..b6f31ef5
--- /dev/null
+++ b/app.json
@@ -0,0 +1,47 @@
+{
+ "name": "Thunder FileToLink",
+ "description": "Telegram files to direct HTTP links.",
+ "repository": "https://github.com/fyaz05/FileToLink",
+ "stack": "container",
+ "formation": {
+ "web": {
+ "quantity": 1,
+ "size": "basic"
+ }
+ },
+ "env": {
+ "API_ID": {
+ "description": "Telegram API ID (my.telegram.org).",
+ "required": true
+ },
+ "API_HASH": {
+ "description": "Telegram API hash.",
+ "required": true
+ },
+ "BOT_TOKEN": {
+ "description": "Bot token (@BotFather).",
+ "required": true
+ },
+ "BIN_CHANNEL": {
+ "description": "Storage channel ID (bot must be admin).",
+ "required": true
+ },
+ "OWNER_ID": {
+ "description": "Owner user ID (boot refuses without it).",
+ "required": true
+ },
+ "DATABASE_URL": {
+ "description": "MongoDB connection string (no Heroku addon; use external).",
+ "required": true
+ },
+ "FQDN": {
+ "description": "Public domain for generated links.",
+ "required": false
+ },
+ "HAS_SSL": {
+ "description": "HTTPS enabled.",
+ "value": "True",
+ "required": false
+ }
+ }
+}
diff --git a/config_sample.env b/config_sample.env
index b83b4343..a07a4789 100644
--- a/config_sample.env
+++ b/config_sample.env
@@ -1,125 +1,184 @@
-# =============================================================
-# Rename this file to config.env before using
-# =============================================================
-
-####################
-## REQUIRED SETTINGS
-####################
-
-# Telegram API credentials (from https://my.telegram.org/apps)
-API_ID=0 # Example: 1234567
-API_HASH="" # Example: "abc123def456"
-
-# Bot token (from @BotFather)
-BOT_TOKEN="" # Example: "123456789:ABCdef..."
-
-# Storage channel ID (create a channel and add bot as admin)
-BIN_CHANNEL=0 # Example: -1001234567890
-
-# Owner information (get ID from @userinfobot)
-OWNER_ID=0 # Your Telegram user ID. Example: 123456789
-
-# Database connection string
-DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db"
-
-# Deployment configuration
-FQDN="" # Your domain name
-HAS_SSL="True" # Set to "True" if using HTTPS
-PORT=8080 # Web server port
-NO_PORT="True" # Hide port in URLs ("True" or "False")
-
-####################
-## OPTIONAL SETTINGS
-####################
-
-MAX_BATCH_FILES=50
-
-# Set bot commands on startup (True/False)
-SET_COMMANDS="True"
-
-# Force users to join a specific channel before using the bot
-FORCE_CHANNEL_ID="" # Example: -1001234567890 (Leave empty if not needed)
-
-# Allow processing of channel messages (True/False)
-CHANNEL="False"
-
-# Banned channels (files from these channels will be rejected)
-BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (Space-separated IDs, leave empty if none)
-
-# Multiple bot tokens (can add up to MULTI_TOKEN49) # Example: MULTI_TOKEN49="123456789:ABCdef..."
-MULTI_TOKEN1=""
-
-####################
-## TOKEN SYSTEM SETTINGS
-####################
-
-# Enable token-based access (True/False)
-TOKEN_ENABLED="False"
-
-# Default token validity in hours
-TOKEN_TTL_HOURS="24"
-
-####################
-## URL SHORTENER SETTINGS
-####################
-
-# Enable URL shortening for tokens (True/False)
-SHORTEN_ENABLED="False"
-
-# Enable URL shortening for media links (True/False)
-SHORTEN_MEDIA_LINKS="False"
-
-# URL Shortener
-URL_SHORTENER_API_KEY="" # Example: "abc123def456"
-URL_SHORTENER_SITE="" # Example: "example.com"
-
-####################
-## GLOBAL RATE LIMITING SETTINGS
-####################
-
-# Enable global rate limiting (True/False)
-GLOBAL_RATE_LIMIT="False"
-
-# Maximum number of requests allowed across all users per minute
-MAX_GLOBAL_REQUESTS_PER_MINUTE=4
-
-####################
-## RATE LIMITING SETTINGS
-####################
-
-# Enable rate limiting (True/False)
-RATE_LIMIT_ENABLED="False"
-
-MAX_FILES_PER_PERIOD=2
-
-# Time window in minutes for rate limiting
-RATE_LIMIT_PERIOD_MINUTES=1
-
-# Maximum number of requests that can be queued.
-MAX_QUEUE_SIZE=100
-
-####################
-## UPDATE SETTINGS
-####################
-
-# Git repository for updates
-UPSTREAM_REPO="https://github.com/fyaz05/FileToLink"
-
-# Branch to update from
-UPSTREAM_BRANCH="main" # Default branch for updates
-
-####################
-## ADVANCED SETTINGS (modify with caution)
-####################
-
-# Application name
-NAME="ThunderF2L" # Bot application name
-
-# Performance settings
-SLEEP_THRESHOLD=600 # Sleep time in seconds
-WORKERS=8 # Number of worker processes
-
-# Web server configuration
-BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces
-PING_INTERVAL=840 # Ping interval in seconds
-
+# Rename this file to config.env before using.
+# A config.env.local overrides config.env for local-only tweaks.
+
+## REQUIRED SETTINGS
+
+# Telegram API credentials (from https://my.telegram.org/apps)
+API_ID=0 # Example: 1234567 (min 1; 0 placeholder fails boot validation by design)
+API_HASH="" # Example: "abc123def456"
+
+# Bot token (from @BotFather)
+BOT_TOKEN="" # Example: "123456789:ABCdef..."
+
+# Storage channel ID (create a channel and add bot as admin)
+BIN_CHANNEL=0 # Example: -1001234567890
+
+# Owner ID (get it from @userinfobot). Required: boot refuses without it.
+OWNER_ID=0 # Example: 123456789 (min 1; 0 placeholder fails boot validation by design)
+
+# Database connection string
+# NOTE: the path here is IGNORED. The database is selected by NAME below, not
+# by the path. Leaving a path in that disagrees with NAME makes the bot warn at
+# boot and use NAME's database anyway.
+DATABASE_URL="" # Example: "mongodb+srv://user:pass@host"
+
+# Deployment configuration
+FQDN="" # Your domain name (warning logged when empty: links use the bind address)
+HAS_SSL="True" # Set to "True" if using HTTPS
+PORT=8080 # Web server port (1-65535)
+NO_PORT="True" # Hide port in URLs ("True" or "False")
+
+## OPTIONAL SETTINGS
+
+MAX_BATCH_FILES=50 # 1-100
+
+# Set bot commands on startup (True/False)
+SET_COMMANDS="True"
+
+# Force users to join a specific channel before using the bot
+FORCE_CHANNEL_ID="" # Example: -1001234567890 (empty = unset; non-integer fails boot)
+
+# Allow processing of channel messages (True/False)
+CHANNEL="False"
+
+# Banned channels (files from these channels will be rejected)
+BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (space-separated IDs; empty = none; non-integer fails boot)
+
+# Multiple bot tokens: MULTI_TOKEN1, MULTI_TOKEN2, ... (any MULTI_TOKEN* prefix,
+# sorted by numeric suffix; empty/whitespace values ignored).
+MULTI_TOKEN1=""
+
+## ACCESS GATES
+
+# Allowlist mode. When True only OWNER_ID + authorized users can use
+# the bot (default False = public). Boot log states the active gate mode.
+# Do not enable both PRIVATE_MODE and TOKEN_ENABLED — boot refuses
+# (token activation links are unreachable under private-mode).
+PRIVATE_MODE="False"
+
+# Token system: require activation before use (True/False)
+TOKEN_ENABLED="False"
+
+# Token validity in hours (min 1)
+TOKEN_TTL_HOURS="24"
+
+## URL SHORTENER SETTINGS
+
+# Both URL_SHORTENER_SITE + URL_SHORTENER_API_KEY are required when either
+# SHORTEN_* is True; otherwise a warning is logged and links are not shortened.
+# Enable URL shortening for tokens (True/False)
+SHORTEN_ENABLED="False"
+
+# Enable URL shortening for media links (True/False)
+SHORTEN_MEDIA_LINKS="False"
+
+# URL Shortener
+URL_SHORTENER_API_KEY="" # Example: "abc123def456"
+URL_SHORTENER_SITE="" # Example: "example.com"
+
+## GLOBAL RATE LIMITING SETTINGS
+
+# Enable global rate limiting (True/False)
+GLOBAL_RATE_LIMIT="False"
+
+# Max requests across all users, per minute (min 1)
+MAX_GLOBAL_REQUESTS_PER_MINUTE=4
+
+# Optional explicit global RPS for the token-bucket breaker.
+# 0 = derive from MAX_GLOBAL_REQUESTS_PER_MINUTE (burst = 2x rate).
+GLOBAL_RPS_LIMIT=0
+
+## RATE LIMITING SETTINGS
+
+# Enable rate limiting (True/False)
+RATE_LIMIT_ENABLED="False"
+
+MAX_FILES_PER_PERIOD=2 # (min 1)
+
+# Window, minutes (min 1)
+RATE_LIMIT_PERIOD_MINUTES=1
+
+# Max queued requests (min 1)
+MAX_QUEUE_SIZE=100
+
+# Queue executor workers, processed concurrently (1-32)
+EXECUTOR_WORKERS=5
+
+# Worker pools (1-16 each)
+BROADCAST_WORKERS=4
+BATCH_WORKERS=5
+
+## STREAM SERVER SETTINGS
+
+# Max concurrent streams per Telegram client (min 1); extra requests get
+# 503 + Retry-After instead of queueing on an overloaded client.
+MAX_CONCURRENT_STREAMS=8
+
+## FILE LIFECYCLE
+
+# Expire file records after N days of inactivity (TTL index, 0-3650); re-upload recreates.
+# 0 = keep forever.
+FILE_TTL_DAYS=0
+
+# Touch-buffer flush cadence + cap (bounded memory)
+TOUCH_FLUSH_SECONDS=3 # 1-60
+TOUCH_BUFFER_MAX=1000 # min 100
+
+## SECURITY SWITCHES
+
+# /shell command is DISABLED by default. Set True only on trusted
+# deployments; it stays owner-only regardless.
+ENABLE_SHELL="False"
+
+# Legacy /watch/<6-char-hash> URLs. Default True keeps historical links
+# working; False forces the canonical /f// family (legacy URLs 410).
+ENABLE_LEGACY_LINKS="True"
+
+## UPDATE SETTINGS
+
+# Git repository for updates (read by update.py; best-effort — guards skip
+# silently, never hard-fail boot; passed to `git pull --ff-only` as an argv
+# element, never through a shell)
+UPSTREAM_REPO="" # Example: "https://github.com/fyaz05/FileToLink" (empty = skip self-update)
+
+# Branch to update from (read by update.py; best-effort, never hard-fails boot)
+UPSTREAM_BRANCH="main" # Default branch for updates
+
+## OBSERVABILITY
+
+# Log level (DEBUG/INFO/WARNING/ERROR) and format (plain/json).
+# /log uploads are redacted (bot tokens, Mongo URIs) before leaving.
+# Unknown LOG_LEVEL falls back to INFO; unknown LOG_FORMAT falls back to plain.
+# env-only (Docker -e / systemd); config.env cannot set this — snapshots before config load
+LOG_LEVEL="INFO"
+LOG_FORMAT="plain"
+
+# Wall-clock budget (seconds) for lightweight Telegram RPCs (min 0).
+# File transfers (copy/upload/stream) are unbounded by default.
+TG_RPC_TIMEOUT_SECONDS=30
+
+## ADVANCED SETTINGS (modify with caution)
+
+# Mongo database name
+# This selects the database. It is NOT the bot's display name -- the banner
+# prints the name from Telegram. Change it and the bot silently serves a
+# different, empty database; the path in DATABASE_URL has no effect.
+NAME="ThunderF2L"
+
+# Performance settings
+SLEEP_THRESHOLD=10 # pyrofork auto-sleep ceiling for FloodWait (s, min 0); keep below TG_RPC_TIMEOUT_SECONDS
+WORKERS=8 # Number of worker processes (1-64)
+
+# Web server configuration
+BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces
+PING_INTERVAL=840 # Ping interval in seconds (health check based, min 30)
+
+# Keepalive probe target override: only set when /health lives on a different
+# host/interface. PaaS anti-sleep: point it at your PUBLIC URL (e.g.
+# your-app.onrender.com) so the periodic ping generates external traffic.
+# A host with no scheme is probed over https on 443; include a scheme to override.
+#KEEPALIVE_HOST="127.0.0.1"
+
+# Build-time version stamp (optional; injected by CI, safe to leave unset).
+# env-only (Docker -e / systemd); config.env cannot set this — snapshots before config load
+#APP_VERSION="2.2.0"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..be76410f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+# Local dev stack: app + MongoDB for manual testing.
+# Unit/integration tiers use testcontainers instead; this file is for humans.
+# Needs config.env next to this file (copy config_sample.env first).
+services:
+ mongo:
+ image: mongo:7.0
+ restart: unless-stopped
+ volumes:
+ - mongo-data:/data/db
+
+ app:
+ build: .
+ restart: unless-stopped
+ depends_on:
+ - mongo
+ ports:
+ - "8080:8080"
+ # SIGKILL before shutdown finishes skips the touch flush. The app bounds
+ # teardown at _SHUTDOWN_BUDGET_SECONDS plus a floor per teardown step
+ # (50 s worst case in Thunder/__main__.py); give that room to complete.
+ stop_grace_period: 60s
+ volumes:
+ # /log tails this file; without a volume it dies with the container
+ - app-logs:/app/Thunder/logs
+ # NOTE: every key here lands in the real environment, which outranks
+ # config.env.local -- drop this block if you rely on local overrides
+ env_file:
+ - config.env
+ environment:
+ DATABASE_URL: mongodb://mongo:27017/thunder
+ # BIND_ADDRESS / PORT come from config.env; keep PORT=8080 here.
+
+volumes:
+ mongo-data:
+ app-logs:
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 00000000..87912fba
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,64 @@
+# HTTP API
+
+Base URL: `Var.URL` (built from `FQDN`/`HAS_SSL`/`PORT`/`NO_PORT`). All file
+URLs are capability links — anyone with the URL can download.
+
+## Routes
+
+| Method | Path | Description |
+|---|---|---|
+| `GET`, `HEAD` | `/health` | Liveness, dependency-free (`{"status":"ok"}`, `no-store`) |
+| `GET`, `HEAD` | `/` | Redirect to the project repo |
+| `GET`, `HEAD` | `/status` | Telemetry (`Cache-Control: no-store`, CORS-readable): `{server: {status, version, uptime}, telegram_bot: {username, active_clients, dc_id}, resources: {total_workload, workload_distribution, touch_buffer}}` |
+| `GET` | `/activate/{token}` | Redirects to `https://t.me/?start=`; never consumes (burn happens in `/start`) |
+| `GET`, `HEAD` | `/watch/f/{hash}/{name}` | Player page (20- or 32-hex hash, side-by-side forever) |
+| `GET`, `HEAD` | `/watch/{path}` | Player page (legacy links, name optional; `ENABLE_LEGACY_LINKS=False` → `410`) |
+| `GET`, `HEAD` | `/f/{hash}/{name}` | Byte stream (canonical); `HEAD` holds an admission ticket so probes reflect load |
+| `GET`, `HEAD` | `/{path}` | Byte stream (legacy links; same `410` gate) |
+| `OPTIONS` | `/{path}` | CORS preflight (`200`; path must be non-empty) |
+
+## Link families
+
+- Canonical: `/f/<32-hex>/` (+ `/watch/f/...` player). 20-hex hashes from
+ older uploads validate side-by-side forever.
+- Legacy: `/watch//` (player) and `//`
+ (bytes). Disable via `ENABLE_LEGACY_LINKS=False`.
+- Filenames are slash-normalized (`/` → `_`) then percent-encoded;
+- `?disposition=inline` streams in-browser, anything else (or absent) downloads
+ as `attachment` (`filename` + RFC 5987 `filename*`).
+
+## Ranges (RFC 7233)
+
+`Range: bytes=-` → `206` + `Content-Range`. Oversized ends clamp;
+suffix ranges (`bytes=-N`) serve the tail; full-file `0-(size-1)` normalizes to
+`200` without `Content-Range` (RFC 9110 lets a server ignore a ranges-specifier it
+cannot satisfy, and a `400` would make some players and proxies abandon the
+transfer). Unparseable → `400`; unsatisfiable → `416` + `Content-Range: bytes */`.
+
+## Errors
+
+| Status | When | Headers |
+|---|---|---|
+| `400` | malformed range / token (bad hash shape → `404`) | CORS, `no-store` |
+| `404` | unknown hash, evicted stale record, zero-size file | `no-store`, CORS |
+| `410` | legacy links with `ENABLE_LEGACY_LINKS=False` | `no-store`, CORS |
+| `416` | unsatisfiable range | `Content-Range`, CORS, `no-store` |
+| `500` | unexpected failure (error-id body, no internals) | CORS, `no-store` |
+| `503` | no free client (`Retry-After: 2`) / pool saturated (`Retry-After: 2`) / index, Telegram, or startup brownout (`Retry-After: 5`) | `no-store`, CORS |
+
+Bodies are fixed wordings per site (capacity vs brownout variants; `500`s carry
+a support error-id, never internals) — retries key off status + `Retry-After`,
+never body text. Transient vault/Telegram failures never delete file records
+(only a confirmed absence self-heals to `404`). Mid-stream transport failures
+truncate the body (headers already sent) — resume with `Range`.
+
+## Caching / CORS / robots
+
+- Bytes: `Cache-Control: public, max-age=31536000` + `Accept-Ranges: bytes`.
+ Every error is `no-store` so caches never pin a failure.
+- `Access-Control-Allow-Origin: *` (allowed request headers: `Range, Content-Type`;
+ methods: `GET, HEAD, OPTIONS`; exposed response headers: `Content-Length,
+ Content-Range, Content-Disposition`).
+ No credentials — the link itself is the secret.
+- Player pages: `` +
+ `X-Robots-Tag: noindex, nofollow`.
diff --git a/docs/TUNING.md b/docs/TUNING.md
new file mode 100644
index 00000000..a0f307c0
--- /dev/null
+++ b/docs/TUNING.md
@@ -0,0 +1,49 @@
+# Tuning
+
+Start with defaults. Touch knobs only with a measured reason (see
+`scripts/bench.py` for the local harness).
+
+## Capacity rule
+
+Peak Telegram RPC ≈ `active streams × per-stream concurrency`. Size
+`MAX_CONCURRENT_STREAMS` so `clients × cap` fits the host's file descriptors
+and upstream patience — not the other way round.
+
+| Host | EXECUTOR / BATCH / BROADCAST | Streams/client | Notes |
+|---|---|---|---|
+| Small VPS (1 vCPU, 1GB) | `5 / 5 / 4` (defaults) | `8` (default) | Defaults are the small preset |
+| Medium VPS (2 vCPU, 4GB) | `8 / 8 / 6` | `12` | Raise only after watching `FLOOD_WAIT` |
+| Big box (4+ vCPU, 8GB+) | `12 / 10 / 8` | `16` | Watch Mongo `timeoutMS=5000` pressure first |
+
+Scale gradually: floods mean back off, not up. A `FloodWait` storm after a
+raise is the signal to revert, not to retry harder.
+
+## Flood guidance
+
+- Sleeps belong in `tg_call` (bounded, `min(value, 30)`); never sleep a pool
+ worker inline. Sustained floods should surface as 503 + `Retry-After`, not
+ silent stalls — check `Retry-After` headers before raising limits.
+- Keep pyrofork auto-sleep ceiling (`SLEEP_THRESHOLD`) below
+ `TG_RPC_TIMEOUT_SECONDS` so waits surface to `tg_call` instead of hiding
+ inside the library.
+
+## Rate limiting
+
+Enable only under abuse: `RATE_LIMIT_ENABLED` + `MAX_FILES_PER_PERIOD` /
+`RATE_LIMIT_PERIOD_MINUTES`. Prefer the global breaker (`GLOBAL_RPS_LIMIT`,
+burst = 2× rate) for provider-level protection — it shapes instead of
+dropping. Queue UX (wait estimates) is protected behavior; don't tune it away.
+
+## Appendix: validation errors (boot refuses until all are fixed)
+
+| Symptom (log line) | Cause | Fix |
+|---|---|---|
+| `API_ID=0 … must be >= 1` / `is required` | placeholder never set | Set real `API_ID` from my.telegram.org |
+| `OWNER_ID … min 1` / boot refusal | no owner configured | Set `OWNER_ID` (owner checks would match nobody) |
+| `BIN_CHANNEL` / `DATABASE_URL … is required` | missing values | Fill both; links and users need them |
+| `… must be >= / <= …` | knob outside its bound | Read the bound in `config_sample.env`, adjust |
+| `… is not a valid integer/number` | junk in a numeric knob | Use plain digits (no quotes needed, no units) |
+| `PRIVATE_MODE and TOKEN_ENABLED cannot both be enabled` | gates unreachable together | Pick one mode |
+| `Shortener enabled but … missing` (warning) | `SHORTEN_*` on without site + key | Set both or turn the feature off |
+| `FQDN is not set` (warning) | links fall back to bind address | Set public domain/IP for reachable links |
+| `BANNED_CHANNELS=… has non-integer entries` | junk token in the set | Space-separated `-100…` IDs only |
diff --git a/heroku.yml b/heroku.yml
index 24efeb83..8eec25b9 100644
--- a/heroku.yml
+++ b/heroku.yml
@@ -1,3 +1,3 @@
-build:
- docker:
- web: Dockerfile
+build:
+ docker:
+ web: Dockerfile
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..0686ec4e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,82 @@
+[project]
+name = "thunder-filetolink"
+version = "2.2.0"
+description = "FileToLink — Telegram file-to-link streaming bot (pyrofork + aiohttp + MongoDB)"
+requires-python = ">=3.13"
+dependencies = [
+ "aiohttp==3.14.3",
+ # upstream pyrofork was archived 2026-09 -- plan the exit to a maintained fork.
+ # Its speedup extra pulls uvloop + tgcrypto, which ship no Windows wheels, and
+ # declares them without a platform marker -- so ask for the extra only where it
+ # can actually be installed. Same pin on both sides; 7 distinct packages.
+ "pyrofork[speedup]==2.3.69; sys_platform != 'win32'",
+ "pyrofork==2.3.69; sys_platform == 'win32'",
+ "pymongo==4.19.0",
+ "Jinja2==3.1.6",
+ "python-dotenv==1.2.3",
+ "psutil==7.2.2",
+ "uvloop==0.23.0; sys_platform != 'win32'",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4", # pytest-asyncio 1.4 requires >=8.4
+ "pytest-asyncio>=0.24",
+ "pytest-cov>=5.0",
+ "ruff>=0.8",
+ "mypy>=1.13",
+ "bandit>=1.8",
+ "vulture>=2.14",
+ "pip-audit>=2.7",
+ # Integration tier only (tests/integration): real MongoDB via Docker.
+ # >=4.15: tests import testcontainers.community.mongodb (moved in 4.15).
+ "testcontainers[mongodb]>=4.15",
+]
+
+[tool.ruff]
+line-length = 100
+target-version = "py313"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP", "B", "SIM"]
+ignore = [
+ "E501", # long lines: message templates
+ "SIM105", # try/except/pass is idiomatic around best-effort teardown here
+ "UP047", # keep TypeVar generics (readable for this codebase)
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/*" = ["B011", "SIM117"]
+
+[tool.mypy]
+python_version = "3.13"
+ignore_missing_imports = true
+check_untyped_defs = true
+warn_unused_ignores = false
+exclude = ["tests/"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+markers = [
+ "unit: fast, hermetic tests (no network, no Mongo)",
+ "integration: testcontainers-backed tests (opt-in, see tests/integration)",
+]
+addopts = "-m unit"
+# Coverage flags are passed by `make test`/CI so the opt-in integration tier
+# (`pytest -m integration`) runs without the unit tier's --cov-fail-under threshold.
+
+[tool.coverage.run]
+source = ["Thunder"]
+# Only __main__ is excluded: it is the boot/teardown entry point whose runtime
+# needs a real Telegram connection. Bot plugins are deliberately NOT omitted --
+# that entry used to be dead config (no __init__.py made coverage skip them
+# anyway), hiding ~900 statements of bot surface from the gate.
+omit = ["Thunder/__main__.py"]
+
+[tool.vulture]
+min_confidence = 80
+paths = ["Thunder"]
+
+[tool.bandit]
+exclude_dirs = ["tests", ".venv"]
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 00000000..c7be71cf
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,25 @@
+# Render Blueprint: Docker web service. Set the secrets below in the dashboard.
+# No managed MongoDB on Render — point DATABASE_URL at an external cluster.
+services:
+ - type: web
+ name: thunder-filetolink
+ runtime: docker
+ dockerfilePath: ./Dockerfile
+ healthCheckPath: /health
+ envVars:
+ - key: API_ID
+ sync: false
+ - key: API_HASH
+ sync: false
+ - key: BOT_TOKEN
+ sync: false
+ - key: BIN_CHANNEL
+ sync: false
+ - key: OWNER_ID
+ sync: false
+ - key: DATABASE_URL
+ sync: false
+ - key: FQDN
+ sync: false
+ - key: HAS_SSL
+ value: true
diff --git a/requirements.lock b/requirements.lock
new file mode 100644
index 00000000..d359a1fa
--- /dev/null
+++ b/requirements.lock
@@ -0,0 +1,599 @@
+# This file was autogenerated by uv via the following command:
+# uv export --frozen --no-dev --hashes -o requirements.lock
+aiohappyeyeballs==2.7.1 \
+ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
+ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
+ # via aiohttp
+aiohttp==3.14.3 \
+ --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \
+ --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \
+ --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \
+ --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \
+ --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \
+ --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \
+ --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \
+ --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \
+ --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \
+ --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \
+ --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \
+ --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \
+ --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \
+ --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \
+ --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \
+ --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \
+ --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \
+ --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \
+ --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \
+ --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \
+ --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \
+ --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \
+ --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \
+ --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \
+ --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \
+ --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \
+ --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \
+ --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \
+ --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \
+ --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \
+ --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \
+ --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \
+ --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \
+ --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \
+ --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \
+ --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \
+ --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \
+ --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \
+ --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \
+ --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \
+ --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \
+ --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \
+ --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \
+ --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \
+ --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \
+ --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \
+ --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \
+ --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \
+ --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \
+ --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \
+ --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \
+ --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \
+ --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \
+ --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \
+ --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \
+ --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \
+ --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \
+ --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \
+ --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \
+ --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \
+ --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \
+ --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \
+ --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \
+ --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \
+ --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24
+ # via thunder-filetolink
+aiosignal==1.4.0 \
+ --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
+ --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
+ # via aiohttp
+attrs==26.1.0 \
+ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
+ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
+ # via aiohttp
+dnspython==2.8.0 \
+ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
+ --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
+ # via pymongo
+frozenlist==1.8.0 \
+ --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
+ --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
+ --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
+ --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
+ --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
+ --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
+ --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
+ --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
+ --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
+ --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
+ --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
+ --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
+ --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
+ --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
+ --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
+ --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
+ --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
+ --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
+ --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
+ --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
+ --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
+ --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
+ --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
+ --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
+ --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
+ --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
+ --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
+ --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
+ --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
+ --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
+ --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
+ --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
+ --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
+ --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
+ --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
+ --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
+ --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
+ --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
+ --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
+ --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
+ --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
+ --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
+ --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
+ --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
+ --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
+ --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
+ --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
+ --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
+ --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
+ --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
+ --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
+ --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
+ --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
+ --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
+ --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
+ --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
+ --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
+ --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
+ --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
+ --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
+ --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
+ --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
+ --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
+ --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
+ --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
+ --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027
+ # via
+ # aiohttp
+ # aiosignal
+idna==3.19 \
+ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
+ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
+ # via yarl
+jinja2==3.1.6 \
+ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
+ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
+ # via thunder-filetolink
+markupsafe==3.0.3 \
+ --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
+ --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
+ --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
+ --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
+ --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
+ --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
+ --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
+ --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
+ --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
+ --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
+ --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
+ --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
+ --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
+ --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
+ --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
+ --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
+ --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
+ --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
+ --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
+ --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
+ --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
+ --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
+ --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
+ --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
+ --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
+ --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
+ --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
+ --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
+ --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
+ --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
+ --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
+ --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
+ --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
+ --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
+ --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
+ --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
+ --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
+ --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
+ --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
+ --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
+ --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
+ --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
+ --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
+ --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
+ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
+ # via jinja2
+multidict==6.9.1 \
+ --hash=sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c \
+ --hash=sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9 \
+ --hash=sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201 \
+ --hash=sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382 \
+ --hash=sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f \
+ --hash=sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919 \
+ --hash=sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418 \
+ --hash=sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771 \
+ --hash=sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c \
+ --hash=sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76 \
+ --hash=sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2 \
+ --hash=sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9 \
+ --hash=sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1 \
+ --hash=sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64 \
+ --hash=sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5 \
+ --hash=sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132 \
+ --hash=sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b \
+ --hash=sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba \
+ --hash=sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b \
+ --hash=sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08 \
+ --hash=sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879 \
+ --hash=sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f \
+ --hash=sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9 \
+ --hash=sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd \
+ --hash=sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b \
+ --hash=sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709 \
+ --hash=sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4 \
+ --hash=sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce \
+ --hash=sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94 \
+ --hash=sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e \
+ --hash=sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1 \
+ --hash=sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e \
+ --hash=sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455 \
+ --hash=sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3 \
+ --hash=sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471 \
+ --hash=sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d \
+ --hash=sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b \
+ --hash=sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f \
+ --hash=sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4 \
+ --hash=sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14 \
+ --hash=sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3 \
+ --hash=sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194 \
+ --hash=sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a \
+ --hash=sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3 \
+ --hash=sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828 \
+ --hash=sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290 \
+ --hash=sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485 \
+ --hash=sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e \
+ --hash=sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8 \
+ --hash=sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c \
+ --hash=sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b \
+ --hash=sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659 \
+ --hash=sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b \
+ --hash=sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9 \
+ --hash=sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c \
+ --hash=sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94 \
+ --hash=sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983 \
+ --hash=sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d \
+ --hash=sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac \
+ --hash=sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7 \
+ --hash=sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45 \
+ --hash=sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40 \
+ --hash=sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d \
+ --hash=sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa \
+ --hash=sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701 \
+ --hash=sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6 \
+ --hash=sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40 \
+ --hash=sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20 \
+ --hash=sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694 \
+ --hash=sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a \
+ --hash=sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519 \
+ --hash=sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125 \
+ --hash=sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d \
+ --hash=sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a \
+ --hash=sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea \
+ --hash=sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c \
+ --hash=sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3 \
+ --hash=sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d \
+ --hash=sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3 \
+ --hash=sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68 \
+ --hash=sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca \
+ --hash=sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8 \
+ --hash=sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840 \
+ --hash=sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610 \
+ --hash=sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca \
+ --hash=sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1 \
+ --hash=sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5 \
+ --hash=sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a \
+ --hash=sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05 \
+ --hash=sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1 \
+ --hash=sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2 \
+ --hash=sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82 \
+ --hash=sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d \
+ --hash=sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f \
+ --hash=sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67 \
+ --hash=sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3 \
+ --hash=sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800 \
+ --hash=sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8 \
+ --hash=sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c \
+ --hash=sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a \
+ --hash=sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841 \
+ --hash=sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec \
+ --hash=sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721 \
+ --hash=sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d \
+ --hash=sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390 \
+ --hash=sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1 \
+ --hash=sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d \
+ --hash=sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e \
+ --hash=sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df \
+ --hash=sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5 \
+ --hash=sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9
+ # via
+ # aiohttp
+ # yarl
+propcache==0.5.2 \
+ --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
+ --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
+ --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
+ --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
+ --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
+ --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
+ --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
+ --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
+ --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
+ --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
+ --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
+ --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
+ --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
+ --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
+ --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
+ --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
+ --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
+ --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
+ --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
+ --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
+ --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
+ --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
+ --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
+ --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
+ --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
+ --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
+ --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
+ --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
+ --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
+ --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
+ --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
+ --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
+ --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
+ --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
+ --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
+ --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
+ --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
+ --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
+ --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
+ --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
+ --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
+ --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
+ --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
+ --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
+ --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
+ --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
+ --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
+ --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
+ --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
+ --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
+ --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
+ --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
+ --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
+ --hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
+ --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
+ --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
+ --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
+ --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
+ --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
+ --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
+ --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
+ --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
+ --hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
+ --hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
+ --hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
+ --hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
+ --hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
+ --hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
+ --hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
+ --hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
+ # via
+ # aiohttp
+ # yarl
+psutil==7.2.2 \
+ --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \
+ --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \
+ --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \
+ --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \
+ --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \
+ --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \
+ --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \
+ --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \
+ --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \
+ --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \
+ --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \
+ --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \
+ --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \
+ --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \
+ --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \
+ --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \
+ --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \
+ --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \
+ --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \
+ --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \
+ --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8
+ # via thunder-filetolink
+pyaes==1.6.1 \
+ --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
+ # via pyrofork
+pymediainfo-pyrofork==6.0.2 \
+ --hash=sha256:674fa8e53de861635b9dc4f77c2ad712306a798bf28864952503bf328210c4c3 \
+ --hash=sha256:fce9402edfd1fa09aba7b3cac4c41ba7fcf6820e561b4db4f9c1a1a68c487c36
+ # via pyrofork
+pymongo==4.19.0 \
+ --hash=sha256:06b9ee12c4ceb7fb6ff8a7ab0465814c1cb5e5c6c2c452cb18eab7435b38a5b2 \
+ --hash=sha256:08c354566ab8b5dce6d805f35d61b5575455d3ea1835d7b90151d53e8c32e669 \
+ --hash=sha256:1435721737b46be9bab5aa2374cfe57de934dc4ac421d5473308aa94c9fa39c3 \
+ --hash=sha256:179bc536b73fc76ae3d227114123ffc804f002fb45ddd996a81b233e806a0d2d \
+ --hash=sha256:212dbc97f8e813a24639aaaef38503d84f7652d00b88b391f87762ba4c1f1709 \
+ --hash=sha256:24668c6990bef96e1558328ba0802279cc1f752a3bcc7b283c2f39099a01e28c \
+ --hash=sha256:2bb0e7c422c14ff2b31ec8be3e6ecaad326c17fca17071bcfcd13482584a8e0f \
+ --hash=sha256:2f5719dfbb5527a55dfaf6a68164df118efc13fffd00bc2ee9231488c1e8e03a \
+ --hash=sha256:2faa34469b052635c81dcec6b07fc5757d4aba0ec60f94c6658c7fa6f887bc46 \
+ --hash=sha256:3af5ab5a9e490580d3f40660665f0f4d579a324e25acee6372e1508e4b7c7b7a \
+ --hash=sha256:3bcebec2536a9aec1d490ad6fa9fc7ffc3329059fb1f99154efa5d594abdc98c \
+ --hash=sha256:3c3a47a6b325ac605352e9825ef658e6cca4f612e3a09838a564859f7d5435ea \
+ --hash=sha256:3c510dd3c5d9b392d3b33bb5d2a594758acfe8f026fca654253f947ce0af9d40 \
+ --hash=sha256:47f04522f786dca82c776d5c3ed3ff9d08d6bf4cd0074c42296da5fac4d816ad \
+ --hash=sha256:4d199721ab77c83a7da83fcd219d3b819c559d8133e66c0d9bec9408001649f7 \
+ --hash=sha256:542b0f4e47fe68e753c85503f8352d4baa81ac73593601c8ede0fa22ba5c0431 \
+ --hash=sha256:546350d196b01b7feff7f8e6d140b6d4ab47486d5ae70dab858605cdfc2ffe1d \
+ --hash=sha256:54877c8e89add9ed115316722ead430d422b95d475b4eb57663bc6e017587853 \
+ --hash=sha256:567e509e1e01c956bfd5e60805b7d582aae45eeba34e9690d0da6f09560afb4f \
+ --hash=sha256:5d684e289cdb687f1508b15a44d3c0268f974c92ba129f658c1ef1fd196854e7 \
+ --hash=sha256:6e65783e95b37c3387ed1105fe01e2be6b1b394c22331c5e8cc2fed2c3a30a06 \
+ --hash=sha256:701c4a102c8794a1f656ff9c06ec9269276fb5f62c268359ee68d46163655b68 \
+ --hash=sha256:823f8b2fb59e4e635e296d5e92efa883e3d01a8faa477d515fc9dfe515368026 \
+ --hash=sha256:8a38cfd2d81daef820a099c28065c6dc2ec9254ae80fefcf7981ea27e5381159 \
+ --hash=sha256:8d866560dfbe44bc5e1110e96af4b8d92ffe6368c345dac1c36c8060188ebba6 \
+ --hash=sha256:9bf359a18df79981ea775b90c4c1fa044480b8896c0ff45932e568b0aed6a9eb \
+ --hash=sha256:9dee18feff3203fa128798c6673c7795ef8a46d0b32c0e6b920c7b3f46129447 \
+ --hash=sha256:a4bd5e3ecd44d94b4eeef51f7e20a513206f2fceeab9534e9299c31133cc2e42 \
+ --hash=sha256:ab0167d3c99a33a119befa93f1771ef0436832275ed6fd95c68b2535dae3f2e7 \
+ --hash=sha256:ac55cf643eaa6146822f5f05f07be4dedbed906f525bb2ee098a865c4892788a \
+ --hash=sha256:ac673404456b23c568cea326ab996a6b35a6009e41d42bcb774db025d0918b7d \
+ --hash=sha256:ae2eb0a729de0b009de52b76003e4f1f19fd28cda88ec7a81c51faf90dd1587b \
+ --hash=sha256:b01cc054878931ea81fc0a57c4c10489db723b8d7275fb10070f7228149012f1 \
+ --hash=sha256:b602baef46ec5cd876fdf45dfdf864a58f5a507129393b93b8248249008f9a70 \
+ --hash=sha256:b7e8b5b546e31ac63255650b0bf764383885a6c657b3269e83b9e1e5de3ed129 \
+ --hash=sha256:b92aa4cc4b0bf67a18e3c73062ef70e00ca6921c742aa4d0f4770a493193c661 \
+ --hash=sha256:be75840640e98ea4b5f150bceda8a55f1085e395732e21da028195da30ae79b5 \
+ --hash=sha256:bfcb5f8912edd9714a52564ad41c0dcd72e5408d1d3d67b41f6145df4a516318 \
+ --hash=sha256:cc81d7ceeb7766254bce7ad7644dddb44241fb57555cd7c71de305b6903493b8 \
+ --hash=sha256:d29ea47eebbeec81b67809fbb3440ffc53628d28f5b9f21624eed0038d9fddaa \
+ --hash=sha256:df57b703b0b07c35860da7b214735b7750b2f2a5288f296dc08eeaf10cf8c46a \
+ --hash=sha256:e7204210e9a613aef743b9c7a2e1f07406c21090b61b9338e3d96bb8b2b14b36 \
+ --hash=sha256:e8e44c4229cfe7e36fc5772b2c4c2d273b141bf9a212829ad5b0cc402efcd629 \
+ --hash=sha256:ec25ab536e42e48fde356c6fc86e66f548e5af0cc584365e2ec34d3683be5a63 \
+ --hash=sha256:eececca812e8f5b3c12ad33dc90201ac20f5f193da446f7719f4321a0841387b \
+ --hash=sha256:eee3fc70ea4253c8c7a6bd7917be468c5ef0a2860898766dd55497a563ddda94 \
+ --hash=sha256:f17b100fdc16b65c12997ec4fcc78eecc0a6395254c7ec92a4596e855ff1f33a \
+ --hash=sha256:f21109534f5555cf77689ad323a21fbc07e8a397b34f157938a347725d83b7b5 \
+ --hash=sha256:f3264b209b6319cae120306e266ed5fa9c7bc071b73ba5e13cbad23a6cbd73d2 \
+ --hash=sha256:fa39c6ddaf987a48ef073ff7fc225b84282079a46fbabaea9c5fcb6f89476e44 \
+ --hash=sha256:fb9d9bff4f666405cd9d7a17b6127294394847dce60ca38d8ba45f4879ada6c9
+ # via thunder-filetolink
+pyrofork==2.3.69 \
+ --hash=sha256:13f7a7fbfa5ede230df6b6df10fcc2c6b33b4c3d75bf2088a7d32f41621df8e4 \
+ --hash=sha256:945b30d50b31819a903749825e2748ac5a6af1e073bf97da8c53e510ff3ed58d
+ # via thunder-filetolink
+pysocks==1.7.1 \
+ --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \
+ --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0
+ # via pyrofork
+python-dotenv==1.2.3 \
+ --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \
+ --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35
+ # via thunder-filetolink
+tgcrypto-pyrofork==1.2.8 ; sys_platform != 'win32' \
+ --hash=sha256:106317b2c42cc5fcd7475a50647fee2da304076cdfcd2444f72d5254927b2afa \
+ --hash=sha256:12d237eb8de98fa759df97bdb988dd6f60702c3376dbf16d2babdd2196bfb58a \
+ --hash=sha256:1f28c07ee6c0ef423b3ff14562881f3bbcb6614d5394f378526f32a109650e24 \
+ --hash=sha256:1ff578ac8b54607e6d536f9593f78d8bea9b99f2e607ea4bd71b1b2a3a5f949c \
+ --hash=sha256:36a71e5cbd14f3226803a2d05c0d3d43e0781565a895d40681ef82410398d950 \
+ --hash=sha256:82bd2e8f249eaef92132ce5a310c27844e9fbb43666e5bfbf6dd1872c1c2eda2 \
+ --hash=sha256:9b1538e0c14d2aee1b9dc72cddfd7706d2e4ea768addecc3f12ff8d44f38d3e1 \
+ --hash=sha256:ae1a23ed300786e28e8d9c2024effba7efc732d99fd8a2db314c02c35355f01f \
+ --hash=sha256:bc888db2675247a1e3d9040577e025d64b66b72702223030b0e18ed10037b99e \
+ --hash=sha256:c50a8ddd8e5256528f8318bcafbe1f59f1cc1c300db0ee16ec49955baead861c \
+ --hash=sha256:d3441ec567f9411ffbe5182fd4fb8c49fbd12faccd71c50fc469b9784e15b04d \
+ --hash=sha256:d4886fa409c891e129c6ab439542e0b80b001b31bcefac63509340b0c691f73b \
+ --hash=sha256:fe3d75abef53bfbfa6e80dc6d25075f603f3ebe1dafa9d5c09b2780e0ea3a382
+ # via pyrofork
+uvloop==0.23.0 ; sys_platform != 'win32' \
+ --hash=sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4 \
+ --hash=sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc \
+ --hash=sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5 \
+ --hash=sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb \
+ --hash=sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f \
+ --hash=sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27 \
+ --hash=sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65 \
+ --hash=sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330 \
+ --hash=sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55 \
+ --hash=sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8 \
+ --hash=sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f \
+ --hash=sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027 \
+ --hash=sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254 \
+ --hash=sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d \
+ --hash=sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e \
+ --hash=sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405 \
+ --hash=sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f \
+ --hash=sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507 \
+ --hash=sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208 \
+ --hash=sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd \
+ --hash=sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49 \
+ --hash=sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476 \
+ --hash=sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53 \
+ --hash=sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a \
+ --hash=sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848 \
+ --hash=sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5 \
+ --hash=sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb \
+ --hash=sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d \
+ --hash=sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410 \
+ --hash=sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2 \
+ --hash=sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f
+ # via
+ # pyrofork
+ # thunder-filetolink
+yarl==1.24.5 \
+ --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
+ --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
+ --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
+ --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
+ --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
+ --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
+ --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
+ --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
+ --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
+ --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
+ --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
+ --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
+ --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
+ --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
+ --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
+ --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
+ --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
+ --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
+ --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
+ --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
+ --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
+ --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
+ --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
+ --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
+ --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
+ --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
+ --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
+ --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
+ --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
+ --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
+ --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
+ --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
+ --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
+ --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
+ --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
+ --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
+ --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
+ --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
+ --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
+ --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
+ --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
+ --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
+ --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
+ --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
+ --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
+ --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
+ --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
+ --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
+ --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
+ --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
+ --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
+ --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
+ --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104
+ # via aiohttp
diff --git a/requirements.txt b/requirements.txt
index 0ab99f4f..8f01f0cf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
-aiohttp
-cloudscraper
-Jinja2
-pyrofork
-pymongo
-psutil
-python-dotenv
-speedtest-cli
-tgcrypto
-uvloop==0.21.0
+# Human-readable export of pyproject [project.dependencies] (H1); CI keeps this
+# set-equal to pyproject. Docker consumes requirements.lock (hash-pinned uv.lock export).
+aiohttp==3.14.3
+pyrofork[speedup]==2.3.69; sys_platform != 'win32'
+pyrofork==2.3.69; sys_platform == 'win32'
+pymongo==4.19.0
+Jinja2==3.1.6
+python-dotenv==1.2.3
+psutil==7.2.2
+uvloop==0.23.0; sys_platform != 'win32'
diff --git a/scripts/bench.py b/scripts/bench.py
new file mode 100644
index 00000000..c20aafa6
--- /dev/null
+++ b/scripts/bench.py
@@ -0,0 +1,67 @@
+"""Throughput micro-benchmarks (hermetic: no network, no Mongo, no Telegram).
+
+Not wired into CI — numbers vary by machine. Run from the repo root:
+
+ .venv\\Scripts\\python.exe scripts\\bench.py
+
+Compares hot-path costs across refactors; absolute values matter less than
+ratios. Keep each bench under a few seconds.
+"""
+
+import asyncio
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+os.environ.update(
+ {
+ "API_ID": "1",
+ "API_HASH": "x" * 32,
+ "BOT_TOKEN": "1:abc",
+ "BIN_CHANNEL": "-1001",
+ "OWNER_ID": "1",
+ "DATABASE_URL": "mongodb://localhost:27017/x",
+ "THUNDER_SKIP_CONFIG_FILES": "1",
+ }
+)
+
+from Thunder.server.stream_routes import parse_range_header, validate_public_hash # noqa: E402
+from Thunder.utils.human_readable import humanbytes # noqa: E402
+from Thunder.utils.render_template import render_media_page # noqa: E402
+
+
+def bench(name, fn, iters=20000):
+ fn() # warmup
+ start = time.perf_counter()
+ for _ in range(iters):
+ fn()
+ dt = time.perf_counter() - start
+ print(f"{name:28s} {iters / dt:12,.0f} ops/s")
+ return iters / dt
+
+
+def main() -> None:
+ print("Thunder micro-benchmarks (hermetic)")
+ bench("parse_range_header", lambda: parse_range_header("bytes=0-1048575", 10 * 1024 * 1024))
+ bench("validate_public_hash", lambda: validate_public_hash("a" * 32))
+ bench("humanbytes", lambda: humanbytes(10 * 1024 * 1024), iters=50000)
+
+ async def _page():
+ return await render_media_page(
+ "v.mp4", "https://h/f/x/v.mp4", mime_type="video/mp4", size_bytes=1024
+ )
+
+ async def _pages(n=200):
+ for _ in range(n):
+ await _page()
+
+ start = time.perf_counter()
+ asyncio.run(_pages())
+ dt = time.perf_counter() - start
+ print(f"{'render_media_page':28s} {200 / dt:12,.0f} ops/s")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100644
index 00000000..aa46cd53
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Fast developer loop.
+#
+# Why this exists: importing pyrogram costs ~2.8s and happens on EVERY pytest
+# run, so there is a hard floor of ~3.8s per invocation no matter how few
+# tests you select. Measured on a Windows host with the dev group synced:
+#
+# gates 1.5s ruff + ruff-format + mypy, no pytest at all
+# file 4.0s one test file (or one node with "::")
+# quick 6.8s the whole unit tier, no coverage
+# test 10.7s the whole tier plus the coverage gate
+#
+# The expensive part is running the wrong command, not the tool being slow.
+# Iterate with `gates` and `file`; run `quick`/`test` ONCE per commit.
+#
+# scripts/dev.sh gates
+# scripts/dev.sh file tests/test_unit/test_batch_chunking.py
+# scripts/dev.sh file 'tests/test_unit/test_batch_chunking.py::test_x'
+# scripts/dev.sh quick
+# scripts/dev.sh test
+# scripts/dev.sh all # what CI runs, minus the slow audits
+#
+# Equivalent to the Makefile targets, for hosts without `make`.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+gates() {
+ uv run ruff check Thunder/ update.py tests/
+ uv run ruff format --check Thunder/ update.py tests/
+ uv run mypy Thunder update.py --ignore-missing-imports
+}
+
+case "${1:-}" in
+ format)
+ uv run ruff check Thunder/ update.py tests/ --fix
+ uv run ruff format Thunder/ update.py tests/ ;;
+ audit)
+ uv run pip-audit
+ uv run bandit -c pyproject.toml -r Thunder update.py -ll --skip B101
+ uv run vulture Thunder update.py --min-confidence 80 ;;
+ gates) gates ;;
+ file)
+ [ $# -eq 2 ] || { echo "usage: dev.sh file " >&2; exit 2; }
+ uv run pytest -m unit -q --no-cov "$2" ;;
+ quick) uv run pytest -m unit -q --no-cov ;;
+ test) uv run pytest -m unit -q --cov=Thunder --cov-report=term-missing --cov-fail-under=35 ;;
+ all) gates; uv run pytest -m unit -q --cov=Thunder --cov-report=term-missing --cov-fail-under=35 ;;
+ *) sed -n '3,21p' "$0"; echo >&2; echo "pick a subcommand (there is no default)" >&2; exit 2 ;;
+esac
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..210eb44f
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,65 @@
+"""Bootstrap a valid fake environment BEFORE any Thunder import."""
+
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+# Hard overrides (not setdefault): must beat any CI/host-leaked env for hermeticity.
+_required = {
+ "API_ID": "1234567",
+ "API_HASH": "test-hash",
+ "BOT_TOKEN": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "BIN_CHANNEL": "-1001234567890",
+ "OWNER_ID": "42",
+ "DATABASE_URL": "mongodb://localhost:27017/thunder_test",
+ "FQDN": "example.com",
+ "NO_PORT": "True",
+ "LOG_LEVEL": "WARNING",
+ "PRIVATE_MODE": "False",
+ "TOKEN_ENABLED": "False",
+ "SHORTEN_ENABLED": "False",
+ "SHORTEN_MEDIA_LINKS": "False",
+ "FILE_TTL_DAYS": "0",
+ "RATE_LIMIT_ENABLED": "False",
+ "GLOBAL_RATE_LIMIT": "False",
+}
+for _key, _value in _required.items():
+ os.environ[_key] = _value
+
+# Hermeticity: never read the developer's config.env in-process (optional knobs
+# like PRIVATE_MODE would leak into assertions); subprocess precedence tests opt back out.
+os.environ["THUNDER_SKIP_CONFIG_FILES"] = "1"
+
+
+@pytest.fixture(autouse=True)
+def _restore_singletons():
+ """Snapshot mutable singletons so one test's tuning can't leak into the next."""
+ import copy
+
+ # lazy imports: avoid import cycles at conftest import time
+ import Thunder.utils.rate_limiter as _rl_mod
+ import Thunder.vars as _vars_mod
+
+ _rl = _rl_mod.rate_limiter
+ _saved_rl_dict = copy.copy(_rl.__dict__)
+ _saved_user_requests = {k: copy.copy(v) for k, v in _rl.user_requests.items()}
+ _saved_global_requests = copy.copy(_rl.global_requests)
+ _saved_breaker_dict = copy.copy(_rl.breaker.__dict__)
+ _saved_errors_len = len(_vars_mod._config_errors)
+ try:
+ yield
+ finally:
+ _rl.__dict__.clear()
+ _rl.__dict__.update(_saved_rl_dict)
+ _rl.user_requests.clear()
+ _rl.user_requests.update(_saved_user_requests)
+ _rl.global_requests.clear()
+ _rl.global_requests.extend(_saved_global_requests)
+ _rl.breaker.__dict__.clear()
+ _rl.breaker.__dict__.update(_saved_breaker_dict)
+ del _vars_mod._config_errors[_saved_errors_len:]
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/test_mongo.py b/tests/integration/test_mongo.py
new file mode 100644
index 00000000..e5765675
--- /dev/null
+++ b/tests/integration/test_mongo.py
@@ -0,0 +1,163 @@
+"""Real-MongoDB tier via testcontainers; opt in with `pytest -m integration` (needs Docker)."""
+
+import os
+from datetime import UTC
+
+import pytest
+
+pytestmark = pytest.mark.integration
+
+docker_unavailable = True
+
+try: # pragma: no cover - environment-dependent
+ # source: testcontainers>=4.15 moved the MongoDB container here (see pyproject dev group)
+ from testcontainers.community.mongodb import MongoDbContainer as MongoContainer
+
+ docker_unavailable = False
+except ImportError:
+ MongoContainer = None
+
+
+@pytest.fixture(scope="module")
+def mongo_container():
+ # Opt-in is checked first, and a missing testcontainers install is an ERROR
+ # rather than a skip: silently skipping would let CI go green with zero
+ # integration coverage if the dev group ever fails to resolve.
+ if os.getenv("TEST_INTEGRATION") != "1":
+ pytest.skip("integration tier disabled (set TEST_INTEGRATION=1 with Docker)")
+ if docker_unavailable:
+ pytest.fail("TEST_INTEGRATION=1 but testcontainers is not installed")
+ with MongoContainer("mongo:7") as mongo:
+ yield mongo
+
+
+@pytest.fixture(scope="module")
+def db(mongo_container):
+ import Thunder.utils.database as database_module
+ import Thunder.utils.tokens as tokens_module
+
+ # Rebind a fresh Database: unit-tier imports cache Thunder.vars in sys.modules
+ # and `from ... import db` copies froze the old instance in every consumer.
+ fresh = database_module.Database(mongo_container.get_connection_url(), "thunder_test")
+ original = database_module.db
+ database_module.db = fresh
+ tokens_module.db = fresh
+ try:
+ yield fresh
+ finally:
+ database_module.db = original
+ tokens_module.db = original
+
+
+async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover
+ assert await db.ensure_indexes(raise_on_error=True) is True
+
+ # atomic activation -- two concurrent consume() calls, one winner
+ import asyncio
+ from datetime import datetime, timedelta
+
+ from Thunder.utils.tokens import consume
+
+ token = "integration-token-1"
+ await db.token_col.insert_one(
+ {
+ "token": token,
+ "user_id": 424242,
+ "activated": False,
+ "created_at": datetime.now(UTC),
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ )
+ results = await asyncio.gather(consume(token, 424242), consume(token, 424242))
+ statuses = sorted(status for status, _ in results)
+ assert statuses == ["already", "ok"]
+
+
+async def test_ensure_indexes_ttl_lifecycle( # pragma: no cover
+ mongo_container, monkeypatch
+):
+ """Review item 9 regression: a FILE_TTL_DAYS change between boots must drop+recreate
+ the TTL index without aborting the remaining unique-index ensures, and legacy rows
+ must be stamped with last_seen_at before the index first activates."""
+ from datetime import datetime
+
+ import Thunder.utils.database as database_module
+ import Thunder.vars as vars_module
+
+ var = vars_module.Var
+ ttl_db = database_module.Database(mongo_container.get_connection_url(), "thunder_ttl_test")
+
+ # legacy row predating any TTL index (no last_seen_at field)
+ await ttl_db.files_col.insert_one(
+ {
+ "file_unique_id": "ttl-legacy-1",
+ "public_hash": "f" * 32,
+ "canonical_message_id": -77001,
+ "created_at": datetime.now(UTC),
+ }
+ )
+
+ # first boot with TTL enabled: backfill runs, index is created
+ monkeypatch.setattr(var, "FILE_TTL_DAYS", 1)
+ assert await ttl_db.ensure_indexes(raise_on_error=True) is True
+ info = await ttl_db.files_col.index_information()
+ assert info["last_seen_at_1"]["expireAfterSeconds"] == 86400
+ row = await ttl_db.files_col.find_one({"file_unique_id": "ttl-legacy-1"})
+ assert "last_seen_at" in row, "legacy row must be stamped before the TTL index activates"
+
+ # second boot with a CHANGED TTL: recreate must not abort the uniques
+ monkeypatch.setattr(var, "FILE_TTL_DAYS", 2)
+ assert await ttl_db.ensure_indexes(raise_on_error=True) is True
+ info = await ttl_db.files_col.index_information()
+ assert info["last_seen_at_1"]["expireAfterSeconds"] == 2 * 86400
+ for unique_index in ("file_unique_id_1", "public_hash_1", "canonical_message_id_1"):
+ assert unique_index in info, f"unique index {unique_index} must still be ensured"
+
+ # third boot with the SAME TTL: steady state, still green
+ assert await ttl_db.ensure_indexes(raise_on_error=True) is True
+
+ await ttl_db.close()
+
+
+# slow: TTL-monitor ~60s (polls up to 90s for Mongo's ~60s TTL pass)
+async def test_file_ttl_actually_expires_rows( # pragma: no cover
+ mongo_container, monkeypatch
+):
+ """End-to-end TTL semantics: a row whose last_seen_at is older than the
+ window must eventually disappear once the index is active."""
+ from datetime import datetime, timedelta
+
+ import Thunder.utils.database as database_module
+ import Thunder.vars as vars_module
+
+ var = vars_module.Var
+ ttl_db = database_module.Database(
+ mongo_container.get_connection_url(), "thunder_ttl_expire_test"
+ )
+ await ttl_db.files_col.insert_one(
+ {
+ "file_unique_id": "ttl-doomed-1",
+ "public_hash": "e" * 32,
+ "canonical_message_id": -77002,
+ "created_at": datetime.now(UTC),
+ "last_seen_at": datetime.now(UTC) - timedelta(days=30),
+ }
+ )
+
+ monkeypatch.setattr(var, "FILE_TTL_DAYS", 1)
+ assert await ttl_db.ensure_indexes(raise_on_error=True) is True
+
+ # Mongo's TTL monitor runs roughly once a minute; poll for up to 90s.
+ deadline = datetime.now(UTC) + timedelta(seconds=90)
+ gone = False
+ while datetime.now(UTC) < deadline:
+ remaining = await ttl_db.files_col.count_documents({"file_unique_id": "ttl-doomed-1"})
+ if remaining == 0:
+ gone = True
+ break
+ import asyncio
+
+ await asyncio.sleep(5)
+ assert gone, "TTL monitor did not expire the aged row in time"
+
+ await ttl_db.close()
diff --git a/tests/integration/test_signal_delivery.py b/tests/integration/test_signal_delivery.py
new file mode 100644
index 00000000..192d1449
--- /dev/null
+++ b/tests/integration/test_signal_delivery.py
@@ -0,0 +1,59 @@
+"""SIGTERM must actually reach a live interpreter and let it run its finally block.
+
+The unit tier can only assert that thunder.sh says ``exec``. This proves the
+other half of the chain for real: pyrofork's ``idle()`` installs a SIGTERM
+handler, so a container stop reaches the app's graceful teardown instead of
+being ignored by PID 1.
+
+No Telegram credentials are needed -- idle() only waits for a signal.
+POSIX only (Windows has no SIGTERM); the integration job runs on Linux.
+"""
+
+import os
+import signal
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+pytestmark = pytest.mark.integration
+
+_CHILD = textwrap.dedent(
+ """
+ import asyncio, sys
+ from pyrogram import idle
+
+ async def main():
+ print("ready", flush=True)
+ try:
+ await idle()
+ finally:
+ # this is the shape shutdown_services relies on
+ print("graceful", flush=True)
+
+ asyncio.run(main())
+ """
+)
+
+
+@pytest.mark.integration
+@pytest.mark.skipif(os.name == "nt", reason="POSIX signals only")
+def test_sigterm_reaches_idle_and_runs_the_teardown():
+ proc = subprocess.Popen(
+ [sys.executable, "-c", _CHILD],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ try:
+ assert proc.stdout is not None
+ assert proc.stdout.readline().strip() == "ready", "child never reached idle()"
+ proc.send_signal(signal.SIGTERM)
+ out, err = proc.communicate(timeout=30)
+ except subprocess.TimeoutExpired as e: # pragma: no cover - failure path
+ proc.kill()
+ raise AssertionError("child ignored SIGTERM: PID 1 would not forward it") from e
+
+ assert "graceful" in out, f"teardown did not run; stderr: {err[-500:]}"
+ assert proc.returncode == 0, f"child exited {proc.returncode}: {err[-500:]}"
diff --git a/tests/test_unit/__init__.py b/tests/test_unit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_unit/test_access_log.py b/tests/test_unit/test_access_log.py
new file mode 100644
index 00000000..e1ed94f5
--- /dev/null
+++ b/tests/test_unit/test_access_log.py
@@ -0,0 +1,120 @@
+"""Access-log middleware: path pseudonymization + log-forging escape."""
+
+import pytest
+
+import Thunder.server as server_mod
+from Thunder.server import _escape_control_chars, _redact_path
+
+# 6 alnum + 2 trailing digits: the old id-first rule used to re-match this
+# shape and hash it a second time.
+_FAKE_PSEUDONYM = "ab12cd99"
+
+
+@pytest.fixture(name="fake_hash")
+def _fake_hash(monkeypatch):
+ """Deterministic hash_path_token with a digit-tailed output."""
+ calls: list[str] = []
+
+ def _fake(token: str) -> str:
+ calls.append(token)
+ return _FAKE_PSEUDONYM
+
+ monkeypatch.setattr(server_mod, "hash_path_token", _fake)
+ return calls
+
+
+@pytest.mark.unit
+def test_canonical_pseudonym_hashed_exactly_once(fake_hash):
+ path = "/f/" + "a" * 32 + "/video.mp4"
+ out = _redact_path(path)
+ assert out == f"/f/{_FAKE_PSEUDONYM}/video.mp4"
+ # one input hashed, and the output never re-hashed
+ assert fake_hash == ["a" * 32]
+ assert "…" not in out
+
+
+@pytest.mark.unit
+def test_legacy_watch_segment_suffixed(fake_hash):
+ out = _redact_path("/watch/AbCdEf12345/name.mp4")
+ assert out == f"/watch/{_FAKE_PSEUDONYM}…/name.mp4"
+ assert fake_hash == ["AbCdEf12345"]
+
+
+@pytest.mark.unit
+def test_id_first_segment_suffixed(fake_hash):
+ out = _redact_path("/AbCdEf12345/name.mp4")
+ assert out == f"/{_FAKE_PSEUDONYM}…/name.mp4"
+ assert fake_hash == ["AbCdEf12345"]
+
+
+@pytest.mark.unit
+def test_bare_legacy_segment_suffixed(fake_hash):
+ # Regression: the no-filename legacy URL is served by the
+ # catch-all route; its bare hash+id is the whole link credential
+ out = _redact_path("/AbCdEf12345")
+ assert out == f"/{_FAKE_PSEUDONYM}…"
+ assert fake_hash == ["AbCdEf12345"]
+
+
+@pytest.mark.unit
+def test_bare_id_only_segment_suffixed(fake_hash):
+ # id-first family without filename (hash rides in the query string)
+ out = _redact_path("/12345678")
+ assert out == f"/{_FAKE_PSEUDONYM}…"
+ assert fake_hash == ["12345678"]
+
+
+@pytest.mark.unit
+def test_legacy_20_char_canonical_hash(fake_hash):
+ out = _redact_path("/f/" + "b" * 20 + "/v")
+ assert out == f"/f/{_FAKE_PSEUDONYM}/v"
+ assert fake_hash == ["b" * 20]
+
+
+@pytest.mark.unit
+def test_activation_token_redacted(fake_hash):
+ out = _redact_path("/activate/" + "T" * 43)
+ assert out == f"/activate/{_FAKE_PSEUDONYM}"
+ assert fake_hash == ["T" * 43]
+
+
+@pytest.mark.unit
+def test_plain_paths_untouched(fake_hash):
+ assert fake_hash == []
+ for path in ("/health", "/status", "/watch/", "/"):
+ assert _redact_path(path) == path
+ assert fake_hash == []
+
+
+@pytest.mark.unit
+def test_short_id_segment_left_alone(fake_hash):
+ # fewer than 6 hash chars: not the legacy capability shape
+ assert _redact_path("/123/v.mp4") == "/123/v.mp4"
+ assert fake_hash == []
+
+
+@pytest.mark.unit
+def test_real_pseudonym_stable_and_bare():
+ # with the real hasher: deterministic, and canonical output never
+ # carries the legacy truncation marker
+ out1 = _redact_path("/f/" + "c" * 32 + "/v")
+ out2 = _redact_path("/f/" + "c" * 32 + "/v")
+ assert out1 == out2
+ assert "…" not in out1
+ pseudonym = out1.split("/f/")[1].split("/")[0]
+ assert len(pseudonym) == 8
+ int(pseudonym, 16) # 8-hex
+
+
+@pytest.mark.unit
+def test_control_chars_escaped():
+ forged = "/f/x\n[INFO] fake line\r\t"
+ out = _escape_control_chars(forged)
+ assert "\n" not in out and "\r" not in out and "\t" not in out
+ assert "%0A" in out and "[INFO] fake line" in out
+
+
+@pytest.mark.unit
+def test_printable_path_untouched():
+ path = "/f/abc/file.mp4?q=1"
+ assert _escape_control_chars(path) == path
diff --git a/tests/test_unit/test_access_log_middleware.py b/tests/test_unit/test_access_log_middleware.py
new file mode 100644
index 00000000..e8475b5d
--- /dev/null
+++ b/tests/test_unit/test_access_log_middleware.py
@@ -0,0 +1,79 @@
+"""The access-log middleware, not just its helpers.
+
+request.path is percent-DECODED by aiohttp, so a request for
+/f/x/%0A[INFO] ... arrives with a real newline and would forge log lines if the
+middleware logged the path raw. The helpers were tested but the middleware was
+never executed, so replacing the escaped path with the plain redacted one
+passed the whole suite.
+"""
+
+import logging
+
+import pytest
+
+from Thunder.server import access_log_middleware
+
+
+class _Request:
+ def __init__(self, path, method="GET", remote="203.0.113.7"):
+ self.path = path
+ self.method = method
+ self.remote = remote
+
+
+class _Response:
+ status = 200
+ content_length = 1234
+
+
+async def _handler(_request):
+ return _Response()
+
+
+@pytest.mark.unit
+async def test_newline_in_path_cannot_forge_a_log_line(caplog):
+ forged = "/f/" + "a" * 32 + "/x\n[INFO] admin logged in\n"
+ with caplog.at_level(logging.INFO, logger="ThunderBot"):
+ await access_log_middleware(_Request(forged), _handler)
+
+ assert len(caplog.records) == 1
+ line = caplog.records[0].getMessage()
+ assert "\n" not in line, f"forged line written: {line!r}"
+ assert "[INFO]" in line # the payload is present but inert
+ assert "%0A" in line # control characters are escaped, not dropped
+
+
+@pytest.mark.unit
+async def test_middleware_logs_method_status_size_and_duration(caplog):
+ with caplog.at_level(logging.INFO, logger="ThunderBot"):
+ await access_log_middleware(_Request("/health", method="GET"), _handler)
+
+ line = caplog.records[0].getMessage()
+ assert '"GET /health"' in line
+ assert "200 1234" in line
+ assert line.rstrip().endswith("ms")
+
+
+@pytest.mark.unit
+async def test_middleware_still_logs_when_the_handler_raises(caplog):
+ async def _boom(_request):
+ raise RuntimeError("handler exploded")
+
+ with caplog.at_level(logging.INFO, logger="ThunderBot"):
+ with pytest.raises(RuntimeError):
+ await access_log_middleware(_Request("/status"), _boom)
+
+ line = caplog.records[0].getMessage()
+ # a non-HTTP failure has no response object: must report 500, not crash
+ assert "500" in line
+
+
+@pytest.mark.unit
+async def test_middleware_redacts_link_credentials(caplog):
+ canonical_hash = "b" * 32
+ with caplog.at_level(logging.INFO, logger="ThunderBot"):
+ await access_log_middleware(_Request(f"/f/{canonical_hash}/movie.mkv"), _handler)
+
+ line = caplog.records[0].getMessage()
+ assert canonical_hash not in line, "the link credential leaked into the access log"
+ assert "/f/" in line and "movie.mkv" in line
diff --git a/tests/test_unit/test_backfill.py b/tests/test_unit/test_backfill.py
new file mode 100644
index 00000000..73291acb
--- /dev/null
+++ b/tests/test_unit/test_backfill.py
@@ -0,0 +1,154 @@
+"""Review fix regression: the last_seen_at backfill must converge across
+boots (persisted cursor + unstamped-only pages), not restart every boot."""
+
+import pytest
+
+from Thunder.utils.database import Database
+
+
+class _FakeCursor:
+ """Cursor stand-in that HONOURS sort().
+
+ The previous version returned self and ignored the key, so removing the
+ real `.sort("_id", 1)` from the backfill left every test green -- while the
+ paging it exists to make correct depends on exactly that ordering: the next
+ boot resumes with `_id: {$gt: last_id}` and an unsorted page would skip
+ rows permanently, then still stamp the "converged" marker.
+
+ Docs are returned shuffled, so a caller that forgets to sort fails here the
+ way it would fail against a real collection.
+ """
+
+ def __init__(self, docs: list[dict]):
+ # deliberately not in _id order
+ self._docs = list(reversed(docs))
+ self._sort_key: str | None = None
+ self._descending = False
+
+ def sort(self, key=None, direction=None, **_kwargs): # noqa: ARG002 - chain shape
+ self._sort_key = key
+ self._descending = direction == -1
+ return self
+
+ async def to_list(self, limit: int) -> list[dict]:
+ docs = self._docs
+ if self._sort_key is not None:
+ docs = sorted(docs, key=lambda d: d[self._sort_key], reverse=self._descending)
+ return docs[:limit]
+
+
+class _FakeFilesCol:
+ """Minimal AsyncCollection stand-in: find + update_many only."""
+
+ name = "files"
+
+ def __init__(self, docs: list[dict]):
+ self.docs = docs
+
+ def find(self, flt: dict, projection=None): # noqa: ARG002 - projection is unread
+ unstamped_only = (
+ isinstance(flt.get("last_seen_at"), dict)
+ and flt["last_seen_at"].get("$exists") is False
+ )
+ gt = flt.get("_id", {}).get("$gt") if isinstance(flt.get("_id"), dict) else None
+ out = []
+ for d in self.docs:
+ if unstamped_only and "last_seen_at" in d:
+ continue
+ if gt is not None and not d["_id"] > gt:
+ continue
+ out.append({"_id": d["_id"]})
+ return _FakeCursor(out)
+
+ async def update_many(self, flt: dict, update: dict) -> int:
+ ids = set(flt["_id"]["$in"])
+ n = 0
+ for d in self.docs:
+ if d["_id"] in ids:
+ d["last_seen_at"] = update["$set"]["last_seen_at"]
+ n += 1
+ return n
+
+
+class _FakeFlagsCol:
+ name = "migration_flags"
+
+ def __init__(self):
+ self.docs: dict[str, dict] = {}
+
+ async def find_one(self, flt: dict) -> dict | None:
+ return self.docs.get(flt["_id"])
+
+ async def update_one(self, flt: dict, update: dict, upsert: bool = False) -> None:
+ doc = self.docs.setdefault(flt["_id"], {"_id": flt["_id"]})
+ doc.update(update["$set"])
+
+
+def _make_db(docs: list[dict]) -> Database:
+ # __new__: the backfill path touches only the two collections below
+ db = Database.__new__(Database)
+ db.files_col = _FakeFilesCol(docs) # type: ignore[assignment]
+ db.migration_flags_col = _FakeFlagsCol() # type: ignore[assignment]
+ return db
+
+
+@pytest.mark.unit
+async def test_backfill_converges_across_boots():
+ # 50_600 unstamped rows vs a 50k-per-boot cap: boot 1 must stop at the
+ # cap WITHOUT losing its place, boot 2 must finish and mark done
+ docs = [{"_id": i} for i in range(50_600)]
+ db = _make_db(docs)
+
+ await db._backfill_file_last_seen()
+ assert not await db._backfill_done()
+ assert sum(1 for d in docs if "last_seen_at" in d) == 50_000
+ assert await db._get_backfill_cursor() == 49_999
+
+ await db._backfill_file_last_seen()
+ assert await db._backfill_done()
+ assert all("last_seen_at" in d for d in docs)
+
+
+@pytest.mark.unit
+async def test_backfill_completes_and_marks_done_in_one_boot():
+ docs = [{"_id": i} for i in range(1_200)] # not a batch-size multiple
+ db = _make_db(docs)
+
+ await db._backfill_file_last_seen()
+ assert await db._backfill_done()
+ assert all("last_seen_at" in d for d in docs)
+
+
+@pytest.mark.unit
+async def test_backfill_skips_already_stamped_rows():
+ docs = [{"_id": 0, "last_seen_at": 1}, {"_id": 1}]
+ db = _make_db(docs)
+
+ await db._backfill_file_last_seen()
+ assert await db._backfill_done()
+ # the stamped row keeps its original value; only the legacy row is touched
+ assert docs[0]["last_seen_at"] == 1
+ assert "last_seen_at" in docs[1]
+
+
+@pytest.mark.unit
+async def test_paging_only_works_because_the_cursor_is_sorted():
+ """The cursor is resumed with `_id: {$gt: last_id}` where last_id is the
+ last row of the page. Without `.sort("_id", 1)` that is not the maximum, so
+ rows are skipped permanently -- and the marker still says "converged".
+
+ _FakeCursor hands back reversed docs, so an unsorted page leaves gaps that
+ the second boot cannot reach.
+ """
+ docs = [{"_id": i} for i in range(1_200)]
+ db = _make_db(docs)
+
+ await db._backfill_file_last_seen()
+ await db._backfill_file_last_seen()
+
+ assert await db._backfill_done(), "the backfill claims to have converged"
+ missing = [d["_id"] for d in docs if "last_seen_at" not in d]
+ assert not missing, (
+ f"{len(missing)} rows were skipped and will never be revisited "
+ f"(first missing: {missing[:5]}) -- the page was not sorted by _id"
+ )
diff --git a/tests/test_unit/test_batch_chunking.py b/tests/test_unit/test_batch_chunking.py
new file mode 100644
index 00000000..14d37cd8
--- /dev/null
+++ b/tests/test_unit/test_batch_chunking.py
@@ -0,0 +1,600 @@
+"""Batch link chunks must fit inside one Telegram message.
+
+The bug: chunks were cut by *count* (``LINK_CHUNK_SIZE = 20``), but each link
+embeds the percent-encoded file name -- one CJK glyph becomes nine characters
+and one emoji likewise. A 20-link chunk of CJK-named files rendered to ~12000
+UTF-16 units against a 4096 cap. ``reply_safe`` raised, the handler logged the
+error and moved on, and the user silently lost every link in that chunk -- in a
+private chat with no notice at all.
+
+These tests pin the property that matters: no rendered message Telegram would
+reject, and no link dropped from the output.
+"""
+
+import html
+import inspect
+from urllib.parse import quote
+
+import pytest
+
+from Thunder.bot.plugins import stream
+from Thunder.utils.messages import (
+ MSG_BATCH_LINKS_READY,
+ MSG_DM_BATCH_PREFIX,
+ TELEGRAM_MAX_UNITS,
+ telegram_len,
+)
+
+BASE = "https://thunder.example/f/0123456789abcdef0123456789abcdef/"
+
+# names long enough that a count-based chunk of 20 cannot fit
+LONG_NAMES = [
+ ("ascii", "a" * 200),
+ ("cjk", "影" * 60),
+ ("emoji", "\U0001f600" * 60),
+ ("mixed", "mixed 混合 \U0001f600 name" * 12),
+ ("html-hostile", ""),
+ [link_for("a.mp4", "")],
+ )
+
+ assert sent, "no DM was sent"
+ assert "',
+ )
+ monkeypatch.setattr(stream, "send_safe", _send)
+ await stream.send_channel_links(hostile, source_info="chan", source_id=-1)
+
+ text = sent[0]["text"]
+ assert "INJECTED" not in text
+ assert "",
+ "a b&c#d",
+ "
",
+ '"' * 40,
+ ],
+)
+async def test_a_hostile_file_name_cannot_break_out_of_an_attribute(hostile_name):
+ """`src` and `download_src` are rendered with |safe -- every autoescaped
+ sibling like alt= and download= is escaped, these are not.
+
+ |safe is load-bearing (autoescaping would mangle the URL's query), so the
+ guarantee has to come from the value instead: the server builds src from
+ quote_media_name(file_name), which percent-encodes with safe="". Pin that,
+ because nothing else here would notice if it stopped.
+ """
+ from Thunder.utils.bot_utils import quote_media_name
+
+ encoded = quote_media_name(hostile_name)
+ assert '"' not in encoded, f"the encoded name still carries a quote: {encoded!r}"
+ assert "'" not in encoded, f"the encoded name still carries an apostrophe: {encoded!r}"
+ assert "<" not in encoded and ">" not in encoded
+
+ out = await _render(hostile_name, "video/mp4", 1024)
+ # every attribute that interpolates src must still be a well-formed tag
+ for tag in re.findall(r"<(?:a|img|meta)\b[^>]*>", out):
+ # a tag containing a second '<' means an attribute was escaped out of
+ assert tag.count("<") == 1, f"a crafted name broke out of a tag: {tag[:120]}"
+ assert tag.count('"') % 2 == 0, f"unbalanced quotes in a tag: {tag[:120]}"
+ assert "" not in out
+
+
+# --- the inline fallback must not become the default ------------------------
+#
+# The inline ", out, re.S))
+
+
+def _css_rules(css: str) -> list[str]:
+ return [r.strip() for r in css.split("}") if "{" in r]
+
+
+def _declarations(body: str) -> str:
+ """Normalise a declaration block for comparison.
+
+ Whitespace INSIDE a declaration matters to this check: `display:\\n none` is
+ valid CSS and `body.replace(" ", "")` left the newline in, so a hidder
+ written across two lines passed the old test.
+ """
+ return re.sub(r"\s+", "", body)
+
+
+async def test_fallback_state_is_only_entered_on_a_cdn_failure():
+ out = await _render()
+ # the class is added by markFallback(), which is only wired to onerror=
+ assert "function markFallback()" in out
+ assert 'onerror="markFallback()"' in out
+ # and never unconditionally from a script that runs on every load
+ inline_scripts = "\n".join(re.findall(r"", out, re.S))
+ assert "fallback-ready" not in inline_scripts.replace(
+ "document.documentElement.classList.add('fallback-ready')", ""
+ ), "an always-run script marks the page as fallback, hiding the full UI"
+
+
+async def test_every_failable_cdn_asset_carries_the_onerror():
+ """A substring check for onerror="markFallback()" *somewhere* passed with 6 of
+ 7 assets unwired: a blocked jsdelivr then never engages the fallback on a
+ video/audio page and the user gets the half-broken layout."""
+ out = await _render()
+ cdn_assets = re.findall(
+ r'<(?:link|script)\b[^>]*?(?:href|src)="https://[^"]*"[^>]*>', out, re.S
+ )
+ # preconnect is a hint that can fail harmlessly, the font links carry
+ # onload=this.media, and the logo is decorative. The player assets are the
+ # ones whose failure must degrade the page.
+ player_assets = [
+ a
+ for a in cdn_assets
+ if 'rel="preconnect"' not in a and "fonts.googleapis" not in a and "logo.png" not in a
+ ]
+ assert len(player_assets) >= 5, (
+ f"the scan found only {len(player_assets)} CDN assets -- the template "
+ "changed shape and this test no longer examines anything"
+ )
+ unwired = [a[:90] for a in player_assets if "markFallback()" not in a]
+ assert not unwired, (
+ f"{len(unwired)} of {len(player_assets)} CDN assets can fail without "
+ f"triggering the fallback: {unwired}"
+ )
+
+
+async def test_the_css_and_the_js_agree_on_the_fallback_class():
+ """Each side was substring-checked independently. Renaming the CSS to
+ `.fallback-ready-v2` while the JS keeps `.fallback-ready` leaves the entire
+ fallback stylesheet inert -- and the suite green."""
+ out = await _render()
+ css = _inline_css(out)
+
+ scoped = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
+ added = set(re.findall(r"classList\.add\(\s*'([^']+)'", out))
+ assert added, "the page never adds a fallback class"
+
+ conditional = {c for c in scoped if "fallback" in c}
+ assert conditional, f"the fallback stylesheet conditions on nothing (classes: {sorted(scoped)})"
+ assert conditional == added, (
+ f"the stylesheet keys on {sorted(conditional)} but the JS adds "
+ f"{sorted(added)} -- the fallback can never engage"
+ )
+
+
+async def test_fallback_css_is_scoped_to_the_fallback_state():
+ """The whole block must be inert when the CDN is reachable.
+
+ This subsumes the older per-selector hidder test, which was four
+ byte-identical parametrizations strictly weaker than this one.
+ """
+ css = _inline_css(await _render())
+ assert css.strip(), "expected an inline fallback stylesheet"
+ hiders = 0
+ for rule in _css_rules(css):
+ head, _, body = rule.partition("{")
+ if "display:none" in _declarations(body):
+ hiders += 1
+ for part in head.split(","):
+ assert ".fallback-ready" in part, (
+ f"unscoped fallback rule `{part.strip()}` overrides the CDN theme "
+ "on every page load"
+ )
+ assert hiders, (
+ "no display:none found in the fallback stylesheet -- the template "
+ "changed shape and the scoping check is no longer examining anything"
+ )
+
+
+async def test_fallback_marker_is_defined_before_any_use():
+ """onerror="markFallback()" fires as soon as the asset fails, which for the
+ head tags is during parsing -- before exists."""
+ out = await _render()
+ defined = out.index("function markFallback()")
+ for match in re.finditer(r'onerror="markFallback\(\)"', out):
+ assert match.start() > defined, "an onerror handler fires before markFallback is defined"
+ assert "document.documentElement.classList.add('fallback-ready')" in out
+
+
+async def test_drawer_and_chrome_exist_so_they_can_be_shown():
+ """The drawer the fallback used to hide -- its markup must stay."""
+ out = await _render()
+ assert 'id="playersDrawer"' in out
+ assert 'id="playersPanel"' in out or 'id="playersDrawer"' in out
+ assert 'class="atmosphere"' in out
+ assert 'id="fileMeta"' in out
diff --git a/tests/test_unit/test_plugin_discovery.py b/tests/test_unit/test_plugin_discovery.py
new file mode 100644
index 00000000..0cc82c37
--- /dev/null
+++ b/tests/test_unit/test_plugin_discovery.py
@@ -0,0 +1,37 @@
+"""Plugin discovery must load handler modules only.
+
+import_plugins loads every file the glob matches as a plugin. The glob
+deliberately excludes __init__.py: the package marker exists so coverage.py
+measures these modules, and picking it up would register the package itself as
+a fifth plugin (executing it a second time under a second module identity).
+"""
+
+import glob
+import os
+
+import pytest
+
+from Thunder import __main__ as main_mod
+
+EXPECTED = ["admin", "callbacks", "common", "stream"]
+
+
+@pytest.mark.unit
+def test_glob_matches_exactly_the_handler_modules():
+ found = sorted(os.path.basename(p)[:-3] for p in glob.glob(main_mod.PLUGIN_PATH))
+ assert found == EXPECTED
+
+
+@pytest.mark.unit
+def test_package_marker_is_never_loaded_as_a_plugin():
+ stems = [os.path.basename(p) for p in glob.glob(main_mod.PLUGIN_PATH)]
+ assert "__init__.py" not in stems
+
+
+@pytest.mark.unit
+def test_every_plugin_imports_cleanly():
+ """A handler that fails to import is reported and skipped at boot, not
+ silently dropped -- so each one must import on its own."""
+ for name in EXPECTED:
+ module = __import__(f"Thunder.bot.plugins.{name}", fromlist=["_"])
+ assert module is not None
diff --git a/tests/test_unit/test_preflight.py b/tests/test_unit/test_preflight.py
new file mode 100644
index 00000000..6618bd49
--- /dev/null
+++ b/tests/test_unit/test_preflight.py
@@ -0,0 +1,158 @@
+"""Unified preflight chain -- gate presets, ordering, fail-closed ids."""
+
+import pytest
+
+from Thunder.utils.decorators import (
+ GATES_STANDARD,
+ GATES_START,
+ PREFLIGHT_GATES,
+ check_banned,
+ check_private_mode,
+ preflight,
+ require_token,
+)
+from Thunder.vars import Var
+
+
+@pytest.mark.unit
+def test_gate_presets_exist_in_registry():
+ """Every preset id must resolve in PREFLIGHT_GATES -- a typo'd id is a
+ fail-closed rejection in production, and this test fails in CI first."""
+ for preset, name in (
+ (GATES_STANDARD, "GATES_STANDARD"),
+ (GATES_START, "GATES_START"),
+ ):
+ for gate_id in preset:
+ assert gate_id in PREFLIGHT_GATES, f"{name}: unknown gate id {gate_id!r}"
+
+
+@pytest.mark.unit
+def test_gate_presets_match_documented_order():
+ # banned -> private-mode -> token (AGENTS.md contract)
+ assert GATES_STANDARD == ("banned", "private_mode", "token")
+ # /start must stay reachable for token-gated users (no token gate)
+ assert GATES_START == ("banned", "private_mode")
+
+
+@pytest.mark.unit
+async def test_unknown_gate_id_rejects_fail_closed():
+ """A typo'd gate id must REJECT, never silently skip a security check."""
+
+ class _Msg:
+ from_user = None
+
+ assert await preflight(object(), _Msg(), gates=("banned", "typo_gate")) is None
+ assert await preflight(object(), _Msg(), gates=("typo_gate",)) is None
+
+
+@pytest.mark.unit
+async def test_preflight_returns_shortener_status_for_owner(monkeypatch):
+ """All gates passing returns the shortener status (NOT None) -- the
+ False/None contract: callers must use `is None`."""
+
+ class _User:
+ id = Var.OWNER_ID
+
+ class _Msg:
+ from_user = _User()
+
+ # pin the knob off: with it env-overridden on, the old `or` fallback
+ # turned the assertion below into a tautology
+ monkeypatch.setattr(Var, "SHORTEN_MEDIA_LINKS", False)
+ result = await preflight(object(), _Msg(), gates=GATES_START)
+ assert result is False
+
+
+@pytest.mark.unit
+async def test_owner_bypasses_force_sub(monkeypatch):
+ """Owner skips the entire chain, including force-sub (no RPC made)."""
+ import Thunder.utils.force_channel as force_channel_module
+ from Thunder.utils.force_channel import force_channel_check
+
+ class _User:
+ id = Var.OWNER_ID
+
+ class _Msg:
+ from_user = _User()
+
+ calls = {"n": 0}
+
+ async def _boom(*args, **kwargs):
+ calls["n"] += 1
+ raise AssertionError("owner path must not touch the network")
+
+ monkeypatch.setattr(force_channel_module, "_is_member", _boom)
+ monkeypatch.setattr(Var, "FORCE_CHANNEL_ID", -100123)
+ assert await force_channel_check(object(), _Msg()) is True
+ assert calls["n"] == 0
+
+
+@pytest.mark.unit
+async def test_gate_reply_failure_never_breaks_the_gate(monkeypatch):
+ """The bot usually has no send permission in a storage channel, so a gate
+ denial that cannot be delivered must not change the gate's answer or raise
+ -- that is the whole point of routing denials through one helper."""
+ from Thunder.utils import decorators as dec
+
+ async def _boom(*args, **kwargs):
+ raise RuntimeError("CHAT_WRITE_FORBIDDEN")
+
+ monkeypatch.setattr(dec, "reply_safe", _boom)
+
+ class _Msg:
+ from_user = None
+
+ # returns None (no exception escapes) for the failure path...
+ assert await dec._reply_ignore_failure(_Msg(), "denied") is None
+ # ...and the gate still denies
+ assert await dec.check_banned(object(), _Msg()) is False
+
+
+@pytest.mark.unit
+async def test_gate_reply_is_delivered_when_possible(monkeypatch):
+ from Thunder.utils import decorators as dec
+
+ seen: list[tuple] = []
+
+ async def _ok(msg, text, **kwargs):
+ seen.append((text, kwargs))
+
+ monkeypatch.setattr(dec, "reply_safe", _ok)
+ await dec._reply_ignore_failure(object(), "denied", parse_mode="HTML")
+ assert seen == [("denied", {"parse_mode": "HTML"})]
+
+
+@pytest.mark.unit
+def test_every_gate_denial_goes_through_the_helper():
+ """Guards the refactor: no gate may re-introduce a bare
+ try/except Exception: pass around a reply."""
+ import inspect
+
+ from Thunder.utils import decorators as dec
+
+ source = inspect.getsource(dec)
+ # the helper itself contains the only try/except around a reply
+ body = source.split("async def check_banned", 1)[1]
+ assert (
+ "try:\n" not in body.replace("try:\n await _reply_ignore_failure", "")
+ or body.count("await reply_safe(") == 0
+ ), "a gate is calling reply_safe directly instead of the helper"
+
+
+@pytest.mark.unit
+async def test_anonymous_sender_denied_by_every_gate(monkeypatch):
+ """Messages with no attributable sender (channel posts, anonymous admins)
+ are DENIED fail-closed -- never bypass."""
+
+ class _Msg:
+ from_user = None
+
+ # ban gate first in the chain: a banned user must not escape by posting as
+ # the channel or as an anonymous admin (AGENTS.md no-sender contract)
+ assert await check_banned(object(), _Msg()) is False
+
+ monkeypatch.setattr(Var, "PRIVATE_MODE", True)
+ assert await check_private_mode(object(), _Msg()) is False
+
+ monkeypatch.setattr(Var, "TOKEN_ENABLED", True)
+ assert await require_token(object(), _Msg()) is False
diff --git a/tests/test_unit/test_preview_admission.py b/tests/test_unit/test_preview_admission.py
new file mode 100644
index 00000000..9bd817fe
--- /dev/null
+++ b/tests/test_unit/test_preview_admission.py
@@ -0,0 +1,311 @@
+"""The legacy /watch preview must not be an unauthenticated Telegram RPC
+amplifier.
+
+render_page() fetches the vault message BEFORE it can verify the capability
+hash -- the hash is a prefix of the file unique id stored in the very message
+being fetched, so verification is necessarily downstream of the fetch. A
+request whose hash does not match can therefore never be cached, and every
+shape-valid /watch URL from an unauthenticated caller is a fresh RPC against
+the bot account.
+
+The branch added admission control to /f/ (work_loads + select_optimal_client)
+but nothing to /watch/. These tests pin the bound.
+"""
+
+import asyncio
+import logging
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import web
+
+from Thunder.utils import render_template as rt
+from Thunder.utils.render_template import render_page
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def reset_admission():
+ rt._preview_rpc_inflight = 0
+ yield
+ rt._preview_rpc_inflight = 0
+
+
+@pytest.fixture
+def no_cache():
+ rt._legacy_cache.clear()
+ yield
+ rt._legacy_cache.clear()
+
+
+# --- the guard itself ------------------------------------------------------
+
+
+def test_concurrency_limit_is_bounded():
+ assert 0 < rt._PREVIEW_RPC_CONCURRENCY <= 16, (
+ "unbounded means unbounded; a large value means the amplifier still works"
+ )
+
+
+def test_slot_sheds_with_a_retryable_503():
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ with pytest.raises(web.HTTPServiceUnavailable) as excinfo:
+ with rt._preview_rpc_slot():
+ pass
+ assert excinfo.value.headers["Retry-After"]
+ assert excinfo.value.status == 503
+ finally:
+ rt._preview_rpc_inflight = 0
+
+
+def test_slot_is_released_on_success_and_on_failure(reset_admission):
+ with rt._preview_rpc_slot():
+ assert rt._preview_rpc_inflight == 1
+ assert rt._preview_rpc_inflight == 0, "a slot leaked after a normal exit"
+
+ with pytest.raises(RuntimeError):
+ with rt._preview_rpc_slot():
+ raise RuntimeError("rpc blew up")
+ assert rt._preview_rpc_inflight == 0, "a slot leaked after an exception"
+
+
+async def test_slot_is_released_on_cancellation(reset_admission):
+ async def run():
+ with rt._preview_rpc_slot():
+ await asyncio.sleep(3600)
+
+ task = asyncio.ensure_future(run())
+ await asyncio.sleep(0)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert rt._preview_rpc_inflight == 0, (
+ "a cancelled preview held its slot forever -- one abort would then "
+ "permanently consume capacity"
+ )
+
+
+async def test_slots_are_bounded_under_concurrency(reset_admission):
+ """Peak in-flight never exceeds the cap, however many arrive at once."""
+ peak = 0
+ admitted = 0
+ shed = 0
+
+ async def one():
+ nonlocal peak, admitted, shed
+ try:
+ with rt._preview_rpc_slot():
+ admitted += 1
+ peak = max(peak, rt._preview_rpc_inflight)
+ await asyncio.sleep(0)
+ except web.HTTPServiceUnavailable:
+ shed += 1
+
+ await asyncio.gather(*(one() for _ in range(40)))
+ assert peak <= rt._PREVIEW_RPC_CONCURRENCY
+ assert admitted == rt._PREVIEW_RPC_CONCURRENCY, (
+ f"{admitted} concurrent RPCs were admitted, cap is {rt._PREVIEW_RPC_CONCURRENCY}"
+ )
+ assert shed == 40 - rt._PREVIEW_RPC_CONCURRENCY
+
+
+# --- the guard is actually on the RPC path ---------------------------------
+
+
+async def test_cold_render_passes_through_the_admission_slot(
+ reset_admission, no_cache, monkeypatch
+):
+ """The slot must wrap the vault fetch. A cache hit costs no RPC and so
+ must not consume a slot."""
+ from Thunder.server.exceptions import InvalidHash
+
+ calls = []
+ inflight_during_rpc = []
+
+ async def fake_tg_call(fn, *args, **kwargs):
+ calls.append(kwargs.get("message_ids"))
+ # observed from inside the RPC: asserts the slot is on THIS path, not
+ # merely released afterwards (which is also true if never taken)
+ inflight_during_rpc.append(rt._preview_rpc_inflight)
+ return None # -> InvalidHash, i.e. a cache miss with no cache write
+
+ monkeypatch.setattr(rt, "tg_call", fake_tg_call)
+
+ with pytest.raises(InvalidHash):
+ await render_page(12345, "abcdef")
+
+ assert calls == [12345], "the vault fetch was not made"
+ assert inflight_during_rpc == [1], (
+ "the vault RPC ran outside the admission slot -- the guard is "
+ "defined but not wired onto the amplifier"
+ )
+ assert rt._preview_rpc_inflight == 0, "the slot was not released after the fetch"
+
+
+async def test_a_shed_request_never_reaches_telegram(reset_admission, no_cache, monkeypatch):
+ """The point of the guard: a shed caller must cost zero RPCs."""
+ from Thunder.server.exceptions import InvalidHash # noqa: F401
+
+ calls = []
+
+ async def fake_tg_call(fn, *args, **kwargs):
+ calls.append(1)
+ return None
+
+ monkeypatch.setattr(rt, "tg_call", fake_tg_call)
+
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ for i in range(10):
+ with pytest.raises(web.HTTPServiceUnavailable):
+ await render_page(1000 + i, "abcdef")
+ finally:
+ rt._preview_rpc_inflight = 0
+
+ assert not calls, f"{len(calls)} Telegram RPCs were spent while shedding"
+
+
+async def test_cached_render_does_not_consume_a_slot(reset_admission, no_cache, monkeypatch):
+ rt._legacy_cache_put((7, "aabbcc"), "v.mp4", "video/mp4", 1024)
+
+ async def fake_tg_call(*args, **kwargs):
+ raise AssertionError("a cached render must not spend an RPC")
+
+ monkeypatch.setattr(rt, "tg_call", fake_tg_call)
+
+ # saturate the gate first: a cache hit costs no RPC, so it must not be
+ # shed just because other callers are busy
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ out = await render_page(7, "aabbcc")
+ assert "v.mp4" in out
+ assert rt._preview_rpc_inflight == rt._PREVIEW_RPC_CONCURRENCY, (
+ "a cache hit took a slot from the RPC budget"
+ )
+
+
+async def test_load_shed_is_not_logged_as_an_error(reset_admission, no_cache, monkeypatch, caplog):
+ """A shed request is the system working, not a fault: a traceback per
+ rejected request under attack is its own outage."""
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ with pytest.raises(web.HTTPServiceUnavailable):
+ await render_page(9, "abcdef")
+ finally:
+ rt._preview_rpc_inflight = 0
+ assert not [r for r in caplog.records if r.levelname == "ERROR"], (
+ "load shedding was logged as an error"
+ )
+
+
+async def test_a_shed_response_carries_no_store(reset_admission, no_cache, monkeypatch):
+ """A cached 503 outlives the condition that produced it. Every other error
+ the server emits goes through server.exceptions.unavailable; a hand-rolled
+ header set is how this one ended up without Cache-Control."""
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ with pytest.raises(web.HTTPServiceUnavailable) as excinfo:
+ await render_page(9, "abcdef")
+ finally:
+ rt._preview_rpc_inflight = 0
+
+ headers = excinfo.value.headers
+ assert headers.get("Cache-Control") == "no-store", (
+ "the shed response can be cached by a CDN or browser"
+ )
+ assert headers.get("Retry-After"), "no Retry-After, so clients will not back off"
+ assert headers.get("Access-Control-Allow-Origin") == "*", (
+ "a browser cannot read the failure reason"
+ )
+
+
+async def test_shed_headers_match_the_routes_error_headers(reset_admission, no_cache, monkeypatch):
+ """Structural: the shed and the routes must agree, so the set lives in one
+ place (server.exceptions) rather than being retyped at each call site."""
+ from Thunder.server.exceptions import ERROR_HEADERS
+
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ with pytest.raises(web.HTTPServiceUnavailable) as excinfo:
+ await render_page(9, "abcdef")
+ finally:
+ rt._preview_rpc_inflight = 0
+
+ for name, value in ERROR_HEADERS.items():
+ assert excinfo.value.headers.get(name) == value, f"shed response differs on {name}"
+
+
+async def test_the_route_ladder_does_not_log_one_line_per_shed_request(
+ reset_admission, no_cache, monkeypatch, caplog
+):
+ """The flood only moved from ERROR to WARNING. An attacker who can trigger
+ the shed can still fill the log file that /log uploads, so nothing at
+ WARNING or above may be emitted for a 5xx."""
+ from Thunder.server import stream_routes
+
+ rt._preview_rpc_inflight = rt._PREVIEW_RPC_CONCURRENCY
+ try:
+ with pytest.raises(web.HTTPException):
+ async with stream_routes._route_ladder("preview"):
+ await render_page(9, "abcdef")
+ finally:
+ rt._preview_rpc_inflight = 0
+
+ noisy = [r for r in caplog.records if r.levelno >= logging.WARNING]
+ assert not noisy, f"a shed request logged {len(noisy)} record(s) at WARNING or above: {noisy}"
+
+
+async def test_the_route_ladder_still_logs_client_errors(monkeypatch, caplog):
+ """4xx is a client mistake and is worth a line; only 5xx is shed."""
+ from Thunder.server import stream_routes
+
+ with pytest.raises(web.HTTPException):
+ async with stream_routes._route_ladder("preview"):
+ raise web.HTTPBadRequest(text="nope")
+
+ assert [r for r in caplog.records if r.levelno >= logging.WARNING], (
+ "a 4xx was swallowed silently -- that is a real client mistake"
+ )
+
+
+async def test_a_wrong_hash_is_a_404_not_a_logged_error(
+ reset_admission, no_cache, monkeypatch, caplog
+):
+ """Same amplifier, aimed at bot.txt: one ERROR traceback per probe is how
+ an unauthenticated caller fills the log file that /log uploads."""
+ from Thunder.server.exceptions import InvalidHash
+
+ async def fake_tg_call(fn, *args, **kwargs):
+ return SimpleNamespace(id=1, unique_id="zzzzzz-unique", document=None)
+
+ monkeypatch.setattr(rt, "tg_call", fake_tg_call)
+
+ for _ in range(5):
+ with pytest.raises(InvalidHash):
+ await render_page(1, "abcdef")
+
+ errors = [r for r in caplog.records if r.levelname in ("ERROR", "CRITICAL")]
+ assert not errors, f"{len(errors)} of 5 wrong-hash probes were logged as errors"
+
+
+async def test_a_wrong_hash_is_not_cacheable_so_the_bound_matters(
+ reset_admission, no_cache, monkeypatch
+):
+ """Why concurrency control is the only pre-fetch defence: a mismatched
+ hash leaves nothing in the cache, so a repeated attack is a repeated RPC."""
+ from Thunder.server.exceptions import InvalidHash
+
+ calls = []
+
+ async def fake_tg_call(fn, *args, **kwargs):
+ calls.append(1)
+ return SimpleNamespace(id=1, unique_id="zzzzzz-unique", document=None)
+
+ monkeypatch.setattr(rt, "tg_call", fake_tg_call)
+
+ for _ in range(3):
+ with pytest.raises(InvalidHash):
+ await render_page(1, "abcdef")
+ assert len(calls) == 3, "a rejected hash was cached, hiding the amplifier"
+ assert not rt._legacy_cache
diff --git a/tests/test_unit/test_rate_limit_notifications.py b/tests/test_unit/test_rate_limit_notifications.py
new file mode 100644
index 00000000..362dd281
--- /dev/null
+++ b/tests/test_unit/test_rate_limit_notifications.py
@@ -0,0 +1,112 @@
+"""Queue-full must replace the queue notice, not sit next to it.
+
+The notice is sent *before* the request is admitted, so a full queue left the
+user staring at "Status: In Queue" with no sign the request was rejected.
+"""
+
+import pytest
+
+from Thunder.utils import rate_limiter as rl
+from Thunder.utils.messages import MSG_RATE_LIMIT_QUEUE_FULL
+from Thunder.vars import Var
+
+
+class _User:
+ id = 4242
+
+
+class _Bot:
+ """The notice is sent BY the bot, so Pyrogram stamps the bot as author."""
+
+ id = 999
+
+
+class _Notice:
+ """Stand-in for the already-sent queue notification."""
+
+ from_user = _Bot()
+ id = 99
+ text = "Status: In Queue"
+
+
+@pytest.mark.unit
+async def test_queue_full_edits_the_existing_notice(monkeypatch):
+ edits = []
+ asked: list[int] = []
+
+ async def _edit(msg, text, **kwargs):
+ edits.append((msg, text))
+
+ async def _estimate(user_id, file_identifier=None):
+ asked.append(user_id)
+ return 0.0
+
+ monkeypatch.setattr(rl, "edit_safe", _edit)
+ monkeypatch.setattr(rl.rate_limiter, "estimate_wait_time", _estimate)
+
+ notice = _Notice()
+ await rl.replace_queue_notification(object(), notice, None, 4242)
+
+ assert len(edits) == 1, "the stale notice was not updated"
+ msg, text = edits[0]
+ assert msg is notice
+ assert "In Queue" not in text
+ assert text == MSG_RATE_LIMIT_QUEUE_FULL.format(wait_estimate=1, s="")
+
+
+@pytest.mark.unit
+async def test_wait_estimate_uses_the_requester_not_the_bot(monkeypatch):
+ """The notice is authored by the bot, so reading from_user off it estimated
+ the wait against the bot's own empty window and always said "less than a
+ minute" -- the requester never saw their real queue time."""
+ asked: list[int] = []
+
+ async def _edit(msg, text, **kwargs):
+ return None
+
+ async def _estimate(user_id, file_identifier=None):
+ asked.append(user_id)
+ return 0.0
+
+ monkeypatch.setattr(rl, "edit_safe", _edit)
+ monkeypatch.setattr(rl.rate_limiter, "estimate_wait_time", _estimate)
+
+ notice = _Notice()
+ assert notice.from_user.id != 4242, "fake no longer models a bot-authored notice"
+ await rl.replace_queue_notification(object(), notice, None, 4242)
+
+ assert asked == [4242], f"wait estimated for the wrong id: {asked}"
+
+
+@pytest.mark.unit
+async def test_queue_full_pluralises_and_rounds_up(monkeypatch):
+ edits = []
+
+ async def _edit(msg, text, **kwargs):
+ edits.append(text)
+
+ async def _estimate(user_id, file_identifier=None):
+ return 130.0 # 2.16 minutes -> "3 minutes"
+
+ monkeypatch.setattr(rl, "edit_safe", _edit)
+ monkeypatch.setattr(rl.rate_limiter, "estimate_wait_time", _estimate)
+
+ await rl.replace_queue_notification(object(), _Notice(), None, 4242)
+
+ assert edits == [MSG_RATE_LIMIT_QUEUE_FULL.format(wait_estimate=3, s="s")]
+
+
+@pytest.mark.unit
+def test_global_rps_cap_is_live_without_the_per_minute_window(monkeypatch):
+ """The old warning told operators GLOBAL_RPS_LIMIT had "no effect" until
+ the global breaker was enabled. _breaker_rate returns the cap ahead of the
+ per-minute window, so it is enforced on its own -- pin that, because the
+ warning text is derived from this behaviour."""
+ monkeypatch.setattr(Var, "GLOBAL_RPS_LIMIT", 5)
+ monkeypatch.setattr(Var, "GLOBAL_RATE_LIMIT", False)
+
+ limiter = rl.RateLimiter.__new__(rl.RateLimiter)
+ limiter.global_rate_limit_enabled = False
+ limiter.max_global_requests_per_minute = 600 # would be 10/s if it won
+
+ assert limiter._breaker_rate() == 5.0
diff --git a/tests/test_unit/test_rate_limiter.py b/tests/test_unit/test_rate_limiter.py
new file mode 100644
index 00000000..1b6f9b34
--- /dev/null
+++ b/tests/test_unit/test_rate_limiter.py
@@ -0,0 +1,190 @@
+"""Window math, breaker, sweep -- pure logic, no Mongo."""
+
+import time
+
+import pytest
+
+from Thunder.utils.rate_limiter import TokenBucket, rate_limiter
+
+
+@pytest.mark.unit
+async def test_check_limits_window():
+ rl = rate_limiter
+ rl.enabled = True
+ rl._initialization_error = False
+ rl.max_requests_per_period = 2
+ rl.rate_limit_period_seconds = 60
+ rl.global_rate_limit_enabled = False
+
+ uid = 90_001
+ rl.user_requests.pop(uid, None)
+
+ assert await rl.check_limits(uid, record=True) is True
+ assert await rl.check_limits(uid, record=True) is True
+ # window exhausted
+ assert await rl.check_limits(uid, record=True) is False
+ # advisory check does not extend the window
+ assert await rl.check_limits(uid, record=False) is False
+ assert len(rl.user_requests[uid]) == 2
+ rl.user_requests.pop(uid, None)
+
+
+@pytest.mark.unit
+async def test_owner_bypass():
+ from Thunder.vars import Var
+
+ rl = rate_limiter
+ rl.enabled = True
+ assert await rl.check_limits(Var.OWNER_ID, record=True) is True
+
+
+@pytest.mark.unit
+async def test_sweep_prunes_stale_users():
+ rl = rate_limiter
+ rl.enabled = True
+ rl.rate_limit_period_seconds = 60
+ uid = 90_002
+ old = time.time() - 3600
+ rl.user_requests[uid] = _deque(old, old)
+ stats = await rl.sweep()
+ assert uid not in rl.user_requests
+ assert stats["user_windows"] >= 1
+
+
+def _deque(*timestamps):
+ from collections import deque
+
+ return deque(timestamps)
+
+
+@pytest.mark.unit
+class TestTokenBucket:
+ def test_burst_then_deny(self):
+ bucket = TokenBucket(rate_per_second=2.0, burst_multiplier=2.0)
+ allowed = 0
+ for _ in range(10):
+ if bucket.allow():
+ allowed += 1
+ assert allowed == int(bucket.burst) # burst = 2x rate = 4
+
+ def test_refill_over_time(self):
+ bucket = TokenBucket(rate_per_second=100.0, burst_multiplier=1.0)
+ while bucket.allow():
+ pass
+ time.sleep(0.05) # ~5 tokens; CI timing jitter: retry briefly before failing
+ for _ in range(20):
+ if bucket.allow():
+ break
+ time.sleep(0.05)
+ else:
+ pytest.fail("bucket did not refill after ~1s of waiting")
+
+ def test_retry_after_positive_when_denied(self):
+ bucket = TokenBucket(rate_per_second=0.5, burst_multiplier=1.0)
+ while bucket.allow():
+ pass
+ assert bucket.retry_after() > 0
+
+ def test_zero_rate_always_allows(self):
+ bucket = TokenBucket(rate_per_second=0.0)
+ for _ in range(50):
+ assert bucket.allow() is True
+ assert bucket.retry_after() == 0.0
+
+
+@pytest.mark.unit
+async def test_occupancy_shape():
+ occ = rate_limiter.occupancy()
+ assert {"queued", "tracked_users", "global_window", "breaker_tokens"} <= set(occ)
+
+
+@pytest.mark.unit
+async def test_immediate_path_consumes_breaker_token(monkeypatch):
+ """Fresh-user bursts execute inline, so the immediate path
+ must consume a breaker token BEFORE running the handler."""
+ from types import SimpleNamespace
+
+ import Thunder.utils.rate_limiter as rl_mod
+
+ rl = rate_limiter
+ rl.enabled = True
+ rl._initialization_error = False
+ rl.max_requests_per_period = 100
+ rl.rate_limit_period_seconds = 60
+ rl.global_rate_limit_enabled = False
+ monkeypatch.setattr(rl, "is_owner", lambda user_id: False)
+ monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=5.0, burst_multiplier=2.0))
+
+ uid = 90_100
+ rl.user_requests.pop(uid, None)
+
+ executed: list = []
+
+ async def handler(bot, message, *args, **kwargs):
+ executed.append(message)
+
+ msg = SimpleNamespace(from_user=SimpleNamespace(id=uid), document=None)
+
+ before = rl.breaker.available()
+ await rl_mod.handle_rate_limited_request(None, msg, handler)
+ assert len(executed) == 1
+ assert rl.breaker.available() < before # one token consumed on the spot
+ rl.user_requests.pop(uid, None)
+
+
+@pytest.mark.unit
+async def test_dry_breaker_defers_immediate_request_to_queue(monkeypatch):
+ """A dry breaker must not drop the request: it falls through to the
+ queue, where workers consume tokens at exec time (shaping, not shedding)."""
+ from types import SimpleNamespace
+
+ import Thunder.utils.rate_limiter as rl_mod
+
+ rl = rate_limiter
+ rl.enabled = True
+ rl._initialization_error = False
+ rl.max_requests_per_period = 100
+ rl.rate_limit_period_seconds = 60
+ rl.global_rate_limit_enabled = False
+ monkeypatch.setattr(rl, "is_owner", lambda user_id: False)
+ monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=5.0, burst_multiplier=2.0))
+ monkeypatch.setattr(rl, "get_user_priority", _async_return("regular"))
+ monkeypatch.setattr(rl_mod, "send_queue_notification", _async_noop)
+ monkeypatch.setattr(rl_mod, "send_queue_full_message", _async_noop)
+
+ queued: list = []
+
+ async def fake_add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs):
+ queued.append(user_id)
+
+ monkeypatch.setattr(rl, "add_to_queue", fake_add_to_queue)
+
+ # drain the bucket completely
+ while rl.breaker.allow():
+ pass
+
+ uid = 90_101
+ rl.user_requests.pop(uid, None)
+
+ executed: list = []
+
+ async def handler(bot, message, *args, **kwargs):
+ executed.append(message)
+
+ msg = SimpleNamespace(from_user=SimpleNamespace(id=uid), document=None)
+
+ await rl_mod.handle_rate_limited_request(None, msg, handler)
+ assert executed == []
+ assert queued == [uid]
+ rl.user_requests.pop(uid, None)
+
+
+def _async_return(value):
+ async def _fn(*args, **kwargs):
+ return value
+
+ return _fn
+
+
+async def _async_noop(*args, **kwargs):
+ return None
diff --git a/tests/test_unit/test_rate_limiter_bounds.py b/tests/test_unit/test_rate_limiter_bounds.py
new file mode 100644
index 00000000..9251bfea
--- /dev/null
+++ b/tests/test_unit/test_rate_limiter_bounds.py
@@ -0,0 +1,207 @@
+"""The per-user bookkeeping bound: admission must enforce it, and eviction must
+not hand an active user a fresh window.
+
+Both properties were checked as WIRING and found wrong. A test read the constant
+and compared numbers; nothing ever filled the table or checked whose window got
+reset. That is the recurring shape of the false-confidence defect in this repo: a
+fake, or an assertion, that re-decides what the real code decides.
+"""
+
+import time
+from collections import deque
+
+import pytest
+
+from Thunder.utils import rate_limiter as rl_mod
+from Thunder.utils.rate_limiter import rate_limiter
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def limiter(monkeypatch):
+ """A fresh limiter, so these tests do not inherit the singleton's state."""
+ fresh = rl_mod.RateLimiter()
+ fresh.enabled = True
+ fresh._initialization_error = False
+ fresh.global_rate_limit_enabled = False
+ fresh.max_requests_per_period = 5
+ fresh.rate_limit_period_seconds = 3600
+ fresh.user_requests.clear()
+ yield fresh
+ fresh.user_requests.clear()
+
+
+@pytest.mark.unit
+async def test_admission_never_exceeds_the_tracked_user_bound(limiter, monkeypatch):
+ """A sweep on a timer bounds the steady state, not what can be created
+ between sweeps. Only admission makes it a real bound."""
+ monkeypatch.setattr(rl_mod, "MAX_TRACKED_USERS", 50)
+
+ for i in range(500):
+ await limiter.check_limits(i, record=True)
+
+ assert len(limiter.user_requests) <= 50, (
+ f"{len(limiter.user_requests)} user windows tracked against a cap of 50 "
+ "-- admission is not enforcing the bound"
+ )
+
+
+@pytest.mark.unit
+async def test_eviction_does_not_reset_a_currently_active_users_window(limiter, monkeypatch):
+ """This is the reason eviction sorts by recency.
+
+ Keys are only inserted when absent, so dict order is FIRST-EVER-SEEN order.
+ Evicting by it drops whoever joined longest ago -- which, for a long-lived
+ account that keeps using the bot, is whoever is CURRENTLY ACTIVE. They are
+ silently handed a fresh window and get another full allowance.
+
+ The scenario needs the victim to be both first-inserted AND still active. A
+ victim that has gone idle is legitimately the least-recently-active entry
+ and dropping it is correct -- so the victim keeps a current timestamp while
+ new ids churn through admission.
+ """
+ monkeypatch.setattr(rl_mod, "MAX_TRACKED_USERS", 20)
+ # headroom, so the victim can keep touching without filling its own window
+ limiter.max_requests_per_period = 500
+
+ victim = 1
+ for _ in range(5):
+ assert await limiter.check_limits(victim, record=True) is True
+
+ for i in range(2, 400):
+ await limiter.check_limits(i, record=True)
+ # still active: a request every round keeps its window current
+ await limiter.check_limits(victim, record=True)
+
+ assert victim in limiter.user_requests, (
+ "a currently active user was evicted by id churn -- their rate-limit "
+ "window was reset by an unrelated flood of new ids"
+ )
+ assert len(limiter.user_requests[victim]) == 5 + 398, (
+ f"the victim's window was truncated to {len(limiter.user_requests[victim])} "
+ "-- their accumulated allowance was partly forgotten"
+ )
+
+
+@pytest.mark.unit
+async def test_eviction_prefers_the_least_recently_active(limiter, monkeypatch):
+ """Directly: the victim is the entry with the oldest newest timestamp."""
+ monkeypatch.setattr(rl_mod, "MAX_TRACKED_USERS", 3)
+ now = time.time()
+
+ # insert in an order that is NOT recency order: sleepiest first
+ limiter.user_requests[10] = deque([now - 900])
+ limiter.user_requests[11] = deque([now - 800])
+ limiter.user_requests[12] = deque([now - 100]) # most recent
+
+ limiter._evict_inactive_users(2)
+
+ assert 12 in limiter.user_requests, "the most recently active entry was evicted"
+ assert 11 in limiter.user_requests, "the second most recent was evicted"
+ assert 10 not in limiter.user_requests, "the least recently active entry survived the eviction"
+
+
+@pytest.mark.unit
+async def test_the_sweep_and_admission_agree_on_who_goes(limiter, monkeypatch):
+ """One helper, so the two paths cannot drift again."""
+ monkeypatch.setattr(rl_mod, "MAX_TRACKED_USERS", 4)
+ now = time.time()
+
+ def seed():
+ limiter.user_requests.clear()
+ for i, age in enumerate([500, 400, 300, 200, 100]):
+ limiter.user_requests[i] = deque([now - age])
+
+ seed()
+ limiter._evict_inactive_users(4)
+ by_admission = set(limiter.user_requests)
+
+ seed()
+ await limiter.sweep()
+ by_sweep = set(limiter.user_requests)
+
+ assert by_sweep, "the sweep expired everything -- the fixture timestamps are stale"
+ assert by_admission == by_sweep, (
+ f"admission kept {by_admission} but the sweep kept {by_sweep} -- the two "
+ "eviction paths disagree about whose window to drop"
+ )
+ assert len(by_sweep) <= 4
+
+
+@pytest.mark.unit
+async def test_eviction_is_one_pass_not_one_per_victim(limiter, monkeypatch):
+ """O(n*k) vs O(n log n), measured rather than asserted about.
+
+ The min()-per-victim version was functionally correct -- every behavioural
+ test here passed with it -- and took 66 s of blocking CPU to evict 28672
+ entries, inside an async sweep() the sweeper calls directly. So the whole
+ bot freezes for a minute, under exactly the id-flood this bound exists to
+ survive. No functional assertion can see that; only the clock can.
+
+ The budget is 2 s against a correct runtime of ~0.05 s, so it is nowhere
+ near the point where a loaded CI box could fail it, while the O(n*k) form
+ is 30x over.
+ """
+ import time
+
+ monkeypatch.setattr(rl_mod, "MAX_TRACKED_USERS", 4096)
+ now = time.time()
+ for i in range(4096 * 8):
+ limiter.user_requests[i] = deque([now - (4096 * 8 - i) * 0.001])
+
+ started = time.perf_counter()
+ dropped = limiter._evict_inactive_users(4096)
+ elapsed = time.perf_counter() - started
+
+ assert dropped == 4096 * 7, f"evicted {dropped}, expected {4096 * 7}"
+ assert len(limiter.user_requests) == 4096
+ assert elapsed < 2.0, (
+ f"evicting {4096 * 7} entries took {elapsed:.1f}s -- that is the O(n*k) "
+ "min()-per-victim form, and sweep() runs it on the event loop"
+ )
+
+
+@pytest.mark.unit
+async def test_the_eviction_body_has_no_per_victim_loop():
+ """The same property structurally, so it is pinned even if the clock test
+ is skipped or the table happens to be small on some host.
+
+ Parsed rather than grepped: a naive "while" in the source also matches the
+ word in the docstring.
+ """
+ import ast
+ import inspect
+
+ func = next(
+ n
+ for n in ast.walk(ast.parse(inspect.getsource(rl_mod)))
+ if isinstance(n, ast.FunctionDef) and n.name == "_evict_inactive_users"
+ )
+ loops = [n for n in ast.walk(func) if isinstance(n, (ast.While, ast.comprehension))]
+ assert not loops, (
+ f"_evict_inactive_users contains {len(loops)} loop(s) at lines "
+ f"{[n.lineno for n in loops]}; it must sort once and drop the prefix"
+ )
+ sorts = [
+ n
+ for n in ast.walk(func)
+ if isinstance(n, ast.Call) and getattr(n.func, "id", None) == "sorted"
+ ]
+ assert len(sorts) == 1, f"expected exactly one sorted() call, found {len(sorts)}"
+
+
+@pytest.mark.unit
+async def test_the_singleton_is_actually_using_the_constant(limiter, monkeypatch):
+ """MAX_TRACKED_USERS was defined twice: a new 50_000 immediately shadowed by
+ the pre-existing 4096 that sweep() uses. Ruff's F811 does not flag a plain
+ module-level rebinding, so the blocking lint gate stayed green over dead
+ code that documented a bound nothing used."""
+ import inspect
+
+ source = inspect.getsource(rl_mod)
+ assert source.count("MAX_TRACKED_USERS =") == 1, (
+ "MAX_TRACKED_USERS is assigned more than once -- the later binding wins "
+ "and the earlier one is dead code"
+ )
+ assert rate_limiter is not None
diff --git a/tests/test_unit/test_rate_limiter_charge.py b/tests/test_unit/test_rate_limiter_charge.py
new file mode 100644
index 00000000..2dde7140
--- /dev/null
+++ b/tests/test_unit/test_rate_limiter_charge.py
@@ -0,0 +1,154 @@
+"""Charge-at-exec (AGENTS.md H6b) -- the invariant the queue is built on.
+
+The sliding window must be charged exactly once, at execution time, never on
+admission: a request that sits in the queue or is requeued after a FloodWait
+must not burn the user's window on server-side failures. Nothing drove
+_process_one on a *runnable* item before these tests, so the charge block
+could be deleted and the whole suite still passed.
+"""
+
+import time
+
+import pytest
+from pyrogram.errors import FloodWait
+
+from Thunder.utils import rate_limiter as rl_mod
+from Thunder.utils.rate_limiter import TokenBucket, rate_limiter
+
+
+def _arm(monkeypatch, *, period=60, max_requests=100, rps=1000.0):
+ """Make the singleton behave like a healthy limiter with an empty window."""
+ rl = rate_limiter
+ rl.enabled = True
+ rl._initialization_error = False
+ rl.max_requests_per_period = max_requests
+ rl.rate_limit_period_seconds = period
+ rl.global_rate_limit_enabled = False
+ monkeypatch.setattr(rl, "is_owner", lambda user_id: False)
+ monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=rps, burst_multiplier=2.0))
+ return rl
+
+
+def _item(uid, handler, *, queue="request_queue"):
+ return {
+ "user_id": uid,
+ "func": handler,
+ "args": (),
+ "kwargs": {},
+ "file_identifier": None,
+ "not_before": 0.0,
+ "charged": False,
+ }
+
+
+@pytest.mark.unit
+async def test_runnable_item_is_charged_once_at_execution(monkeypatch):
+ rl = _arm(monkeypatch)
+ uid = 90_301
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+
+ ran = []
+
+ async def handler(*args, **kwargs):
+ ran.append(kwargs.get("notification_msg"))
+
+ request = _item(uid, handler)
+ request["kwargs"] = {"notification_msg": "notice"}
+ rl.request_queue.append(request)
+
+ assert await rl._process_one() is True
+
+ assert ran, "runnable item never executed"
+ assert len(rl.user_requests[uid]) == 1, "charge-at-exec did not record exactly one hit"
+ assert request["charged"] is True
+
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+
+
+@pytest.mark.unit
+async def test_charged_item_is_not_charged_again(monkeypatch):
+ """A FloodWait requeue must not re-charge: one upload burning the user's
+ whole window on retries is the exact failure H6b exists to prevent."""
+ rl = _arm(monkeypatch)
+ uid = 90_302
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+
+ async def always_floods(*args, **kwargs):
+ raise FloodWait(1)
+
+ request = _item(uid, always_floods)
+ rl.request_queue.append(request)
+
+ await rl._process_one()
+ after_first = len(rl.user_requests[uid])
+ assert after_first == 1
+ assert request["charged"] is True
+ # requeued with a delay; clear not_before so the next pass is runnable
+ requeued = rl.request_queue or rl.priority_queue
+ assert requeued, "FloodWait did not requeue the item"
+ requeued[0]["not_before"] = 0.0
+
+ await rl._process_one()
+
+ assert len(rl.user_requests[uid]) == 1, "requeue re-charged the sliding window"
+
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+
+
+@pytest.mark.unit
+async def test_admission_does_not_charge_before_execution(monkeypatch):
+ """check_limits(record=False) is advisory; the window only moves when the
+ request actually runs."""
+ rl = _arm(monkeypatch)
+ uid = 90_303
+ rl.user_requests.pop(uid, None)
+
+ assert await rl.check_limits(uid, record=False) is True
+ assert len(rl.user_requests[uid]) == 0
+
+ assert await rl.check_limits(uid, record=True) is True
+ assert len(rl.user_requests[uid]) == 1
+
+ rl.user_requests.pop(uid, None)
+
+
+@pytest.mark.unit
+async def test_process_one_returns_false_on_an_empty_queue(monkeypatch):
+ rl = _arm(monkeypatch)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+ assert await rl._process_one() is False
+
+
+@pytest.mark.unit
+async def test_deferred_item_is_not_charged(monkeypatch):
+ """An item parked until later must not consume the window either."""
+ rl = _arm(monkeypatch)
+ uid = 90_304
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+
+ async def handler(*args, **kwargs):
+ raise AssertionError("deferred item must not run")
+
+ request = _item(uid, handler)
+ request["not_before"] = time.time() + 3600
+ rl.request_queue.append(request)
+
+ await rl._process_one()
+
+ assert len(rl.user_requests.get(uid, ())) == 0, "deferred item was charged"
+ rl.request_queue.clear()
+
+
+@pytest.mark.unit
+def test_module_exposes_max_attempts():
+ assert rl_mod.MAX_REQUEST_ATTEMPTS >= 1
diff --git a/tests/test_unit/test_rate_limiter_fairness.py b/tests/test_unit/test_rate_limiter_fairness.py
new file mode 100644
index 00000000..9b4f8ba6
--- /dev/null
+++ b/tests/test_unit/test_rate_limiter_fairness.py
@@ -0,0 +1,151 @@
+"""Fairness between the priority and regular queues.
+
+A continuous stream of owner/authorized requests used to starve regular users
+indefinitely: the drain took the priority queue whenever it was non-empty. The
+position estimate was honest about the wait, but nothing bounded it. The drain
+now alternates while both tiers are populated.
+"""
+
+import time
+
+import pytest
+
+from Thunder.utils.rate_limiter import TokenBucket, rate_limiter
+
+UID_REG = 90_501
+UID_PRIO = 90_502
+
+
+def _arm(monkeypatch, ran: list[int]) -> None:
+ rl = rate_limiter
+ rl.enabled = True
+ rl._initialization_error = False
+ rl.max_requests_per_period = 10_000
+ rl.rate_limit_period_seconds = 60
+ rl.global_rate_limit_enabled = False
+ rl._prefer_regular = False
+ monkeypatch.setattr(rl, "is_owner", lambda user_id: False)
+ monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=10_000.0, burst_multiplier=2.0))
+ for uid in (UID_REG, UID_PRIO):
+ rl.user_requests.pop(uid, None)
+ rl.request_queue.clear()
+ rl.priority_queue.clear()
+ return rl
+
+
+def _item(uid: int, ran: list[int]) -> dict:
+ async def handler(*args, **kwargs):
+ ran.append(uid)
+
+ return {
+ "user_id": uid,
+ "func": handler,
+ "args": (),
+ "kwargs": {},
+ "file_identifier": None,
+ "not_before": 0.0,
+ "charged": True, # already charged: these tests are about ordering only
+ }
+
+
+def _fill(rl, uid: int, count: int, ran: list[int], *, queue: str) -> None:
+ target = rl.priority_queue if queue == "priority" else rl.request_queue
+ for _ in range(count):
+ target.append(_item(uid, ran))
+
+
+@pytest.mark.unit
+async def test_priority_only_is_drained_completely(monkeypatch):
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ _fill(rl, UID_PRIO, 3, ran, queue="priority")
+
+ for _ in range(3):
+ assert await rl._process_one() is True
+
+ assert not rl.priority_queue
+ assert ran == [UID_PRIO] * 3
+ assert not rl.request_queue
+
+
+@pytest.mark.unit
+async def test_regular_only_is_drained_completely(monkeypatch):
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ _fill(rl, UID_REG, 3, ran, queue="regular")
+
+ for _ in range(3):
+ assert await rl._process_one() is True
+
+ assert ran == [UID_REG] * 3
+ assert not rl.priority_queue
+
+
+@pytest.mark.unit
+async def test_a_continuous_priority_stream_cannot_starve_regulars(monkeypatch):
+ """A priority item is refilled before every pop, so priority is *never*
+ empty -- which is exactly the condition under which the old
+ priority-first drain starved regulars indefinitely. Every regular request
+ must still run."""
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ _fill(rl, UID_PRIO, 3, ran, queue="priority")
+ _fill(rl, UID_REG, 3, ran, queue="regular")
+
+ served_regular = 0
+ for _ in range(12):
+ assert await rl._process_one() is True
+ if ran and ran[-1] == UID_REG:
+ served_regular += 1
+ # keep the priority queue permanently non-empty
+ rl.priority_queue.append(_item(UID_PRIO, ran))
+
+ assert served_regular == 3, f"regular requests starved ({served_regular}/3 served)"
+ assert not rl.request_queue
+
+
+@pytest.mark.unit
+async def test_alternation_starts_with_priority(monkeypatch):
+ """The owner keeps its edge on ties -- only the starvation bound changes."""
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ rl.priority_queue.append(_item(UID_PRIO, ran))
+ rl.request_queue.append(_item(UID_REG, ran))
+
+ assert await rl._process_one() is True
+ assert ran == [UID_PRIO]
+
+ assert await rl._process_one() is True
+ assert ran == [UID_PRIO, UID_REG]
+
+
+@pytest.mark.unit
+async def test_deferred_regular_item_is_rotated_not_lost(monkeypatch):
+ """A parked regular item comes back to the queue instead of blocking
+ priority work or vanishing."""
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ deferred = _item(UID_REG, ran)
+ deferred["not_before"] = time.time() + 3600
+ rl.request_queue.append(deferred)
+ rl.priority_queue.append(_item(UID_PRIO, ran))
+
+ assert await rl._process_one() is True # tie -> priority
+ assert await rl._process_one() is True # regular, not runnable, rotated
+
+ assert ran == [UID_PRIO] # the deferred item was not executed
+ assert len(rl.request_queue) == 1, "deferred item was lost"
+ assert not rl.priority_queue
+ assert rl._prefer_regular is False # toggle resets when a tier empties
+
+
+@pytest.mark.unit
+async def test_fairness_toggle_resets_when_a_tier_empties(monkeypatch):
+ ran: list[int] = []
+ rl = _arm(monkeypatch, ran)
+ rl.priority_queue.append(_item(UID_PRIO, ran))
+ rl.request_queue.append(_item(UID_REG, ran))
+
+ await rl._process_one() # priority (toggle flips to prefer-regular)
+ await rl._process_one() # regular, both tiers now empty
+ assert rl._prefer_regular is False
diff --git a/tests/test_unit/test_rate_limiter_park.py b/tests/test_unit/test_rate_limiter_park.py
new file mode 100644
index 00000000..93afd729
--- /dev/null
+++ b/tests/test_unit/test_rate_limiter_park.py
@@ -0,0 +1,71 @@
+"""Regression: deferred requeue must park the worker pool, not busy-spin."""
+
+import time
+
+import pytest
+
+from Thunder.utils.rate_limiter import RateLimiter
+
+
+@pytest.mark.unit
+async def test_all_deferred_parks_pool():
+ rl = RateLimiter()
+ rl.request_event.set()
+
+ async def noop(*a, **k):
+ pass
+
+ request = {
+ "func": noop,
+ "user_id": 123,
+ "args": (),
+ "kwargs": {},
+ "not_before": time.time() + 60,
+ }
+ await rl._requeue_request(request, "regular", delay=60)
+
+ # worker pops the deferred item, rotates it -- pool must park
+ handled = await rl._process_one()
+ assert handled is True
+ assert rl.request_event.is_set() is False # parked: no busy-spin
+ assert rl._deferred_timer is not None
+ assert len(rl.request_queue) == 1 # item still queued
+
+ # a new enqueue wakes the pool immediately (timer + event both work)
+ rl.request_event.set()
+ rl._deferred_timer.cancel()
+ rl._deferred_timer = None
+ await rl.shutdown()
+
+
+@pytest.mark.unit
+async def test_runnable_item_prevents_parking():
+ rl = RateLimiter()
+ rl.request_event.set()
+
+ async def noop(*a, **k):
+ pass
+
+ deferred = {
+ "func": noop,
+ "user_id": 1,
+ "args": (),
+ "kwargs": {},
+ "not_before": time.time() + 60,
+ }
+ await rl._requeue_request(deferred, "regular", delay=60)
+
+ runnable = {
+ "func": noop,
+ "user_id": 2,
+ "args": (),
+ "kwargs": {},
+ "not_before": 0.0,
+ }
+ async with rl.request_lock:
+ rl.request_queue.append(runnable)
+
+ handled = await rl._process_one() # pops deferred -> rotates
+ assert handled is True
+ assert rl.request_event.is_set() is True # NOT parked: runnable item exists
+ await rl.shutdown()
diff --git a/tests/test_unit/test_redaction.py b/tests/test_unit/test_redaction.py
new file mode 100644
index 00000000..e241d775
--- /dev/null
+++ b/tests/test_unit/test_redaction.py
@@ -0,0 +1,52 @@
+"""Shared redaction regexes."""
+
+import pytest
+
+from Thunder.utils.logger import hash_path_token, redact_secrets
+
+
+@pytest.mark.unit
+def test_bot_token_redacted():
+ text = "starting bot with token 1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567"
+ out = redact_secrets(text)
+ assert "1234567890:ABCdef" not in out
+ assert "***REDACTED***" in out
+
+
+@pytest.mark.unit
+def test_mongo_uri_redacted():
+ text = "connecting to mongodb+srv://user:supersecret@cluster.example.net/db"
+ out = redact_secrets(text)
+ assert "supersecret" not in out
+
+
+@pytest.mark.unit
+def test_clean_text_untouched():
+ text = "no secrets here, just 42 and a link https://example.com/f/abc/file"
+ assert redact_secrets(text) == text
+
+
+@pytest.mark.unit
+def test_hash_path_token_is_stable_and_short():
+ a = hash_path_token("1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567")
+ b = hash_path_token("1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567")
+ c = hash_path_token("different")
+ assert a == b and a != c and len(a) == 8
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "text,leaked",
+ [
+ # API_HASH_PATTERN: api_hash[:=] + 32 hex (contextual so file hashes still log)
+ ("config api_hash = '0123456789abcdef0123456789abcdef' done", "0123456789abcdef"),
+ # SESSION_STRING_PATTERN: session_string[:=] + 40+ base64url chars
+ ("login session_string='AbC123_-xYzAbC123_-xYzAbC123_-xYzAbC123_-xYz' ok", "AbC123_-xYz"),
+ # ACTIVATION_TOKEN_PATTERN: ?start= + 43 urlsafe chars
+ ("open https://t.me/MyBot?start=" + "a" * 43 + " now", "a" * 43),
+ ],
+)
+def test_secret_patterns_redacted(text, leaked):
+ out = redact_secrets(text)
+ assert leaked not in out
+ assert "***REDACTED***" in out
diff --git a/tests/test_unit/test_registry.py b/tests/test_unit/test_registry.py
new file mode 100644
index 00000000..a7bd9192
--- /dev/null
+++ b/tests/test_unit/test_registry.py
@@ -0,0 +1,39 @@
+"""Registry drives the menu (owner-only hidden) and AGENTS.md drift."""
+
+import re
+from pathlib import Path
+
+import pytest
+
+from Thunder.bot.registry import COMMANDS, bot_commands, help_command_rows
+
+REPO_ROOT = Path(__file__).resolve().parent.parent.parent
+
+
+@pytest.mark.unit
+def test_owner_only_commands_hidden_from_menu():
+ menu_names = {c.command for c in bot_commands()}
+ owner_names = {c.name for c in COMMANDS if c.owner_only}
+ assert owner_names.isdisjoint(menu_names)
+ assert {"start", "help", "link", "ping", "dc", "about"} <= menu_names
+
+
+@pytest.mark.unit
+def test_help_rows_match_public_commands():
+ rows = help_command_rows()
+ for cmd in COMMANDS:
+ if cmd.owner_only:
+ assert f"/{cmd.name} " not in rows
+ else:
+ assert f"/{cmd.name} " in rows
+
+
+@pytest.mark.unit
+def test_agents_md_documents_every_command():
+ """Code-gen drift check: AGENTS.md must mention each command name."""
+ agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8")
+ for cmd in COMMANDS:
+ assert re.search(rf"`/{cmd.name}`", agents), (
+ f"AGENTS.md is missing `/{cmd.name}` -- update the commands table "
+ "when editing Thunder/bot/registry.py"
+ )
diff --git a/tests/test_unit/test_safe_call.py b/tests/test_unit/test_safe_call.py
new file mode 100644
index 00000000..54c5a4bc
--- /dev/null
+++ b/tests/test_unit/test_safe_call.py
@@ -0,0 +1,129 @@
+"""Helper semantics: retry-then-retry, exhaustion, timeout."""
+
+import asyncio
+
+import pytest
+from pyrogram.errors import FloodWait
+
+from Thunder.utils.safe_call import tg_call
+
+
+class Flaky:
+ """Calls fail N times with FloodWait, then succeed."""
+
+ def __init__(self, failures: int, wait: float = 0.01):
+ self.failures = failures
+ self.wait = wait
+ self.calls = 0
+
+ async def __call__(self, *args, **kwargs):
+ self.calls += 1
+ if self.calls <= self.failures:
+ raise FloodWait(value=self.wait)
+ return "ok"
+
+
+@pytest.mark.unit
+async def test_retries_through_floodwait():
+ flaky = Flaky(failures=1)
+ assert await tg_call(flaky) == "ok"
+ assert flaky.calls == 2
+
+
+@pytest.mark.unit
+async def test_exhaustion_raises():
+ flaky = Flaky(failures=3)
+ with pytest.raises(FloodWait):
+ await tg_call(flaky, retries=1)
+ assert flaky.calls == 2 # initial + one retry
+
+
+@pytest.mark.unit
+async def test_zero_retries_propagates_immediately():
+ flaky = Flaky(failures=1)
+ with pytest.raises(FloodWait):
+ await tg_call(flaky, retries=0, timeout=0)
+ assert flaky.calls == 1
+
+
+@pytest.mark.unit
+async def test_timeout_fires():
+ async def hang():
+ await asyncio.sleep(5)
+
+ with pytest.raises(asyncio.TimeoutError):
+ await tg_call(hang, timeout=0.05, retries=0)
+
+
+@pytest.mark.unit
+async def test_lightweight_shape_caps_floodwait_sleep(monkeypatch):
+ """Non-media RPCs cap the FloodWait sleep at 30s (wall-clock budget)."""
+ slept = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep)
+
+ async def get_me():
+ raise FloodWait(value=90)
+
+ with pytest.raises(FloodWait):
+ await tg_call(get_me, retries=1, timeout=0)
+ assert slept == [30.0]
+
+
+@pytest.mark.unit
+async def test_media_shape_sleeps_full_floodwait(monkeypatch):
+ """File-transfer shapes (e.g. copy) ride out long FloodWaits up to the
+ 600s media ceiling -- sustained throttle must not fail the ingest path."""
+ slept = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep)
+
+ async def copy(*args, **kwargs):
+ raise FloodWait(value=90)
+
+ with pytest.raises(FloodWait):
+ await tg_call(copy, retries=1)
+ assert slept == [90.0]
+
+
+@pytest.mark.unit
+async def test_media_shape_floodwait_ceiling(monkeypatch):
+ """Waits beyond the 600s media ceiling are clamped, never unbounded."""
+ slept = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep)
+
+ async def copy(*args, **kwargs):
+ raise FloodWait(value=3600)
+
+ with pytest.raises(FloodWait):
+ await tg_call(copy, retries=1)
+ assert slept == [600.0]
+
+
+@pytest.mark.unit
+async def test_max_flood_sleep_overrides_shape_cap(monkeypatch):
+ """Fan-out callers cap the sleep explicitly: a media-shaped copy with
+ max_flood_sleep=30 sleeps 30s on a 3600s flood, then raises."""
+ slept = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep)
+
+ async def copy(*args, **kwargs):
+ raise FloodWait(value=3600)
+
+ with pytest.raises(FloodWait):
+ await tg_call(copy, retries=1, max_flood_sleep=30.0)
+ assert slept == [30.0]
diff --git a/tests/test_unit/test_safe_call_budgets.py b/tests/test_unit/test_safe_call_budgets.py
new file mode 100644
index 00000000..cbc7839f
--- /dev/null
+++ b/tests/test_unit/test_safe_call_budgets.py
@@ -0,0 +1,205 @@
+"""The RPC budget contract (H4a / H8) -- which shapes run unbounded.
+
+The wall-clock budget is chosen from the callable's name. That is only safe if
+the set of unbounded shapes is exactly the set of media calls actually routed
+through tg_call, and if a wrapped callable still resolves to its real name.
+Both properties are pinned here; the FloodWait caps themselves are covered by
+test_safe_call.
+"""
+
+import asyncio
+from functools import partial
+
+import pytest
+from pyrogram.errors import FloodWait
+
+from Thunder.utils import safe_call
+from Thunder.utils.safe_call import UNBOUNDED_SHAPES, tg_call
+
+# Message methods that move a media payload. Spelled out as verb x noun rather
+# than a prefix, because `reply_` also matches reply_text and `edit_` matches
+# edit_reply_markup -- both of which are legitimately budgeted.
+_MEDIA_NOUNS = (
+ "document",
+ "photo",
+ "video",
+ "audio",
+ "animation",
+ "voice",
+ "video_note",
+ "media_group",
+ "paid_media",
+)
+_MESSAGE_MEDIA_SHAPES = {"copy"} | {
+ f"{verb}_{noun}" for verb in ("reply", "edit") for noun in _MEDIA_NOUNS
+}
+
+
+@pytest.mark.unit
+def test_every_media_call_site_is_unbounded():
+ """The real contract is not the set's contents but that no media call site
+ is missing from it. The call sites are read out of the source rather than
+ restating a literal: /log ships a 5 MiB tail and /shell ships arbitrary
+ output, and a 30 s budget on either is a production failure.
+
+ The shape list is derived from pyrofork's own Client methods rather than
+ retyped. It used to name nine shapes and omit send_animation,
+ send_video_note, send_voice, send_paid_media and copy_message -- so
+ adding one of those as a tg_call site would inherit the 30 s budget while
+ this test stayed green.
+ """
+ import inspect
+ import pathlib
+ import re
+
+ from pyrogram import Client
+ from pyrogram.types import Message
+
+ import Thunder
+
+ root = pathlib.Path(Thunder.__file__).parent
+
+ # every method that transfers a payload: the budget cannot apply to any of
+ # them. Text and metadata shapes are excluded on purpose -- the API caps
+ # them (4096 chars for a message), so a 30 s budget is correct there. The
+ # contract being protected is about media, not about every send_* method.
+ text_or_metadata = {
+ "send_chat_action",
+ "send_code",
+ "send_contact",
+ "send_dice",
+ "send_game",
+ "send_inline_bot_result",
+ "send_location",
+ "send_message",
+ "send_paid_reaction",
+ "send_reaction",
+ "send_recovery_code",
+ "send_todo",
+ "send_venue",
+ "send_web_page",
+ }
+ media_methods = {
+ name
+ for name in dir(Client)
+ if name.startswith(("send_", "copy", "stream_", "download_", "reply_"))
+ and name not in text_or_metadata
+ }
+ # Message carries the other half: Message.copy is the ingest forward and
+ # Message.reply_document ships /log's 5 MiB tail and /shell's output.
+ media_methods |= {name for name in dir(Message) if name in _MESSAGE_MEDIA_SHAPES}
+ assert len(media_methods) > 20, (
+ f"only found {len(media_methods)} transfer methods -- the derivation is broken"
+ )
+ # the two shapes actually wired unbounded, so a regression that moved the
+ # call off them cannot pass silently
+ assert {"copy", "reply_document"} <= media_methods, (
+ f"the live call sites use Message.copy and Message.reply_document; "
+ f"derived set has {sorted(media_methods & {'copy', 'reply_document'})}"
+ )
+ # the scan and the derived set must agree, so a shape can never be quietly
+ # missing from the regex while the comparison still passes
+ scanned = "|".join(sorted(media_methods))
+ media_shapes = re.compile(rf"tg_call\(\s*[\w.]*?({scanned})\b")
+ found: set[str] = set()
+ for path in root.rglob("*.py"):
+ found |= {m.group(1) for m in media_shapes.finditer(path.read_text(encoding="utf-8"))}
+
+ assert found, "scan found no media call sites -- the pattern is stale"
+ assert found <= UNBOUNDED_SHAPES, f"media calls without a budget: {found - UNBOUNDED_SHAPES}"
+ assert inspect.isfunction(safe_call._env_timeout)
+
+
+@pytest.mark.unit
+def test_lightweight_shapes_get_the_env_budget():
+ """Assert against Var, not against _env_timeout() itself.
+
+ _default_timeout returns _env_timeout() by construction, so comparing the
+ two asserted f(x) == f() and pinned nothing: hardcoding _env_timeout to
+ DEFAULT_RPC_TIMEOUT_SECONDS and deleting the Var.TG_RPC_TIMEOUT_SECONDS read
+ left every test green, and the documented env knob had no coverage at all.
+ """
+ from Thunder.vars import Var
+
+ async def reply_text():
+ return None
+
+ expected = float(Var.TG_RPC_TIMEOUT_SECONDS)
+ assert expected > 0, "TG_RPC_TIMEOUT_SECONDS is not a usable number"
+ assert safe_call._env_timeout() == expected, (
+ f"the RPC budget ignores TG_RPC_TIMEOUT_SECONDS ({Var.TG_RPC_TIMEOUT_SECONDS})"
+ )
+ assert safe_call._default_timeout(reply_text) == expected
+
+
+@pytest.mark.unit
+def test_the_env_budget_knob_is_read_not_hardcoded():
+ """Distinct from the test above: this one reads the source, so it fails even
+ if the value happens to match today."""
+ import inspect
+
+ src = inspect.getsource(safe_call._env_timeout)
+ assert "TG_RPC_TIMEOUT_SECONDS" in src, "_env_timeout no longer reads the configured timeout"
+
+
+@pytest.mark.unit
+def test_reply_document_is_unbounded():
+ """/log tails up to 5 MiB and /shell ships arbitrary output; both go out
+ through reply_document, which must not inherit the 30 s budget."""
+
+ async def reply_document(*args, **kwargs):
+ return None
+
+ assert safe_call._default_timeout(reply_document) is None
+
+
+@pytest.mark.unit
+def test_partial_wrapped_media_shape_keeps_its_budget():
+ """functools.partial hides the callable behind .func; without unwrapping it
+ a wrapped copy would look unnamed and inherit the 30s budget."""
+
+ async def copy(*args, **kwargs):
+ return None
+
+ assert safe_call._call_name(partial(copy, chat_id=1)) == "copy"
+ assert safe_call._default_timeout(partial(copy, chat_id=1)) is None
+
+
+@pytest.mark.unit
+async def test_partial_wrapped_media_shape_sleeps_the_media_cap(monkeypatch):
+ slept = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep)
+
+ async def copy(*args, **kwargs):
+ raise FloodWait(value=90)
+
+ with pytest.raises(FloodWait):
+ await tg_call(partial(copy, chat_id=1), retries=1)
+ assert slept == [90.0], "partial-wrapped media call lost the 600s ceiling"
+
+
+@pytest.mark.unit
+async def test_explicit_zero_opts_out_of_the_budget():
+ """The documented escape hatch for a new media call site."""
+
+ async def send_document(*args, **kwargs):
+ await asyncio.sleep(0.2)
+ return "ok"
+
+ assert await tg_call(send_document, timeout=0, retries=0) == "ok"
+
+
+@pytest.mark.unit
+async def test_explicit_timeout_overrides_the_shape_default(monkeypatch):
+ """A media shape given an explicit budget honours it -- the reverse
+ direction also has to hold, or a caller could not tighten a call."""
+
+ async def copy(*args, **kwargs):
+ await asyncio.sleep(5)
+
+ with pytest.raises(asyncio.TimeoutError):
+ await tg_call(copy, timeout=0.05, retries=0)
diff --git a/tests/test_unit/test_shortener.py b/tests/test_unit/test_shortener.py
new file mode 100644
index 00000000..adbd48dd
--- /dev/null
+++ b/tests/test_unit/test_shortener.py
@@ -0,0 +1,100 @@
+"""Plugin registry lookup, offline builders, host validation."""
+
+import pytest
+
+from Thunder.utils.shortener import (
+ BitlyPlugin,
+ CuttLyPlugin,
+ GenericShortenerPlugin,
+ LinkvertisePlugin,
+ OuoIoPlugin,
+ ShortenerSystem,
+)
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "domain,expected",
+ [
+ ("bitly.com", BitlyPlugin),
+ ("shrinkme.dev", GenericShortenerPlugin), # generic fallback
+ ("bitly.com.evil.com", GenericShortenerPlugin), # lookalike rejected
+ ],
+)
+def test_registry_lookup(domain, expected):
+ system = ShortenerSystem()
+ assert system._get_plugin_class(domain) is expected
+
+
+@pytest.mark.unit
+async def test_linkvertise_offline_constructor():
+ plugin = LinkvertisePlugin()
+ out = await plugin.shorten(None, "https://example.com/file", "12345", "linkvertise.com")
+ assert any(
+ out.startswith(prefix)
+ for prefix in (
+ "https://link-to.net/",
+ "https://up-to-down.net/",
+ "https://direct-link.net/",
+ "https://file-link.net/",
+ )
+ )
+ assert "12345" in out
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "short_url,domain,expected",
+ [
+ ("https://shrinkme.dev/xAbc", "shrinkme.dev", True),
+ ("https://evil.example/xAbc", "shrinkme.dev", False),
+ ("https://shrinkme.dev.evil.io/xAbc", "shrinkme.dev", False),
+ ],
+)
+def test_host_validation(short_url, domain, expected):
+ assert GenericShortenerPlugin._validate_short_url(short_url, domain) is expected
+
+
+@pytest.mark.unit
+async def test_short_url_passthrough_when_not_ready():
+ system = ShortenerSystem()
+ assert await system.short_url("https://example.com") == "https://example.com"
+
+
+@pytest.mark.unit
+async def test_cache_hit_is_returned_without_http():
+ system = ShortenerSystem()
+ system.ready = True
+ system._cache["https://long.example/a"] = "https://shrinkme.dev/xyz"
+ assert await system.short_url("https://long.example/a") == "https://shrinkme.dev/xyz"
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "domain,plugin,expected",
+ [
+ # legit hosts match (exact, subdomain, FQDN trailing dot)
+ ("bitly.com", BitlyPlugin, True),
+ ("bit.ly", BitlyPlugin, True),
+ ("www.bit.ly", BitlyPlugin, True),
+ ("bit.ly.", BitlyPlugin, True),
+ ("linkvertise.com", LinkvertisePlugin, True),
+ ("sub.linkvertise.com", LinkvertisePlugin, True),
+ ("ouo.io", OuoIoPlugin, True),
+ ("cutt.ly", CuttLyPlugin, True),
+ # lookalikes that substring matching used to accept must not match
+ ("evil.com/bitly.com", BitlyPlugin, False),
+ ("bitly.com.evil.com", BitlyPlugin, False),
+ ("notbitly.com", BitlyPlugin, False),
+ ("bit.ly.evil.io", BitlyPlugin, False),
+ ("evillinkvertise.com", LinkvertisePlugin, False),
+ ("linkvertise.com.evil.net", LinkvertisePlugin, False),
+ ("ouo.io.evil.dev", OuoIoPlugin, False),
+ ("cutt.ly.evil.org", CuttLyPlugin, False),
+ # cross-provider isolation
+ ("ouo.io", BitlyPlugin, False),
+ ("bit.ly", OuoIoPlugin, False),
+ ],
+)
+def test_plugin_host_matching(domain, plugin, expected):
+ assert plugin.matches(domain) is expected
diff --git a/tests/test_unit/test_shutdown_budget.py b/tests/test_unit/test_shutdown_budget.py
new file mode 100644
index 00000000..ac6cd83d
--- /dev/null
+++ b/tests/test_unit/test_shutdown_budget.py
@@ -0,0 +1,385 @@
+"""Shutdown budget helpers -- the container grace period depends on them.
+
+SIGTERM arrives, the process has a bounded window to flush the touch buffer
+and close Mongo before the runtime SIGKILLs it. These helpers decide how much
+of that window each step gets, so they are pinned rather than left implicit.
+"""
+
+import asyncio
+import inspect
+
+import pytest
+
+from Thunder import __main__ as main_mod
+
+
+def _deadline_in(seconds: float) -> float:
+ return asyncio.get_running_loop().time() + seconds
+
+
+@pytest.mark.unit
+async def test_step_budget_is_capped_while_budget_remains():
+ assert main_mod._step_budget(_deadline_in(30.0)) == main_mod._TEARDOWN_STEP_SECONDS
+
+
+@pytest.mark.unit
+async def test_step_budget_never_drops_to_zero():
+ """A spent budget must still let db.close() run. Timing out at 0 s would
+ record an error for a step that never ran and exit non-zero on a healthy
+ stop -- which on Heroku/Render means a restart loop."""
+ assert main_mod._step_budget(_deadline_in(-100.0)) == main_mod._TEARDOWN_FLOOR_SECONDS
+ assert main_mod._TEARDOWN_FLOOR_SECONDS > 0
+
+
+@pytest.mark.unit
+async def test_step_budget_uses_whatever_is_left():
+ """Midway through the budget, a step gets the remainder (above the floor)."""
+ loop = asyncio.get_running_loop()
+ assert main_mod._step_budget(loop.time() + 4.0) == pytest.approx(4.0, abs=0.5)
+
+
+@pytest.mark.unit
+async def test_total_teardown_fits_the_PaaS_grace_period():
+ """Worst case is the shared budget, plus one floor per step that draws on it,
+ plus the clients step's own slice. Both counts are read out of the plan, so
+ adding or re-bucketing a step cannot silently invalidate the bound.
+
+ The bound that matters is the SMALLEST grace period we ship for, not
+ compose's comfortable 60s: Heroku, Render and Kubernetes all SIGKILL at 30s
+ and neither app.json nor render.yaml can raise it. A 40s budget x 2s floors
+ gave a 50s worst case, so a stop could be killed before the touch flush ran
+ -- the exact outcome this budget exists to prevent.
+ """
+ plan = main_mod._teardown_plan()
+ shared = [s for s in plan if s.shared]
+ exclusive = [s for s in plan if not s.shared]
+
+ assert plan, "the teardown plan is empty?"
+ assert shared, "no step draws on the shared budget?"
+ assert exclusive, "no step has its own slice -- is the clients step still bucketed?"
+
+ worst_case = (
+ main_mod._SHUTDOWN_BUDGET_SECONDS
+ + len(shared) * main_mod._TEARDOWN_FLOOR_SECONDS
+ # app_runner.cleanup() is budgeted through _step_budget, which grants
+ # the same floor, and it runs OUTSIDE _teardown_plan -- so counting
+ # only the plan's steps under-counted the bound by a full floor.
+ + main_mod._PRE_PLAN_FLOOR_STEPS * main_mod._TEARDOWN_FLOOR_SECONDS
+ # the CEILING, not the cap for whatever client count is configured:
+ # the bound has to hold for the worst one
+ + sum(main_mod._CLIENT_STOP_SECONDS for _ in exclusive)
+ )
+
+ # Spelled out here rather than read from the module. The PaaS limit is a
+ # fact about Heroku/Render/Kubernetes, not a constant we get to define --
+ # and a test that read _MIN_PAAS_GRACE_SECONDS would merely agree with the
+ # module's own import-time assert, so raising both would keep it green.
+ REAL_PAAS_GRACE_SECONDS = 30.0
+ assert worst_case < REAL_PAAS_GRACE_SECONDS, (
+ f"shutdown can take {worst_case:.1f}s but Heroku, Render and Kubernetes "
+ f"only allow {REAL_PAAS_GRACE_SECONDS:.0f}s -- a stop gets SIGKILLed "
+ "before the touch flush and db.close() run"
+ )
+ assert main_mod._MIN_PAAS_GRACE_SECONDS <= REAL_PAAS_GRACE_SECONDS, (
+ f"_MIN_PAAS_GRACE_SECONDS claims {main_mod._MIN_PAAS_GRACE_SECONDS}s, "
+ f"more than the {REAL_PAAS_GRACE_SECONDS:.0f}s the PaaS manifests allow"
+ )
+ # the shipped constants must agree with the plan, not restate it
+ assert len(shared) == main_mod._TEARDOWN_STEP_COUNT, (
+ f"_TEARDOWN_STEP_COUNT says {main_mod._TEARDOWN_STEP_COUNT} but the plan "
+ f"has {len(shared)} steps drawing on the shared budget"
+ )
+ assert pytest.approx(worst_case) == main_mod._SHUTDOWN_WORST_CASE_SECONDS, (
+ f"the module's own worst-case figure is "
+ f"{main_mod._SHUTDOWN_WORST_CASE_SECONDS}s but the plan gives {worst_case}s"
+ )
+
+
+@pytest.mark.unit
+async def test_the_clients_step_gets_its_own_slice_and_the_slice_is_what_binds(monkeypatch):
+ """The slice must actually be the GRANT, not just a stored number.
+
+ This is the defect a tuple-shape assertion could never see. The grant is
+ min(ceiling, budget_left), so while the clients step drew on the shared
+ budget its ceiling was capped by what the four cheap steps left over, and
+ 2+ clients were cut anyway. Proving it requires running _safe_teardown_step
+ and reading the timeout it used.
+ """
+ seen: list[float] = []
+
+ async def spy(awaitable, timeout=None):
+ seen.append(timeout)
+ await awaitable
+ return None
+
+ async def noop():
+ return None
+
+ cap = main_mod._CLIENT_STOP_SECONDS
+ monkeypatch.setattr(main_mod.asyncio, "wait_for", spy)
+ loop = asyncio.get_running_loop()
+ # deliberately generous: the slice must be granted even with the whole
+ # shared budget still unspent, which is the case that used to be impossible
+ deadline = loop.time() + main_mod._SHUTDOWN_BUDGET_SECONDS + 60.0
+
+ await main_mod._safe_teardown_step(noop, "clients", [], deadline, cap=cap, shared=False)
+
+ assert seen == [cap], f"the clients step was granted {seen}, not its own cap of {cap}"
+ assert cap > main_mod._SHUTDOWN_BUDGET_SECONDS, (
+ f"the cap ({cap}s) does not exceed the shared budget "
+ f"({main_mod._SHUTDOWN_BUDGET_SECONDS}s), so `shared=False` is not what "
+ "makes it bind -- the exemption is doing nothing"
+ )
+
+
+@pytest.mark.unit
+async def test_a_shared_step_is_still_bounded_by_the_shared_budget(monkeypatch):
+ """The exemption must be narrow: every other step still shares."""
+ seen: list[float] = []
+
+ async def spy(awaitable, timeout=None):
+ seen.append(timeout)
+ await awaitable
+ return None
+
+ async def noop():
+ return None
+
+ monkeypatch.setattr(main_mod.asyncio, "wait_for", spy)
+ loop = asyncio.get_running_loop()
+ # the budget and the step cap are both 8s, so a deadline at exactly the
+ # budget makes this a coin flip on microseconds. Give it room so the step
+ # cap is unambiguously the binding constraint.
+ deadline = loop.time() + main_mod._SHUTDOWN_BUDGET_SECONDS + 60.0
+
+ await main_mod._safe_teardown_step(noop, "database", [], deadline, shared=True)
+
+ assert seen == [main_mod._TEARDOWN_STEP_SECONDS], (
+ f"a shared step was granted {seen} -- it must be the per-step ceiling, not something larger"
+ )
+
+
+@pytest.mark.unit
+async def test_a_shared_step_is_cut_once_the_budget_is_gone(monkeypatch):
+ """The other half: a spent budget must not grant a shared step its ceiling."""
+ seen: list[float] = []
+
+ async def spy(awaitable, timeout=None):
+ seen.append(timeout)
+ await awaitable
+ return None
+
+ async def noop():
+ return None
+
+ monkeypatch.setattr(main_mod.asyncio, "wait_for", spy)
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() - 5.0 # already spent
+
+ await main_mod._safe_teardown_step(noop, "database", [], deadline, shared=True)
+
+ assert seen == [main_mod._TEARDOWN_FLOOR_SECONDS], (
+ f"a shared step past its deadline was granted {seen}; it should get the "
+ "floor so db.close() still runs"
+ )
+
+
+@pytest.mark.unit
+async def test_the_teardown_plan_is_ordered_and_the_clients_step_has_its_own_slice():
+ """The wiring itself, not the clock.
+
+ A test that merely watches shutdown_services finish cannot tell a correct
+ per-step cap from one that was never passed: with the cap pinned back to
+ the generic 8s the multi-client stop still completed, because a test's fake
+ cleanup returns long before any cap is reached.
+ """
+ plan = main_mod._teardown_plan()
+ names = [s.name for s in plan]
+
+ assert names == ["rate limiter", "touch buffer", "shortener", "clients", "database"], (
+ f"teardown order changed: {names}"
+ )
+
+ clients = next(s for s in plan if s.name == "clients")
+ assert clients.cap == main_mod._CLIENT_STOP_SECONDS, (
+ f"the clients step is capped at {clients.cap}s; the generic "
+ f"{main_mod._TEARDOWN_STEP_SECONDS}s ceiling would cut a healthy stop"
+ )
+ assert clients.cap > main_mod._TEARDOWN_STEP_SECONDS, (
+ f"the clients slice ({clients.cap}s) is not larger than the generic step "
+ "ceiling, so it would be cut exactly as before"
+ )
+ assert not clients.shared, (
+ "the clients step draws on the shared budget, so its cap is limited by "
+ "whatever the other four steps left over"
+ )
+ for step in plan:
+ if step.name == "clients":
+ continue
+ assert step.cap is None, f"{step.name} has an unexpected cap {step.cap}"
+ assert step.shared, f"{step.name} should draw on the shared budget"
+
+
+@pytest.mark.unit
+async def test_the_clients_slice_is_flat_and_says_so():
+ """Per-client scaling CANNOT fit the grace period, so it must not be faked.
+
+ An earlier version computed min(12, n * 10). Because 12 < 2*10, the min()
+ picked the constant for EVERY n >= 2 -- the scaling was decorative while
+ the code and its tests both claimed it scaled, and the assertion that would
+ have noticed (two == 2 * per-client) was deleted in the same commit.
+ """
+ src = inspect.getsource(main_mod)
+ assert "_client_stop_cap" not in src, (
+ "the decorative per-client scaling helper is back; it cannot bind for "
+ f"any n >= 2 because _CLIENT_STOP_SECONDS={main_mod._CLIENT_STOP_SECONDS} "
+ f"< 2 * per-client"
+ )
+ # and the flat slice must still cover ONE stuck client, which is the case
+ # that motivated it
+ assert main_mod._CLIENT_STOP_SECONDS > 10.0, (
+ f"the slice is {main_mod._CLIENT_STOP_SECONDS}s, which does not cover a "
+ "single client's 10s internal stop timeout"
+ )
+
+
+@pytest.mark.unit
+@pytest.mark.unit
+async def test_the_touch_flush_precedes_the_database_close():
+ """After db.close() the client rejects writes, so a flush that runs later
+ loses every delta with no error."""
+ names = [s.name for s in main_mod._teardown_plan()]
+ assert names.index("touch buffer") < names.index("database"), (
+ "the touch buffer is flushed after the database is closed -- those deltas are lost"
+ )
+
+
+@pytest.mark.unit
+async def test_budget_left_never_goes_negative():
+ loop = asyncio.get_running_loop()
+ assert main_mod._budget_left(loop.time() - 5.0) == 0.0
+ assert main_mod._budget_left(loop.time() + 5.0) > 0.0
+
+
+@pytest.mark.unit
+async def test_every_boot_failure_teardown_step_is_bounded_by_the_shared_budget():
+ """The four boot-failure handlers are NOT covered by the plan.
+
+ They used to call _safe_teardown_step with no deadline, which grants every
+ step its FULL ceiling -- behind a _cancel_tasks allowed 30s on two of them.
+ That is an 82s unwind against a 30s grace, and the import-time assert could
+ not see it because it only does arithmetic over constants.
+
+ Structural: the alternative is waiting out real timeouts on four code paths
+ nobody can exercise cheaply.
+ """
+ import ast
+
+ source = inspect.getsource(main_mod)
+ tree = ast.parse(source)
+ source_lines = source.splitlines()
+
+ bounded = 0
+ unbounded = []
+ for node in ast.walk(tree):
+ if not (
+ isinstance(node, ast.Call)
+ and getattr(node.func, "id", None) == "_safe_teardown_step"
+ and node.args
+ ):
+ continue
+ if "deadline" in {kw.arg for kw in node.keywords}:
+ bounded += 1
+ else:
+ # the only call left without a keyword deadline is the plan loop,
+ # which passes `deadline` positionally
+ if getattr(node.args[0], "attr", None) != "run":
+ unbounded.append(f"line {node.lineno}: {source_lines[node.lineno - 1].strip()}")
+
+ assert bounded >= 15, (
+ f"only {bounded} teardown steps pass a deadline; the boot-failure paths "
+ "should have grown more than that"
+ )
+ assert not unbounded, (
+ "teardown steps with no deadline, so each takes its full ceiling:\n "
+ + "\n ".join(unbounded)
+ )
+
+
+@pytest.mark.unit
+async def test_every_cancellation_timeout_is_bounded_by_a_named_constant():
+ """_cancel_tasks RETURNS pending tasks rather than raising, so its timeout is
+ fully spendable. Two of the four boot-failure paths allowed a literal 30s,
+ which is the entire platform grace period spent before teardown begins."""
+ import ast
+
+ tree = ast.parse(inspect.getsource(main_mod))
+ timeouts: set = set()
+
+ for node in ast.walk(tree):
+ if not (isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_cancel_tasks"):
+ continue
+ for kw in node.keywords:
+ if kw.arg == "timeout":
+ timeouts.add(ast.unparse(kw.value))
+
+ assert timeouts, "no _cancel_tasks call found -- the scan is stale"
+ for t in timeouts:
+ assert t == "_BOOT_FAILURE_CANCEL_SECONDS", (
+ f"a cancellation timeout is the literal {t}; literals drift out of the "
+ "budget arithmetic, which is how this reached 30s in the first place"
+ )
+ assert main_mod._BOOT_FAILURE_CANCEL_SECONDS <= 10.0, (
+ f"the boot-failure cancel is {main_mod._BOOT_FAILURE_CANCEL_SECONDS}s, which "
+ "on its own eats a third of the platform grace period"
+ )
+
+
+@pytest.mark.unit
+async def test_the_boot_failure_worst_case_fits_the_grace_period():
+ """The module has its own assert on this; recomputing it independently is the
+ point, since a shared formula would agree with itself no matter how wrong."""
+ REAL_PAAS_GRACE_SECONDS = 30.0
+ worst = (
+ main_mod._BOOT_FAILURE_CANCEL_SECONDS
+ + main_mod._SHUTDOWN_BUDGET_SECONDS
+ + main_mod._BOOT_FAILURE_STEP_COUNT * main_mod._TEARDOWN_FLOOR_SECONDS
+ )
+ assert worst < REAL_PAAS_GRACE_SECONDS, (
+ f"a failed boot can take {worst:.1f}s but Heroku, Render and Kubernetes "
+ f"only allow {REAL_PAAS_GRACE_SECONDS:.0f}s -- the touch flush and db.close() "
+ "would be SIGKILLed on a boot failure"
+ )
+ assert worst == main_mod._BOOT_FAILURE_WORST_CASE_SECONDS, (
+ f"the module computes {main_mod._BOOT_FAILURE_WORST_CASE_SECONDS}s but the "
+ f"terms give {worst}s -- one of them has drifted"
+ )
+
+ # The count has to match the real thing, or under-counting it makes the
+ # bound a comfortable lie. A smaller count still passes the < 30s check,
+ # which is why that check alone was not enough.
+ import ast
+
+ tree = ast.parse(inspect.getsource(main_mod))
+ runs, current = [], 0
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_safe_teardown_step":
+ current += 1
+ elif current:
+ runs.append(current)
+ current = 0
+ if current:
+ runs.append(current)
+
+ longest = max(runs) if runs else 0
+ assert longest == main_mod._BOOT_FAILURE_STEP_COUNT, (
+ f"_BOOT_FAILURE_STEP_COUNT says {main_mod._BOOT_FAILURE_STEP_COUNT} but the "
+ f"longest run of teardown steps is {longest}"
+ )
+ assert (
+ len([s for s in main_mod._teardown_plan() if s.shared]) + 2
+ == main_mod._BOOT_FAILURE_STEP_COUNT
+ ), (
+ "the largest boot-failure handler runs the shared plan steps plus the "
+ "clients stop and the bot stop"
+ )
diff --git a/tests/test_unit/test_shutdown_e2e.py b/tests/test_unit/test_shutdown_e2e.py
new file mode 100644
index 00000000..241f3683
--- /dev/null
+++ b/tests/test_unit/test_shutdown_e2e.py
@@ -0,0 +1,240 @@
+"""End-to-end shutdown: the path CI never exercises.
+
+The container fix (shared budget + floor per step) is only meaningful if
+SIGTERM actually reaches ``shutdown_services`` and the touch buffer lands in
+Mongo before the process exits. Docker is not available on every dev host, so
+this drives ``shutdown_services`` directly with a populated buffer and a
+cancel-friendly background task -- the real function, the real rate limiter,
+the real touch buffer, only the database and the Telegram client are faked.
+
+What this does NOT cover: PID 1 signal delivery, which is a property of
+Dockerfile/thunder.sh. That is pinned separately by
+``test_signal_delivery_is_wired_for_pid_1``.
+"""
+
+import asyncio
+
+import pytest
+
+from Thunder import __main__ as main_mod
+from Thunder.utils import canonical_files as cf
+from Thunder.utils import rate_limiter as rl
+
+
+class _FakeDb:
+ """Mirrors Database.bulk_touch_file_records: the kwarg and the bool."""
+
+ def __init__(self) -> None:
+ self.touched: list[tuple[str, int, int]] = []
+ self.closed = False
+ self.post_close_writes: list = []
+ self.raise_on_error_seen: list[bool] = []
+
+ async def bulk_touch_file_records(self, rows, *, raise_on_error: bool = False) -> bool:
+ self.raise_on_error_seen.append(raise_on_error)
+ if self.closed:
+ # the real client raises once the pool is closed; a fake that
+ # accepts writes after close() cannot detect a flush that happens
+ # in the wrong order relative to db.close()
+ self.post_close_writes.append(list(rows))
+ raise RuntimeError("client is closed")
+ self.touched.extend(rows)
+ return True
+
+ async def close(self):
+ self.closed = True
+
+
+@pytest.fixture
+def shutdown_env(monkeypatch):
+ """Wire shutdown_services to fakes and give back an observation bucket."""
+ fake = _FakeDb()
+ monkeypatch.setattr(cf, "db", fake)
+ monkeypatch.setattr(cf, "_pending_touches", type(cf._pending_touches)())
+ monkeypatch.setattr(cf, "_flush_task", None)
+ monkeypatch.setattr(cf, "_dropped_touches", 0)
+ monkeypatch.setattr(cf, "_cache_by_hash", type(cf._cache_by_hash)())
+ monkeypatch.setattr(cf, "_cache_by_unique_id", type(cf._cache_by_unique_id)())
+ monkeypatch.setattr(main_mod, "close_shortener", _noop)
+ monkeypatch.setattr(main_mod, "cleanup_clients", _noop)
+ monkeypatch.setattr(main_mod, "db", fake)
+ monkeypatch.setattr(main_mod, "work_loads", {})
+ # no rate-limiter executors running in a unit test
+ monkeypatch.setattr(rl.rate_limiter, "shutdown", _noop)
+ return fake
+
+
+async def _noop():
+ return None
+
+
+def _arm_touch(cf_mod, public_hash: str) -> None:
+ cf_mod.schedule_touch_file_record(
+ {"public_hash": public_hash, "file_unique_id": "u", "seen_count": 0}, reused=True
+ )
+
+
+@pytest.mark.unit
+async def test_shutdown_flushes_the_touch_buffer_and_closes_cleanly(shutdown_env):
+ """The whole point: a populated buffer must reach Mongo during teardown,
+ and the process must NOT exit non-zero (which would restart-loop a PaaS)."""
+ _arm_touch(cf, "a" * cf.PUBLIC_HASH_LENGTH)
+
+ assert cf._pending_touches, "fixture did not populate the buffer"
+
+ await main_mod.shutdown_services(background_tasks=[], app_runner=None)
+
+ assert shutdown_env.touched, "the touch buffer was never flushed on shutdown"
+ assert (("a" * cf.PUBLIC_HASH_LENGTH), 1, 1) in shutdown_env.touched
+ assert not cf._pending_touches
+ assert shutdown_env.closed, "the database was not closed"
+ # the fake rejects writes after close(), so this pins the ORDER, not just
+ # that both happened: moving db.close() before the flush loses the deltas
+ # with no error anywhere
+ assert not shutdown_env.post_close_writes, (
+ "the touch buffer was flushed after db.close() -- those deltas are lost"
+ )
+ assert shutdown_env.raise_on_error_seen == [True], (
+ "the flush did not ask the client to raise on failure, so a Mongo "
+ "brownout is reported as a clean shutdown"
+ )
+
+
+@pytest.mark.unit
+async def test_shutdown_returns_normally_on_a_healthy_stop(shutdown_env):
+ """A healthy stop returns without raising. sys.exit(1) here would
+ restart-loop Heroku/Render on every clean deploy, so the only exit this
+ path may take is a real, recorded failure."""
+
+ assert await main_mod.shutdown_services(background_tasks=[], app_runner=None) is None
+
+
+@pytest.mark.unit
+async def test_shutdown_cancels_background_work_without_failing(shutdown_env):
+ """Background tasks are cancelled first; a clean return must survive that,
+ or every deploy looks like a crash."""
+ started = asyncio.Event()
+
+ async def _worker():
+ started.set()
+ await asyncio.sleep(3600)
+
+ task = asyncio.create_task(_worker())
+ await started.wait()
+
+ assert await main_mod.shutdown_services(background_tasks=[task], app_runner=None) is None
+ assert task.done()
+
+
+@pytest.mark.unit
+async def test_hung_mongo_during_shutdown_is_reported_as_a_failure(shutdown_env, monkeypatch):
+ """A touch flush that never answers must be *reported*, not silently
+ swallowed: the drain shields its write, and swallowing the caller's
+ cancellation would make wait_for return normally and mark the step clean.
+ The budget still bounds the whole teardown.
+
+ The step/budget caps are shrunk so the mechanism runs in ~0.2s instead of
+ waiting out the real 8s cap. The production values are pinned separately,
+ against the source, in test_shutdown_budget.py.
+ """
+ monkeypatch.setattr(main_mod, "_SHUTDOWN_BUDGET_SECONDS", 1.0)
+ monkeypatch.setattr(main_mod, "_TEARDOWN_STEP_SECONDS", 0.2)
+ monkeypatch.setattr(main_mod, "_TEARDOWN_FLOOR_SECONDS", 0.05)
+
+ async def _hang(rows, *, raise_on_error: bool = False):
+ await asyncio.sleep(30)
+
+ monkeypatch.setattr(shutdown_env, "bulk_touch_file_records", _hang)
+ _arm_touch(cf, "b" * cf.PUBLIC_HASH_LENGTH)
+
+ loop = asyncio.get_running_loop()
+ started = loop.time()
+ with pytest.raises(SystemExit) as exc:
+ await main_mod.shutdown_services(background_tasks=[], app_runner=None)
+ elapsed = loop.time() - started
+
+ assert exc.value.code == 1, "a step that hung must be reported, not swallowed"
+ budget = (
+ main_mod._SHUTDOWN_BUDGET_SECONDS
+ + main_mod._TEARDOWN_STEP_COUNT * main_mod._TEARDOWN_FLOOR_SECONDS
+ )
+ assert elapsed < budget + 2, f"shutdown took {elapsed:.1f}s, over the {budget:.0f}s bound"
+ assert elapsed < 60, "shutdown outlived the container stop grace period"
+
+
+@pytest.mark.unit
+async def test_a_multi_client_stop_is_not_recorded_as_a_failure(shutdown_env, monkeypatch):
+ """A healthy stop must exit 0.
+
+ cleanup_clients used to share the generic 8s step cap, which cut it and
+ recorded a failure that never happened -- restarting a PaaS that was
+ shutting down fine. It now has its own flat slice.
+
+ The previous version of this test inflated _SHUTDOWN_BUDGET_SECONDS to 30s,
+ so it passed because of a bigger budget rather than because of the slice.
+ Nothing is inflated here: the slice has to do the work on its own.
+ """
+ waits = []
+
+ async def slow_cleanup():
+ # three clients, each well under the slice
+ for _ in range(3):
+ waits.append(1)
+ await asyncio.sleep(0.02)
+
+ monkeypatch.setattr(main_mod, "cleanup_clients", slow_cleanup)
+ # shrink the slice so the test proves the exemption, not the wall clock
+ monkeypatch.setattr(main_mod, "_CLIENT_STOP_SECONDS", 0.5)
+
+ # no SystemExit: the point is that this stop is clean
+ await main_mod.shutdown_services(background_tasks=[], app_runner=None)
+
+ assert waits == [1, 1, 1], "cleanup_clients did not run to completion"
+
+
+@pytest.mark.unit
+async def test_a_clients_step_that_outlasts_its_slice_IS_reported(shutdown_env, monkeypatch):
+ """The other half, and the honest one.
+
+ The slice is flat: it cannot scale with the client count, because two hung
+ clients would need 20s and the rest of the teardown needs the remainder of
+ the platform's 30s. So a stop that genuinely exceeds it is cut, recorded,
+ and exits 1 -- which is correct, because it means clients are stuck.
+ """
+ monkeypatch.setattr(main_mod, "_CLIENT_STOP_SECONDS", 0.05)
+
+ async def stuck_cleanup():
+ await asyncio.sleep(30)
+
+ monkeypatch.setattr(main_mod, "cleanup_clients", stuck_cleanup)
+
+ with pytest.raises(SystemExit) as exc:
+ await main_mod.shutdown_services(background_tasks=[], app_runner=None)
+
+ assert exc.value.code == 1, (
+ "a clients step that outlasted its slice was not reported -- that is a "
+ "real failure and the restart policy should see it"
+ )
+
+
+@pytest.mark.unit
+def test_thunder_sh_execs_python_so_it_becomes_pid_1():
+ """Docker runs CMD ["bash", "thunder.sh"]; without exec, bash stays PID 1 and
+ never forwards SIGTERM, so shutdown_services is unreachable in every
+ container deploy.
+
+ This covers only our half of the chain. That pyrofork's idle() actually
+ delivers the signal to a real process is proved for real, by sending
+ SIGTERM to a live interpreter, in test_signal_delivery.test_sigterm_reaches_idle
+ (integration tier: POSIX only).
+ """
+ import pathlib
+
+ root = pathlib.Path(__file__).resolve().parents[2]
+ script = (root / "thunder.sh").read_text(encoding="utf-8")
+ dockerfile = (root / "Dockerfile").read_text(encoding="utf-8")
+
+ assert "exec " in script, "thunder.sh must exec python so it becomes PID 1"
+ assert 'CMD ["bash", "thunder.sh"]' in dockerfile
+ # the update phase runs under bash as PID 1; it must not block forever
+ assert "update.py" in script
diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py
new file mode 100644
index 00000000..dbf2acf8
--- /dev/null
+++ b/tests/test_unit/test_stream_routes.py
@@ -0,0 +1,341 @@
+"""HTTP parsing primitives + dual-hash validation + disposition rules."""
+
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import web
+from aiohttp.web import HTTPBadRequest, HTTPRequestRangeNotSatisfiable
+
+from Thunder.server.exceptions import InvalidHash
+from Thunder.server.stream_routes import (
+ _is_activation_token,
+ _slot_release,
+ _telegram_activate_url,
+ build_content_disposition,
+ parse_media_request,
+ parse_range_header,
+ validate_public_hash,
+)
+
+
+class TestParseMediaRequest:
+ @pytest.mark.unit
+ def test_hash_first(self):
+ mid, h = parse_media_request("AbCdEf12345/video.mp4", {})
+ assert mid == 12345
+ assert h == "AbCdEf"
+
+ @pytest.mark.unit
+ def test_hash_first_with_trailing_slash_path(self):
+ mid, h = parse_media_request("AbCdEf12345", {})
+ assert mid == 12345
+ assert h == "AbCdEf"
+
+ @pytest.mark.unit
+ def test_id_first_with_query_hash(self):
+ mid, h = parse_media_request("12345/name.mp4", {"hash": "AbCdEf"})
+ assert mid == 12345
+ assert h == "AbCdEf"
+
+ @pytest.mark.unit
+ def test_invalid_hash_raises(self):
+ with pytest.raises(InvalidHash):
+ parse_media_request("12345/name.mp4", {"hash": "short"})
+ with pytest.raises(InvalidHash):
+ parse_media_request("nonsense", {})
+
+ @pytest.mark.unit
+ def test_bad_message_id_raises(self):
+ with pytest.raises(InvalidHash):
+ parse_media_request("AbCdEf_+*/x", {})
+
+
+class TestValidatePublicHash:
+ @pytest.mark.unit
+ def test_accepts_legacy_20(self):
+ assert validate_public_hash("a" * 20) == "a" * 20
+
+ @pytest.mark.unit
+ def test_accepts_new_32(self):
+ assert validate_public_hash("b" * 32) == "b" * 32
+
+ @pytest.mark.unit
+ def test_rejects_other_lengths_and_normalizes_case(self):
+ with pytest.raises(InvalidHash):
+ validate_public_hash("c" * 21)
+ with pytest.raises(InvalidHash):
+ validate_public_hash("g" * 32) # not hex
+ assert validate_public_hash("A" * 32) == "a" * 32 # lowercased
+
+
+class TestParseRangeHeader:
+ @pytest.mark.unit
+ def test_full_file(self):
+ assert parse_range_header("", 100) == (0, 99)
+
+ @pytest.mark.unit
+ def test_open_ended(self):
+ assert parse_range_header("bytes=10-", 100) == (10, 99)
+
+ @pytest.mark.unit
+ def test_closed_range(self):
+ assert parse_range_header("bytes=0-49", 100) == (0, 49)
+
+ @pytest.mark.unit
+ def test_end_beyond_eof_clamps_instead_of_416(self):
+ # RFC 7233: last-byte-pos >= length means "rest of the file"; download
+ # managers send such fixed-chunk ends, and a hard 416 broke their resume/seeking.
+ assert parse_range_header("bytes=0-99999999", 100) == (0, 99)
+ assert parse_range_header("bytes=50-1000", 100) == (50, 99)
+
+ @pytest.mark.unit
+ def test_suffix_range(self):
+ assert parse_range_header("bytes=-10", 100) == (90, 99)
+
+ @pytest.mark.unit
+ def test_unsatisfiable_raises_416(self):
+ with pytest.raises(HTTPRequestRangeNotSatisfiable) as exc:
+ parse_range_header("bytes=99999999999-", 100)
+ assert exc.value.headers["Content-Range"] == "bytes */100"
+
+ @pytest.mark.unit
+ def test_invalid_header_raises_400(self):
+ with pytest.raises(HTTPBadRequest):
+ parse_range_header("bytes=1-2-3", 100)
+
+
+class TestContentDisposition:
+ @pytest.mark.unit
+ def test_ascii_fallback_plus_rfc5987(self):
+ header = build_content_disposition("attachment", "video.mp4")
+ assert 'filename="video.mp4"' in header
+ assert "filename*=UTF-8''video.mp4" in header
+
+ @pytest.mark.unit
+ def test_non_latin_gets_ascii_fallback(self):
+ header = build_content_disposition("attachment", "视频 file.mp4")
+ assert header.startswith("attachment;")
+ assert 'filename="' in header and "视频" not in header.split("filename*")[0]
+ assert "%E8%A7%86%E9%A2%91" in header # encoded filename*
+
+
+class TestActivationTokenShape:
+ @pytest.mark.unit
+ def test_accepts_real_token_urlsafe_shape(self):
+ import secrets
+
+ assert _is_activation_token(secrets.token_urlsafe(32))
+
+ @pytest.mark.unit
+ def test_rejects_wrong_length_and_chars(self):
+ assert not _is_activation_token("short")
+ assert not _is_activation_token("a" * 42)
+ assert not _is_activation_token("a" * 44)
+ assert not _is_activation_token("a" * 42 + "$$")
+ # redirect / header injection payloads must fail the shape check
+ assert not _is_activation_token("../../evil.com?")
+ assert not _is_activation_token("x\r\nLocation: https://evil.com")
+ assert not _is_activation_token("a" * 20 + "/" + "b" * 22)
+
+
+class TestTelegramActivateUrl:
+ @pytest.mark.unit
+ def test_valid_token_produces_expected_deep_link(self):
+ token = "a" * 43
+ assert _telegram_activate_url("MyBot", token) == f"https://t.me/MyBot?start={token}"
+
+ @pytest.mark.unit
+ def test_hostile_token_cannot_reshape_url(self):
+ url = _telegram_activate_url("MyBot", "x&start=evil#frag")
+ assert url.startswith("https://t.me/MyBot?start=")
+ assert "&" not in url[24:] and "#" not in url[24:]
+
+
+class TestCanonicalDeliveryErrorLadder:
+ """Review fix regression: transport errors must 503 WITHOUT deleting the
+ record; only true Telegram-side absence may self-heal (delete)."""
+
+ @staticmethod
+ def _request():
+ return SimpleNamespace(match_info={"secure_hash": "a" * 32})
+
+ @pytest.mark.unit
+ async def test_db_outage_maps_to_503_not_404(self, monkeypatch):
+ """Review fix regression: a Mongo read failure is a brownout, not
+ absence -- must never surface as a cacheable 404."""
+ from pymongo.errors import ExecutionTimeout
+
+ import Thunder.server.stream_routes as stream_routes
+
+ async def get_file_by_hash(_h, raise_on_error=True):
+ raise ExecutionTimeout("server timeout")
+
+ monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash)
+ monkeypatch.setattr(stream_routes, "work_loads", {0: 0})
+
+ with pytest.raises(web.HTTPServiceUnavailable) as exc:
+ await stream_routes.canonical_media_delivery(self._request())
+ assert exc.value.headers.get("Retry-After") == "5"
+ # no admission happened: the slot count is untouched
+ assert stream_routes.work_loads == {0: 0}
+
+ @pytest.mark.unit
+ async def test_transport_error_maps_to_503_and_never_deletes(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+ from Thunder.server.exceptions import TelegramUnavailable
+
+ deleted: list[dict] = []
+ secret = "FloodWait-SECRET-xyz-987"
+
+ async def get_message(_ref):
+ raise TelegramUnavailable(secret)
+
+ async def get_file_by_hash(_h, raise_on_error=False):
+ return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10}
+
+ async def forget_stale_record(record):
+ deleted.append(record)
+
+ monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash)
+ monkeypatch.setattr(
+ stream_routes,
+ "select_optimal_client",
+ lambda: (0, SimpleNamespace(get_message=get_message)),
+ )
+ monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record)
+ monkeypatch.setattr(stream_routes, "work_loads", {0: 0})
+
+ with pytest.raises(web.HTTPServiceUnavailable) as exc:
+ await stream_routes.canonical_media_delivery(self._request())
+ assert exc.value.headers.get("Retry-After") == "5"
+ assert deleted == [] # transient error must NOT destroy the record
+ assert stream_routes.work_loads == {0: 0} # admission slot released
+ # 503 bodies are constant strings: the exception text carries internal
+ # state (FloodWait values, chat ids) and must never reach the client
+ assert secret not in exc.value.text
+ assert exc.value.text == "Telegram is temporarily unavailable; please retry shortly."
+
+ @pytest.mark.unit
+ async def test_true_absence_self_heals_and_maps_to_404(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+ from Thunder.server.exceptions import FileNotFound
+
+ deleted: list[dict] = []
+
+ async def get_message(_ref):
+ raise FileNotFound("no such message")
+
+ async def get_file_by_hash(_h, raise_on_error=False):
+ return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10}
+
+ async def forget_stale_record(record):
+ deleted.append(record)
+
+ monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash)
+ monkeypatch.setattr(
+ stream_routes,
+ "select_optimal_client",
+ lambda: (0, SimpleNamespace(get_message=get_message)),
+ )
+ monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record)
+ monkeypatch.setattr(stream_routes, "work_loads", {0: 0})
+
+ with pytest.raises(web.HTTPNotFound):
+ await stream_routes.canonical_media_delivery(self._request())
+ assert len(deleted) == 1 # genuine absence is the one self-heal case
+ assert stream_routes.work_loads == {0: 0}
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("bad_size", [0, -1, -5])
+ async def test_non_positive_file_size_never_builds_a_response(self, bad_size):
+ """A corrupt record with a zero *or negative* size must raise before a
+ Response exists -- the negative case would otherwise reach
+ ``Content-Length: -1``. Exercised on the serving helper directly: at
+ route level the vault size check happens first and would mask which
+ guard fired.
+ """
+ import Thunder.server.stream_routes as stream_routes
+ from Thunder.server.exceptions import FileNotFound
+
+ request = SimpleNamespace(headers={}, query={})
+ with pytest.raises(FileNotFound):
+ await stream_routes._serve_media_response(
+ request,
+ file_info={"file_name": "x.bin", "file_size": bad_size},
+ streamer=SimpleNamespace(),
+ client_id=0,
+ media_ref=1,
+ release_slot=_slot_release(0),
+ )
+
+ @pytest.mark.unit
+ async def test_medialess_vault_message_self_heals(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+
+ deleted: list[dict] = []
+
+ async def get_message(_ref):
+ return SimpleNamespace() # message exists but carries no media
+
+ async def get_file_by_hash(_h, raise_on_error=False):
+ return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10}
+
+ async def forget_stale_record(record):
+ deleted.append(record)
+
+ monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash)
+ monkeypatch.setattr(
+ stream_routes,
+ "select_optimal_client",
+ lambda: (0, SimpleNamespace(get_message=get_message)),
+ )
+ monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record)
+ monkeypatch.setattr(stream_routes, "get_media", lambda m: None)
+ monkeypatch.setattr(stream_routes, "work_loads", {0: 0})
+
+ with pytest.raises(web.HTTPNotFound):
+ await stream_routes.canonical_media_delivery(self._request())
+ assert len(deleted) == 1
+
+
+class TestLegacyDisabledGone:
+ """With ENABLE_LEGACY_LINKS=False the legacy families must 410."""
+
+ @staticmethod
+ def _request(path="AbCdEf12345/name.mp4"):
+ return SimpleNamespace(match_info={"path": path})
+
+ @pytest.mark.unit
+ async def test_preview_gone(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+ from Thunder.vars import Var
+
+ monkeypatch.setattr(Var, "ENABLE_LEGACY_LINKS", False)
+ with pytest.raises(web.HTTPGone):
+ await stream_routes.media_preview(self._request())
+
+ @pytest.mark.unit
+ async def test_delivery_gone(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+ from Thunder.vars import Var
+
+ monkeypatch.setattr(Var, "ENABLE_LEGACY_LINKS", False)
+ with pytest.raises(web.HTTPGone):
+ await stream_routes.media_delivery(self._request())
+
+
+class TestAdmissionControl:
+ """Saturated clients refuse with 503 + Retry-After instead of stacking."""
+
+ @pytest.mark.unit
+ def test_full_pool_maps_to_503_with_retry_after(self, monkeypatch):
+ import Thunder.server.stream_routes as stream_routes
+
+ monkeypatch.setattr(
+ stream_routes, "work_loads", {0: stream_routes.MAX_CONCURRENT_PER_CLIENT}
+ )
+ with pytest.raises(web.HTTPServiceUnavailable) as exc:
+ stream_routes.select_optimal_client()
+ assert exc.value.headers.get("Retry-After") == str(
+ stream_routes.OVERLOAD_RETRY_AFTER_SECONDS
+ )
diff --git a/tests/test_unit/test_stream_slots.py b/tests/test_unit/test_stream_slots.py
new file mode 100644
index 00000000..0361ddf2
--- /dev/null
+++ b/tests/test_unit/test_stream_slots.py
@@ -0,0 +1,658 @@
+"""A stalled reader must not hold a stream slot forever.
+
+work_loads[client_id] is the admission counter. It is incremented on every
+delivery request and decremented in the body generator's `finally`. A client
+that opens a connection, requests a Range, and then stops reading fills the
+transport's write buffer, suspends the generator at `yield`, and never unwinds
+-- so the slot leaks permanently. EXECUTOR_WORKERS of those and the client
+503s forever with no in-flight request to account for it.
+
+These tests drive the real generator through a real consumer that stops
+reading, because the whole point is what happens while the generator is
+suspended at a yield -- something a mock cannot show.
+"""
+
+import asyncio
+import contextlib
+import types
+
+import pytest
+
+from Thunder.bot import work_loads
+from Thunder.server import stream_routes as sr
+
+pytestmark = pytest.mark.unit
+
+
+_PATTERN = bytes(range(256))
+
+
+def _bytes_at(start: int, size: int) -> bytes:
+ """A chunk whose byte values encode its absolute position (mod 256).
+
+ Cheap (one repeat-and-slice) but position-sensitive: a trim that is skipped,
+ or applied to the wrong chunk, shifts the content and shows up in the bytes
+ rather than only in the length.
+ """
+ off = start % 256
+ blob = _PATTERN * (size // 256 + 2)
+ return blob[off : off + size]
+
+
+class _StalledStreamer:
+ """Yields data forever. The consumer decides how much it takes.
+
+ Models the REAL ByteStreamer.stream_file contract, which is easy to get
+ wrong and which this fake previously violated:
+
+ chunk_offset = offset // CHUNK_SIZE # seek to a chunk BOUNDARY
+ chunk_limit = ceil(limit / CHUNK_SIZE) + 1 # in CHUNKS, not bytes
+
+ The real streamer returns whole CHUNK_SIZE pieces starting at the aligned
+ offset, and the caller trims the leading ``offset % CHUNK_SIZE`` bytes. The
+ old version ignored ``offset`` entirely and yielded exactly ``limit`` bytes,
+ which happened to work for every case here because they all used start=0 --
+ so the trim path was never exercised, and adding a Range case would have
+ silently delivered wrong bytes rather than failing.
+
+ It reads sr.CHUNK_SIZE rather than capturing it, so a test that patches the
+ route's chunk size moves both sides together, exactly as custom_dl.CHUNK_SIZE
+ does in production.
+ """
+
+ def __init__(self):
+ self.started = 0
+ self.pulled = 0
+ self.calls: list[tuple[int, int]] = []
+
+ async def stream_file(self, media_ref, offset=0, limit=0):
+ self.started += 1
+ self.calls.append((offset, limit))
+ chunk_size = sr.CHUNK_SIZE
+ chunk_offset = (offset // chunk_size) * chunk_size
+ chunk_limit = ((limit + chunk_size - 1) // chunk_size) + 1 if limit > 0 else 0
+ for i in range(chunk_limit or 1):
+ start = chunk_offset + i * chunk_size
+ self.pulled += 1
+ yield _bytes_at(start, chunk_size)
+
+
+async def _drain(body):
+ """Consume a body generator the way aiohttp does: keep pulling."""
+ async for _ in body:
+ pass
+
+
+@pytest.fixture(autouse=True)
+def small_chunks(monkeypatch):
+ """Shrink the route's chunk size for the whole module.
+
+ Production is 1 MiB. Building a real 1 MiB position-encoded chunk per test
+ would cost more than the tests themselves, and the alignment arithmetic is
+ identical at any size -- what matters is that start is not a multiple of it.
+ Both sides read sr.CHUNK_SIZE, so this moves them together.
+ """
+ monkeypatch.setattr(sr, "CHUNK_SIZE", 64)
+
+
+@pytest.fixture
+def client_slot(monkeypatch):
+ work_loads[0] = 0
+ yield
+ work_loads[0] = 0
+
+
+def _request(range_header=""):
+ headers = {"Range": range_header} if range_header else {}
+ return types.SimpleNamespace(method="GET", headers=headers, query={})
+
+
+def _info(size):
+ return {"file_size": size, "file_name": "v.mp4", "mime_type": "video/mp4"}
+
+
+def _body_for(streamer, content_length, client_id=0, deadline=None, start=0):
+ """Drive the real body builder with a slot already taken.
+
+ Uses the production release helper rather than a local copy: an earlier
+ version re-implemented the idempotency guard here, so making the real one
+ non-idempotent left every test green.
+ """
+ work_loads[client_id] += 1
+ release_slot = sr._slot_release(client_id)
+ if deadline is None:
+ deadline = sr._stream_deadline_seconds(content_length)
+ return sr._make_body(streamer, 1, start, content_length, release_slot, deadline)
+
+
+def test_the_release_helper_is_idempotent():
+ """Both the generator's finally and the watchdog can release; a second
+ release drives work_loads negative and makes the limiter think there is
+ spare capacity."""
+ work_loads[0] = 5
+ release = sr._slot_release(0)
+
+ release()
+ assert work_loads[0] == 4
+ release()
+ release()
+ assert work_loads[0] == 4, f"the slot was released more than once ({work_loads[0]})"
+
+
+# --- the range-alignment trim ---------------------------------------------
+#
+# ByteStreamer.stream_file(offset=N) seeks to floor(N / CHUNK_SIZE) * CHUNK_SIZE
+# and yields whole chunks from there, so the body generator must drop the
+# leading N % CHUNK_SIZE bytes itself. Nothing pinned that: every case in this
+# file used start=0, where the trim is a no-op, and the fake ignored offset --
+# so the arithmetic could have been deleted outright and the suite stayed green.
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ ("start", "length"),
+ [
+ (0, 100), # aligned: no trim at all
+ (1, 100), # one byte of trim
+ (10, 10), # trim longer than the requested span
+ (63, 1), # last byte of the first chunk
+ (64, 64), # exactly chunk-aligned start
+ (65, 130), # spans three chunks from a misaligned start
+ (200, 10), # start deep past the first chunk
+ ],
+)
+async def test_a_range_gets_exactly_the_requested_bytes(client_slot, start, length):
+ body = _body_for(_StalledStreamer(), length, start=start)
+ delivered = b"".join([chunk async for chunk in body])
+
+ assert len(delivered) == length, f"start={start} length={length}: got {len(delivered)} bytes"
+ assert delivered == _bytes_at(start, length), (
+ f"start={start}: the wrong bytes were delivered -- the offset % CHUNK_SIZE trim is wrong or missing"
+ )
+ assert work_loads[0] == 0, "the slot was not released"
+
+
+@pytest.mark.unit
+async def test_the_streamer_is_asked_for_the_aligned_position(client_slot):
+ """The route passes the raw start; the STREAMER aligns it, and the route
+ trims. If either side moved the other way the bytes would be wrong."""
+ streamer = _StalledStreamer()
+ body = _body_for(streamer, 100, start=70)
+
+ async for _ in body:
+ pass
+
+ assert streamer.calls == [(70, 100)], (
+ f"stream_file was called with {streamer.calls}; the route must pass the raw "
+ "offset and let the streamer align it to a chunk boundary"
+ )
+
+
+@pytest.mark.unit
+async def test_the_fake_is_position_sensitive(client_slot):
+ """Guards the tests above against becoming vacuous: if the fake stopped
+ encoding position, a broken trim would pass."""
+ body = _body_for(_StalledStreamer(), 10, start=10)
+ delivered = b"".join([chunk async for chunk in body])
+
+ assert delivered != _bytes_at(0, 10), (
+ "the fake no longer distinguishes positions -- a missing trim would go unnoticed"
+ )
+
+
+# --- the deadline itself ---------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("size", "expected"),
+ [
+ (0, sr._STREAM_MIN_SECONDS), # unknown size: the floor
+ (1024, sr._STREAM_MIN_SECONDS), # tiny: still the floor
+ (sr._STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND * 600, 600.0), # rate-limited
+ (sr._STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND * 10**9, sr._STREAM_MAX_SECONDS), # capped
+ ],
+)
+def test_the_deadline_scales_with_the_transfer(size, expected):
+ assert sr._stream_deadline_seconds(size) == pytest.approx(expected)
+
+
+def test_the_deadline_is_always_positive_and_bounded():
+ for size in (-1, 0, 1, 10**12):
+ d = sr._stream_deadline_seconds(size)
+ assert 0 < d <= sr._STREAM_MAX_SECONDS
+
+
+def test_the_deadline_is_finite_for_an_enormous_file():
+ """A file larger than any disk still gets a deadline."""
+ assert sr._stream_deadline_seconds(10**18) == sr._STREAM_MAX_SECONDS
+
+
+# --- a stalled reader ------------------------------------------------------
+
+
+async def test_a_stalled_consumer_gets_its_slot_back(monkeypatch, client_slot):
+ """The regression, end to end: take one chunk, then stop reading.
+
+ The generator is left suspended at `yield`, which is exactly the state a
+ slowloris-style reader produces. Without the deadline the slot is never
+ returned.
+ """
+ monkeypatch.setattr(sr, "_STREAM_MIN_SECONDS", 0.2)
+ monkeypatch.setattr(sr, "_STREAM_MAX_SECONDS", 0.2)
+ monkeypatch.setattr(sr, "_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND", 1)
+
+ streamer = _StalledStreamer()
+
+ body = _body_for(streamer, 10**6)
+ assert work_loads[0] == 1, "the slot was not taken before the test began"
+
+ # take exactly one chunk, then go quiet -- this is the client stopping
+ # to read. The generator is now suspended at a yield with nobody waiting
+ # on it, which is the state it can never unwind from on its own.
+ await anext(body)
+ assert work_loads[0] == 1, "the slot was released while the stream was still live"
+
+ # let the deadline pass without touching the body again
+ await asyncio.sleep(0.6)
+
+ assert work_loads[0] == 0, (
+ "a stalled reader kept its stream slot -- EXECUTOR_WORKERS of these and "
+ "the client is 503'd forever"
+ )
+ with pytest.raises(StopAsyncIteration):
+ await anext(body)
+
+
+async def test_a_slot_is_never_released_twice(monkeypatch, client_slot):
+ """Double release drives the counter negative, which makes the limiter
+ believe there is spare capacity -- worse than the leak being fixed."""
+ monkeypatch.setattr(sr, "_STREAM_MIN_SECONDS", 5.0)
+ monkeypatch.setattr(sr, "_STREAM_MAX_SECONDS", 5.0)
+ monkeypatch.setattr(sr, "_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND", 1)
+
+ body = _body_for(_StalledStreamer(), 4096)
+
+ await anext(body)
+ # normal completion: the generator's own finally releases
+ remaining = [chunk async for chunk in body]
+
+ assert len(remaining) >= 1
+ assert work_loads[0] == 0, "a completed stream left its slot held"
+ # and closing again must not double-decrement
+ await body.aclose()
+ assert work_loads[0] == 0, f"the slot counter went to {work_loads[0]} -- released twice"
+
+
+async def test_a_healthy_stream_releases_exactly_once(client_slot):
+ body = _body_for(_StalledStreamer(), 4096)
+ chunks = [c async for c in body]
+ assert len(chunks) >= 1
+ assert work_loads[0] == 0
+
+
+async def test_head_releases_the_slot_without_a_body(client_slot):
+ request = types.SimpleNamespace(method="HEAD", headers={}, query={})
+ work_loads[0] = 1
+ await sr._serve_media_response(
+ request,
+ file_info=_info(4096),
+ streamer=_StalledStreamer(),
+ client_id=0,
+ media_ref=1,
+ release_slot=sr._slot_release(0),
+ )
+ assert work_loads[0] == 0, "a HEAD request leaked its slot"
+
+
+# --- one release closure for the whole request ------------------------------
+#
+# The ladder, the generator's finally and the watchdog can all be first to
+# return a slot. Three separate decrements were three chances to go negative,
+# and a failure between "watchdog created" and "Response returned" genuinely
+# hit two of them at once.
+
+
+async def test_the_ladder_hands_the_stream_the_same_release(client_slot):
+ """The ladder, the generator's finally and the watchdog can all be first to
+ return a slot, so there must be ONE closure, not three decrements.
+
+ Decisive rather than arithmetic: if _serve_media_response minted its own
+ closure, the ladder's would still be unused, and calling it after HEAD would
+ take the counter to -1.
+ """
+ work_loads[0] = 1 # admission already took the slot
+
+ async with sr._admission_ladder(0, "probe") as release:
+ await sr._serve_media_response(
+ types.SimpleNamespace(method="HEAD", headers={}, query={}),
+ file_info=_info(4096),
+ streamer=_StalledStreamer(),
+ client_id=0,
+ media_ref=1,
+ release_slot=release,
+ )
+ assert work_loads[0] == 0, "HEAD should have returned the slot"
+
+ # the ladder's own release on the way out must now be a no-op
+ release()
+
+ assert work_loads[0] == 0, (
+ f"work_loads is {work_loads[0]} -- _serve_media_response used its own "
+ "release closure instead of the ladder's, so the slot is counted twice"
+ )
+
+
+async def test_the_watchdog_does_not_release_a_slot_the_ladder_already_returned(monkeypatch):
+ """The failure window is between _make_body (watchdog created, holding the
+ release) and the Response returning. The ladder releases on the way out and
+ the watchdog releases at its deadline -- with two closures that is -1, which
+ makes select_optimal_client believe there is spare capacity.
+
+ Driven through _serve_media_response, not _make_body directly, because the
+ HEAD path returns before _serve_media_response would touch the passed-in
+ closure -- so a HEAD-only test cannot see a second closure being minted.
+ The streaming path is where the swap happens.
+ """
+ monkeypatch.setattr(sr, "_STREAM_MIN_SECONDS", 0.15)
+ monkeypatch.setattr(sr, "_STREAM_MAX_SECONDS", 0.15)
+ monkeypatch.setattr(sr, "_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND", 1)
+
+ work_loads[0] = 1
+ release = sr._slot_release(0)
+ response = await sr._serve_media_response(
+ types.SimpleNamespace(method="GET", headers={}, query={}),
+ file_info=_info(10**7),
+ streamer=_StalledStreamer(),
+ client_id=0,
+ media_ref=1,
+ release_slot=release,
+ )
+ assert work_loads[0] == 1, "the slot should still be held: nobody consumed the body"
+
+ # the request fails before/while the body is delivered, so the ladder's
+ # except-path returns the slot
+ release()
+ assert work_loads[0] == 0, "the ladder did not release"
+
+ await asyncio.sleep(0.4) # the watchdog's deadline passes
+
+ assert work_loads[0] == 0, (
+ f"work_loads is {work_loads[0]} -- the watchdog released a slot the "
+ "ladder had already returned, so _serve_media_response must have minted "
+ "its own closure instead of reusing the one it was given"
+ )
+ # the response wraps the generator in a payload; close() is a COROUTINE, so
+ # calling it bare emitted "coroutine was never awaited" and did nothing
+ await response.body.close()
+
+
+async def test_serve_media_response_requires_the_release_closure():
+ """release_slot is required, not defaulted.
+
+ The parameter defaults to None and HEAD calls release_slot() BEFORE any
+ fallback could run, so a call site that omitted it was a TypeError and a
+ 500 -- on the one path that most needs to work.
+
+ Checked on the signature rather than by calling it: with a default restored,
+ HEAD calls None() and raises the SAME TypeError, so the behaviour is
+ identical and only the declaration distinguishes them.
+ """
+ import inspect as _inspect
+
+ param = _inspect.signature(sr._serve_media_response).parameters["release_slot"]
+ assert param.default is _inspect.Parameter.empty, (
+ f"release_slot defaults to {param.default!r}; a caller that forgets it "
+ "gets a runtime TypeError from calling None instead of a signature error"
+ )
+
+
+@pytest.mark.unit
+async def test_omitting_the_release_closure_is_an_error_not_a_silent_path(client_slot):
+ """And the omission is not papered over: HEAD, which has no body to defer to."""
+ with pytest.raises((TypeError, AttributeError)):
+ await sr._serve_media_response(
+ types.SimpleNamespace(method="HEAD", headers={}, query={}),
+ file_info=_info(4096),
+ streamer=_StalledStreamer(),
+ client_id=0,
+ media_ref=1,
+ release_slot=None,
+ )
+
+
+@pytest.mark.unit
+async def test_both_routes_thread_the_ladder_closure_into_the_response():
+ """Structural, because the property is about a call site, not arithmetic.
+
+ If a route forgets `release_slot=`, _serve_media_response mints a fresh
+ closure and the ladder's release stops being idempotent with the watchdog's
+ -- the exact double-decrement above, invisible until a transfer stalls at
+ just the wrong moment.
+ """
+ import ast
+ import inspect
+
+ tree = ast.parse(inspect.getsource(sr))
+ calls = [
+ n
+ for n in ast.walk(tree)
+ if isinstance(n, ast.Call) and getattr(n.func, "id", None) == "_serve_media_response"
+ ]
+ assert len(calls) == 2, f"expected 2 call sites, found {len(calls)} -- the scan is stale"
+ for call in calls:
+ kwargs = {kw.arg: kw.value for kw in call.keywords}
+ assert "release_slot" in kwargs, (
+ f"line {call.lineno}: this route does not thread a release closure into "
+ "_serve_media_response"
+ )
+ # and it must be the LADDER's closure, not a fresh one. Passing
+ # release_slot=_slot_release(client_id) satisfies the keyword check while
+ # reintroducing exactly the double-decrement this exists to prevent: the
+ # ladder would release one closure and the watchdog another.
+ assert getattr(kwargs["release_slot"], "id", None) == "release_slot", (
+ f"line {call.lineno}: passes {ast.unparse(kwargs['release_slot'])} "
+ "instead of the ladder's closure -- a second closure means the slot "
+ "is returned twice and work_loads goes negative"
+ )
+
+ # and the name it passes must be what the ladder binds. The `as` clause is
+ # on the `async with`, not on the call itself.
+ async_withs = [
+ n
+ for n in ast.walk(tree)
+ if isinstance(n, ast.AsyncWith)
+ and any(
+ isinstance(item.context_expr, ast.Call)
+ and getattr(item.context_expr.func, "id", None) == "_admission_ladder"
+ for item in n.items
+ )
+ ]
+ assert len(async_withs) == 2, (
+ f"expected 2 `async with _admission_ladder(...)` sites, found "
+ f"{len(async_withs)} -- the scan is stale"
+ )
+ for node in async_withs:
+ bindings = [
+ getattr(item.optional_vars, "id", None)
+ for item in node.items
+ if item.optional_vars is not None
+ ]
+ assert "release_slot" in bindings, (
+ f"line {node.lineno}: the ladder binds {bindings}, not 'release_slot', "
+ "so the routes cannot be passing it through"
+ )
+
+
+# --- the watchdog ----------------------------------------------------------
+
+
+async def test_the_watchdog_releases_and_closes_when_the_deadline_passes():
+ released = []
+ closed = []
+
+ async def body():
+ try:
+ while True:
+ yield b"x"
+ finally:
+ closed.append(1)
+
+ gen = body()
+ await anext(gen) # a generator that never started cannot be closed
+ task = asyncio.ensure_future(
+ sr._reclaim_slot_when_stalled(asyncio.Event(), 0.15, lambda: released.append(1), gen)
+ )
+
+ await asyncio.sleep(0.4)
+ assert released, "the watchdog let the deadline pass without reclaiming the slot"
+ assert closed, "the stalled generator was left open -- the vault fetch keeps running"
+ task.cancel()
+
+
+async def test_the_watchdog_releases_nothing_when_the_stream_completes():
+ released = []
+ completed = asyncio.Event()
+
+ async def body():
+ yield b"x"
+
+ gen = body()
+ task = asyncio.ensure_future(
+ sr._reclaim_slot_when_stalled(completed, 5.0, lambda: released.append(1), gen)
+ )
+ await anext(gen)
+ completed.set()
+ await asyncio.sleep(0.05)
+ task.cancel()
+
+ assert not released, "the watchdog reclaimed a slot from a stream that finished"
+
+
+async def test_cancelling_the_watchdog_reclaims_nothing():
+ released = []
+ completed = asyncio.Event()
+
+ async def body():
+ yield b"x"
+
+ gen = body()
+ task = asyncio.ensure_future(
+ sr._reclaim_slot_when_stalled(completed, 30.0, lambda: released.append(1), gen)
+ )
+ await asyncio.sleep(0.02)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ await asyncio.sleep(0.02)
+
+ assert not released, (
+ "cancelling the watchdog released the slot twice -- the generator's own "
+ "cleanup already did it"
+ )
+
+
+async def test_the_watchdog_does_not_close_a_generator_a_consumer_is_pulling(monkeypatch):
+ """`aclose()` only works on a generator suspended at a `yield`.
+
+ While a consumer is actively awaiting the next chunk -- the NORMAL state, and
+ exactly what a slow upstream looks like -- the generator is suspended inside
+ `await streamer.stream_file(...)` and aclose() raises
+ "RuntimeError: asynchronous generator is already running". An earlier version
+ wrapped it in suppress(RuntimeError), which turned a real, reportable failure
+ into silence while claiming the upstream fetch had been stopped.
+ """
+ monkeypatch.setattr(sr, "_STREAM_MIN_SECONDS", 0.2)
+ monkeypatch.setattr(sr, "_STREAM_MAX_SECONDS", 0.2)
+ monkeypatch.setattr(sr, "_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND", 1)
+
+ upstream_closed = []
+
+ class SlowUpstream:
+ async def stream_file(self, media_ref, offset=0, limit=0):
+ try:
+ while True:
+ yield b"x" * 64
+ await asyncio.sleep(5) # long enough that the deadline lands here
+ finally:
+ upstream_closed.append(1)
+
+ body = _body_for(SlowUpstream(), 10**7)
+ consumer = asyncio.ensure_future(_drain(body))
+ await asyncio.sleep(0.1)
+ assert body.ag_running, "the consumer should be mid-__anext__, not parked at a yield"
+
+ # Hold the watchdog so its outcome is observable. Removing the ag_running
+ # guard makes aclose() raise RuntimeError, and the effects the other
+ # assertions look at (upstream not closed, slot reclaimed) are IDENTICAL
+ # either way -- release() runs first. The only difference is whether the
+ # watchdog survives, which only surfaces by awaiting it. A task's exception
+ # reaches the loop's handler at GC time, so checking that would be racy.
+ watchdog = next(
+ (t for t in asyncio.all_tasks() if t.get_name() == "stream_slot_watchdog"),
+ None,
+ )
+ assert watchdog is not None, "no watchdog task was created"
+
+ try:
+ await asyncio.wait_for(asyncio.shield(watchdog), timeout=2.0)
+ except RuntimeError as e:
+ pytest.fail(
+ f"the watchdog died with {e!r} -- aclose() was called on a generator "
+ "a consumer is already pulling. The guard is gone."
+ )
+ except TimeoutError:
+ pytest.fail("the watchdog never finished; its deadline did not fire")
+
+ assert work_loads[0] == 0, "the slot must be reclaimed even when we cannot close"
+ assert not upstream_closed, (
+ "the upstream was closed -- aclose() cannot do that while a consumer is "
+ "pulling, so either the guard is gone or this test is not in the state "
+ "it claims to be"
+ )
+ assert not consumer.done(), "the consumer should still own the generator"
+ consumer.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await consumer
+
+
+async def test_the_watchdog_reclaims_while_a_consumer_is_mid_fetch(monkeypatch):
+ """The slot is the thing that must always come back. Even when the
+ generator cannot be closed, the counter must not stay held."""
+ monkeypatch.setattr(sr, "_STREAM_MIN_SECONDS", 0.15)
+ monkeypatch.setattr(sr, "_STREAM_MAX_SECONDS", 0.15)
+ monkeypatch.setattr(sr, "_STREAM_MIN_THROUGHPUT_BYTES_PER_SECOND", 1)
+
+ class SlowUpstream:
+ async def stream_file(self, media_ref, offset=0, limit=0):
+ while True:
+ yield b"x" * 64
+ await asyncio.sleep(5)
+
+ body = _body_for(SlowUpstream(), 10**7)
+ consumer = asyncio.ensure_future(_drain(body))
+ await asyncio.sleep(0.05)
+ await asyncio.sleep(0.4)
+
+ assert work_loads[0] == 0, (
+ "a stalled reader held its slot because the generator was mid-fetch -- "
+ "the reclaim must not depend on being able to close it"
+ )
+ consumer.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await consumer
+
+
+async def test_the_watchdog_does_not_outlive_a_healthy_stream():
+ """A finished stream must not leave a task waiting out the full deadline."""
+ body = _body_for(_StalledStreamer(), 4096)
+ [c async for c in body]
+
+ await asyncio.sleep(0.05)
+ leftovers = [
+ t
+ for t in asyncio.all_tasks()
+ if t.get_name().startswith("stream_slot_watchdog") and not t.done()
+ ]
+ assert not leftovers, f"watchdog tasks leaked: {[t.get_name() for t in leftovers]}"
diff --git a/tests/test_unit/test_time_format.py b/tests/test_unit/test_time_format.py
new file mode 100644
index 00000000..984e14db
--- /dev/null
+++ b/tests/test_unit/test_time_format.py
@@ -0,0 +1,33 @@
+import pytest
+
+from Thunder.utils.time_format import get_readable_time
+
+
+@pytest.mark.unit
+@pytest.mark.parametrize(
+ "seconds,expected",
+ [
+ (0, "0s"),
+ (-10, "0s"),
+ (59, "59s"),
+ (60, "1m"),
+ (61, "1m 1s"),
+ (3600, "1h"),
+ (3661, "1h 1m 1s"),
+ (86400, "1d"),
+ (90061, "1d 1h 1m 1s"),
+ ],
+)
+def test_get_readable_time(seconds, expected):
+ assert get_readable_time(seconds) == expected
+
+
+@pytest.mark.unit
+def test_non_int_input_is_handled():
+ # float truncates; garbage returns "N/A" via the guard
+ assert get_readable_time(90.9) == "1m 30s"
+
+
+@pytest.mark.unit
+def test_none_returns_na():
+ assert get_readable_time(None) == "N/A"
diff --git a/tests/test_unit/test_tokens_consume.py b/tests/test_unit/test_tokens_consume.py
new file mode 100644
index 00000000..ceaf3ff6
--- /dev/null
+++ b/tests/test_unit/test_tokens_consume.py
@@ -0,0 +1,208 @@
+"""Consume() status ladder: corrupt/expired tokens read "invalid", never "already"."""
+
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+
+import pytest
+
+import Thunder.utils.tokens as tokens_module
+from Thunder.utils.tokens import consume
+
+
+class _FakeTokenCol:
+ """Records every CAS filter it is handed.
+
+ ``cas_result`` is what the atomic update returns (None = the CAS lost the
+ race). ``find_one`` returns the pre-check row, or the scripted post-CAS doc
+ when a projection arg is passed.
+ """
+
+ def __init__(self, doc, post_cas_doc=None):
+ self._doc = doc
+ self._post_cas_doc = post_cas_doc
+ self.cas_filters: list[dict] = []
+ self.cas_result = None
+
+ async def find_one(self, *args, **_kwargs):
+ if self._post_cas_doc is not None and len(args) > 1:
+ return self._post_cas_doc
+ return self._doc
+
+ async def find_one_and_update(self, filter_, *_args, **_kwargs):
+ self.cas_filters.append(filter_)
+ return self.cas_result
+
+
+@pytest.mark.unit
+async def test_corrupt_token_without_expires_at_is_invalid(monkeypatch):
+ doc = {"token": "t", "user_id": 7, "activated": False} # expires_at missing
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc)))
+ status, hours = await consume("t", 7)
+ assert (status, hours) == ("invalid", 0.0)
+
+
+@pytest.mark.unit
+async def test_expired_unactivated_token_is_invalid_not_already(monkeypatch):
+ doc = {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) - timedelta(hours=1),
+ }
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc)))
+ status, hours = await consume("t", 7)
+ assert (status, hours) == ("invalid", 0.0)
+
+
+@pytest.mark.unit
+async def test_cas_loss_to_concurrent_winner_is_already(monkeypatch):
+ doc = {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ col = _FakeTokenCol(doc, post_cas_doc={"activated": True})
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ status, _hours = await consume("t", 7)
+ assert status == "already"
+
+
+@pytest.mark.unit
+async def test_cas_loss_to_expiry_is_invalid(monkeypatch):
+ doc = {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ col = _FakeTokenCol(doc, post_cas_doc={"activated": False})
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ status, _hours = await consume("t", 7)
+ assert status == "invalid"
+
+
+@pytest.mark.unit
+async def test_naive_future_expiry_treated_as_utc(monkeypatch):
+ """Legacy naive datetimes are UTC instants: future stays activatable."""
+ col = _FakeTokenCol(
+ {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1),
+ }
+ )
+ col.cas_result = {"activated": True}
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ monkeypatch.setattr(tokens_module.Var, "TOKEN_TTL_HOURS", 7)
+ monkeypatch.setattr(tokens_module.flags, "invalidate", lambda *keys: None)
+ status, _hours = await consume("t", 7)
+ assert status == "ok"
+
+
+@pytest.mark.unit
+async def test_naive_past_expiry_is_invalid(monkeypatch):
+ doc = {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1),
+ }
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc)))
+ status, hours = await consume("t", 7)
+ assert (status, hours) == ("invalid", 0.0)
+
+
+@pytest.mark.unit
+async def test_non_datetime_expiry_is_invalid_not_500(monkeypatch):
+ """Corrupt expires_at shapes fail closed instead of raising TypeError."""
+ doc = {"token": "t", "user_id": 7, "activated": False, "expires_at": "tomorrow-ish"}
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc)))
+ status, hours = await consume("t", 7)
+ assert (status, hours) == ("invalid", 0.0)
+
+
+@pytest.mark.unit
+async def test_token_for_other_user_is_wrong_user(monkeypatch):
+ doc = {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc)))
+ status, hours = await consume("t", 999)
+ assert (status, hours) == ("wrong_user", 0.0)
+
+
+@pytest.mark.unit
+async def test_ok_path_activates_and_invalidates_flags(monkeypatch):
+ col = _FakeTokenCol(
+ {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ )
+ col.cas_result = {"activated": True}
+ invalidated: list = []
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ monkeypatch.setattr(tokens_module.Var, "TOKEN_TTL_HOURS", 7)
+ monkeypatch.setattr(tokens_module.flags, "invalidate", lambda *keys: invalidated.append(keys))
+ status, hours = await consume("t", 7)
+ assert status == "ok"
+ assert hours == 7.0 # round(ttl_hours, 1)
+ assert (("allowed", 7), ("token_ok", 7)) in invalidated
+
+
+@pytest.mark.unit
+async def test_cas_predicate_carries_the_whole_guarded_update(monkeypatch):
+ """The atomicity lives entirely in this filter, not in the Python around
+ it: a concurrent second activation, a wrong user or a token that expired
+ between the pre-check and the write must simply not match. This used to be
+ invisible here, and only the Docker-backed integration tier could catch a
+ dropped clause.
+ """
+ col = _FakeTokenCol(
+ {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) + timedelta(hours=1),
+ }
+ )
+ col.cas_result = {"activated": True}
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ monkeypatch.setattr(tokens_module.Var, "TOKEN_TTL_HOURS", 7)
+ monkeypatch.setattr(tokens_module.flags, "invalidate", lambda *keys: None)
+
+ assert (await consume("t", 7))[0] == "ok"
+
+ assert len(col.cas_filters) == 1
+ flt = col.cas_filters[0]
+ assert flt["token"] == "t"
+ assert flt["user_id"] == 7
+ # refuses to match an already-activated token: the double-activation guard
+ assert flt["activated"] == {"$ne": True}
+ # refuses to match a token that expired between pre-check and CAS
+ assert isinstance(flt["expires_at"], dict) and "$gt" in flt["expires_at"]
+
+
+@pytest.mark.unit
+async def test_cas_is_never_reached_for_expired_or_foreign_tokens(monkeypatch):
+ """The pre-check must short-circuit before any write is attempted."""
+ for doc in (
+ {"token": "t", "user_id": 7, "activated": True}, # already activated
+ {
+ "token": "t",
+ "user_id": 7,
+ "activated": False,
+ "expires_at": datetime.now(UTC) - timedelta(minutes=1),
+ }, # expired
+ ):
+ col = _FakeTokenCol(doc)
+ monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col))
+ await consume("t", 7)
+ assert col.cas_filters == [], f"CAS attempted for {doc}"
diff --git a/tests/test_unit/test_touch_buffer.py b/tests/test_unit/test_touch_buffer.py
new file mode 100644
index 00000000..9ac33a3c
--- /dev/null
+++ b/tests/test_unit/test_touch_buffer.py
@@ -0,0 +1,326 @@
+"""Buffered file touches must survive a cancelled flush.
+
+The touch buffer is drained at shutdown, and shutdown cancels background
+tasks. `_bulk_flush` snapshots the buffer *before* awaiting the bulk write,
+so a cancellation landing mid-write used to drop those deltas on the floor:
+the buffer was already cleared and `except Exception` cannot catch
+`CancelledError`. These tests pin the re-queue behaviour.
+"""
+
+import asyncio
+from collections import OrderedDict
+
+import pytest
+
+from Thunder.utils import canonical_files as cf
+
+
+class _FakeDb:
+ """Database stand-in that can block mid-write to open the race window.
+
+ Mirrors Database.bulk_touch_file_records' signature and return contract,
+ including ``raise_on_error``. An earlier version took only ``rows``, so a
+ TypeError from the unexpected kwarg was re-queued forever and the flush
+ loop spun -- the fake had drifted from the thing it stands in for.
+ """
+
+ def __init__(self, block: asyncio.Event | None = None) -> None:
+ self.calls: list[list[tuple[str, int, int]]] = []
+ self.raise_on_error_seen: list[bool] = []
+ self._block = block
+ self.entered = asyncio.Event()
+
+ async def bulk_touch_file_records(self, rows, *, raise_on_error: bool = False) -> bool:
+ self.calls.append(list(rows))
+ self.raise_on_error_seen.append(raise_on_error)
+ self.entered.set()
+ if self._block is not None:
+ await self._block.wait()
+ return True
+
+
+@pytest.fixture
+def touch_env(monkeypatch):
+ """Isolate the module's touch state from the rest of the suite."""
+ monkeypatch.setattr(cf, "db", _FakeDb())
+ monkeypatch.setattr(cf, "_pending_touches", OrderedDict())
+ monkeypatch.setattr(cf, "_flush_task", None)
+ monkeypatch.setattr(cf, "_dropped_touches", 0)
+ monkeypatch.setattr(cf, "_cache_by_hash", OrderedDict())
+ monkeypatch.setattr(cf, "_cache_by_unique_id", OrderedDict())
+ monkeypatch.setattr(cf, "_FLUSH_DELAY_SECONDS", 0)
+ return cf.db
+
+
+def _record(public_hash: str) -> dict:
+ return {"public_hash": public_hash, "file_unique_id": f"u-{public_hash}", "seen_count": 0}
+
+
+@pytest.mark.unit
+async def test_cancelled_flush_keeps_deltas(monkeypatch):
+ """Cancelling the flush task mid-write must not empty the buffer."""
+ block = asyncio.Event()
+ fake = _FakeDb(block=block)
+ monkeypatch.setattr(cf, "db", fake)
+ monkeypatch.setattr(cf, "_pending_touches", OrderedDict())
+ monkeypatch.setattr(cf, "_flush_task", None)
+ monkeypatch.setattr(cf, "_dropped_touches", 0)
+ monkeypatch.setattr(cf, "_cache_by_hash", OrderedDict())
+ monkeypatch.setattr(cf, "_cache_by_unique_id", OrderedDict())
+ monkeypatch.setattr(cf, "_FLUSH_DELAY_SECONDS", 0)
+
+ public_hash = "a" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash), reused=True)
+ await fake.entered.wait() # the bulk write is now in flight
+
+ assert cf._flush_task is not None
+ cf._flush_task.cancel()
+ # the flush task absorbs its own cancellation by design, so it completes
+ await cf._flush_task
+ assert cf._flush_task is None
+
+ buffered = cf._pending_touches.get(public_hash)
+ assert buffered is not None, "cancelled flush dropped the buffered deltas"
+ assert buffered[1:] == (1, 1) # reuse + seen survive
+
+
+@pytest.mark.unit
+async def test_drain_writes_back_requeued_deltas(monkeypatch):
+ """After a cancelled flush, the shutdown drain must persist the deltas."""
+ block = asyncio.Event()
+ fake = _FakeDb(block=block)
+ monkeypatch.setattr(cf, "db", fake)
+ monkeypatch.setattr(cf, "_pending_touches", OrderedDict())
+ monkeypatch.setattr(cf, "_flush_task", None)
+ monkeypatch.setattr(cf, "_dropped_touches", 0)
+ monkeypatch.setattr(cf, "_cache_by_hash", OrderedDict())
+ monkeypatch.setattr(cf, "_cache_by_unique_id", OrderedDict())
+ monkeypatch.setattr(cf, "_FLUSH_DELAY_SECONDS", 0)
+
+ public_hash = "b" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash))
+ await fake.entered.wait()
+
+ cf._flush_task.cancel()
+ await cf._flush_task
+ block.set() # let the in-flight write (and any drain) proceed
+
+ await cf.drain_background_touch_tasks()
+
+ # the first write was abandoned by the cancellation, so the drain has to
+ # write the re-queued delta again -- an over-count is the accepted trade for
+ # never dropping a touch (see _bulk_flush's note on partial writes)
+ assert len(fake.calls) == 2, f"drain never rewrote the re-queued delta: {fake.calls}"
+ assert fake.calls[-1] == [(public_hash, 0, 1)]
+ assert not cf._pending_touches
+
+
+@pytest.mark.unit
+async def test_failed_flush_keeps_deltas(touch_env):
+ """A failed write is still retried on the next flush (pre-existing behaviour).
+
+ The stub honours ``raise_on_error`` exactly as Database.bulk_touch_file_
+ records does. An earlier version raised unconditionally, which is NOT what
+ the real callee does: it swallows every Mongo exception and returns False
+ unless asked to re-raise. That mis-model is why a Blocking bug shipped --
+ _bulk_flush omitted raise_on_error, so in production the exception never
+ arrived, the re-queue never ran, and every buffered delta was lost -- while
+ this test, against the raising stub, stayed green.
+ """
+
+ async def _fail_like_the_real_callee(rows, *, raise_on_error: bool = False):
+ if raise_on_error:
+ raise RuntimeError("mongo down")
+ return False
+
+ touch_env.bulk_touch_file_records = _fail_like_the_real_callee
+ public_hash = "c" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash))
+
+ await cf._bulk_flush()
+
+ assert cf._pending_touches.get(public_hash) is not None, (
+ "the delta was dropped: a Mongo failure silently loses seen/reuse "
+ "counts while the in-memory cache has already been incremented"
+ )
+
+
+@pytest.mark.unit
+async def test_flush_does_not_swallow_a_failed_write(touch_env):
+ """Pin the call contract itself: the callee defaults to returning False,
+ so _bulk_flush must ask it to raise or the failure is unobservable."""
+ seen_kwargs = {}
+
+ async def _capture(rows, *, raise_on_error: bool = False):
+ seen_kwargs["raise_on_error"] = raise_on_error
+ return True
+
+ touch_env.bulk_touch_file_records = _capture
+ public_hash = "e" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash))
+
+ await cf._bulk_flush()
+
+ assert seen_kwargs.get("raise_on_error") is True, (
+ "bulk_touch_file_records is called without raise_on_error, so it "
+ "swallows Mongo errors and its False return is discarded"
+ )
+
+
+@pytest.mark.unit
+async def test_a_swallowed_write_is_detected_even_if_raise_on_error_is_dropped(touch_env):
+ """Defence in depth: should the callee ever change back to returning False,
+ _bulk_flush must not read that as success."""
+
+ async def _return_false(rows, *, raise_on_error: bool = False):
+ return False
+
+ touch_env.bulk_touch_file_records = _return_false
+ public_hash = "f" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash))
+
+ await cf._bulk_flush()
+
+ assert cf._pending_touches.get(public_hash) is not None, (
+ "a False return was treated as a successful flush; the delta is lost with no error anywhere"
+ )
+
+
+@pytest.mark.unit
+async def test_concurrent_touches_merge_into_one_row(touch_env):
+ """Two touches for the same file collapse into summed deltas, one row."""
+ public_hash = "d" * cf.PUBLIC_HASH_LENGTH
+ cf.schedule_touch_file_record(_record(public_hash))
+ cf.schedule_touch_file_record(_record(public_hash), reused=True)
+
+ assert len(cf._pending_touches) == 1
+ await cf._bulk_flush()
+
+ assert touch_env.calls == [[(public_hash, 1, 2)]]
+ assert not cf._pending_touches
+
+
+# --- the two bounds the comments claim ------------------------------------
+#
+# "memory stays capped" and "repeated failures must not grow the buffer" were
+# both justified in the code by a comment and enforced by nothing: removing
+# either while-loop left the suite green.
+
+
+@pytest.mark.unit
+async def test_the_buffer_is_capped(touch_env):
+ from Thunder.utils import canonical_files as cf
+
+ for i in range(cf._TOUCH_BUFFER_MAX + 250):
+ cf.schedule_touch_file_record(_record(f"{i:032x}"[: cf.PUBLIC_HASH_LENGTH]))
+ assert len(cf._pending_touches) <= cf._TOUCH_BUFFER_MAX, (
+ f"the buffer grew to {len(cf._pending_touches)} entries, past the cap "
+ f"of {cf._TOUCH_BUFFER_MAX}"
+ )
+
+ assert cf._dropped_touches >= 250, "the overflow was not counted"
+
+
+@pytest.mark.unit
+async def test_the_oldest_entries_drop_first(touch_env):
+ """Dropping the NEWEST would discard the deltas we just took, which is worse
+ than dropping the ones we have been failing to flush longest."""
+ from Thunder.utils import canonical_files as cf
+
+ total = cf._TOUCH_BUFFER_MAX + 10
+ for i in range(total):
+ cf.schedule_touch_file_record(_record(f"{i:032x}"[: cf.PUBLIC_HASH_LENGTH]))
+
+ keys = list(cf._pending_touches)
+ assert len(keys) == cf._TOUCH_BUFFER_MAX
+ expected = [f"{i:032x}"[: cf.PUBLIC_HASH_LENGTH] for i in range(10, total)]
+ assert keys == expected, "the wrong end of the buffer was dropped"
+
+
+@pytest.mark.unit
+async def test_the_hottest_key_is_not_the_one_evicted(touch_env):
+ """Assigning to an existing OrderedDict key does NOT reorder it.
+
+ That made this a silent data-loss regression: the file touched most often
+ stayed at position 0 and was the first thing popitem(last=False) threw away,
+ taking every delta it had accumulated with it, while files touched once each
+ were kept. A test that only ever inserts never-repeated keys cannot see this,
+ because for them insertion order and touch order are the same thing.
+ """
+ from Thunder.utils import canonical_files as cf
+
+ keys = [f"{i:032x}"[: cf.PUBLIC_HASH_LENGTH] for i in range(cf._TOUCH_BUFFER_MAX)]
+ for k in keys:
+ cf.schedule_touch_file_record(_record(k))
+
+ hot = keys[0]
+ for _ in range(50):
+ cf.schedule_touch_file_record(_record(hot))
+
+ assert cf._pending_touches[hot][2] == 51, "the delta did not accumulate"
+ assert list(cf._pending_touches).index(hot) == len(cf._pending_touches) - 1, (
+ "the most-touched file is still at the FRONT of the buffer -- "
+ "re-touching it did not move it to the back, so it is next to be evicted"
+ )
+
+ # one new key forces exactly one eviction
+ cf.schedule_touch_file_record(_record("ff" * 16))
+
+ assert hot in cf._pending_touches, (
+ "the most frequently touched file was evicted while four-thousand files "
+ "touched once each were kept -- its accumulated deltas are gone"
+ )
+ assert cf._dropped_touches == 1
+
+
+@pytest.mark.unit
+async def test_a_requeued_entry_is_not_evicted_by_the_next_overflow(touch_env):
+ """_requeue_touches re-inserts an entry that keeps failing to flush. Without
+ moving it to the back it sits at the front and the trim below drops it on the
+ very next overflow -- so the entry that failed most is the first lost, while
+ live arrivals at the back survive.
+ """
+ from Thunder.utils import canonical_files as cf
+
+ keys = [f"{i:032x}"[: cf.PUBLIC_HASH_LENGTH] for i in range(cf._TOUCH_BUFFER_MAX)]
+ for k in keys:
+ cf.schedule_touch_file_record(_record(k))
+
+ doomed = keys[0]
+ before = cf._pending_touches[doomed]
+ cf._requeue_touches([(doomed, before)])
+
+ assert list(cf._pending_touches).index(doomed) == len(cf._pending_touches) - 1, (
+ "the re-queued entry is still at the front, so the next overflow drops it"
+ )
+
+ cf.schedule_touch_file_record(_record("ff" * 16))
+ assert doomed in cf._pending_touches, "the persistently failing entry was evicted"
+
+
+@pytest.mark.unit
+async def test_a_repeatedly_failing_flush_does_not_grow_the_buffer(touch_env):
+ """Re-queued deltas merge by key, so a persistently failing Mongo cannot make
+ the buffer grow -- the merge is what bounds it, not the cap alone."""
+
+ async def _fail_like_the_real_callee(rows, *, raise_on_error: bool = False):
+ if raise_on_error:
+ raise RuntimeError("mongo down")
+ return False
+
+ touch_env.bulk_touch_file_records = _fail_like_the_real_callee
+ public_hash = "9" * cf.PUBLIC_HASH_LENGTH
+ rounds = cf._TOUCH_BUFFER_MAX + 20
+
+ for _ in range(rounds):
+ cf.schedule_touch_file_record(_record(public_hash))
+ await cf._bulk_flush()
+
+ assert len(cf._pending_touches) == 1, (
+ f"the same file grew into {len(cf._pending_touches)} buffered rows -- a "
+ "failing flush is multiplying deltas instead of merging them"
+ )
+ assert cf._pending_touches[public_hash][2] == rounds, (
+ f"the merged delta is {cf._pending_touches[public_hash][2]}, expected one "
+ f"per schedule call ({rounds})"
+ )
diff --git a/tests/test_unit/test_user_dedup.py b/tests/test_unit/test_user_dedup.py
new file mode 100644
index 00000000..821e572d
--- /dev/null
+++ b/tests/test_unit/test_user_dedup.py
@@ -0,0 +1,99 @@
+"""users.id dedup must scale and must keep the oldest document.
+
+The previous implementation sorted the whole collection in memory and then
+deleted with a single ``{"_id": {"$nin": keep_ids}}`` -- one ObjectId per user
+inside one BSON document (16 MB cap), which trips the client ``timeoutMS`` on a
+large collection and leaves ``users.id`` permanently unindexed.
+"""
+
+import pytest
+
+from Thunder.utils.database import Database
+
+
+class _FakeUsers:
+ """Async stand-in for the ``users`` collection."""
+
+ def __init__(self, groups):
+ self.groups = groups
+ self.pipeline = None
+ self.aggregate_kwargs = None
+ self.deletes: list[dict] = []
+
+ async def aggregate(self, pipeline, **kwargs):
+ self.pipeline = pipeline
+ self.aggregate_kwargs = kwargs
+
+ async def _cursor():
+ for g in self.groups:
+ yield g
+
+ return _cursor()
+
+ async def delete_many(self, flt):
+ self.deletes.append(flt)
+ return type("R", (), {"deleted_count": 1})()
+
+
+def _db(users):
+ db = Database.__new__(Database) # no client needed
+ db.col = users
+ return db
+
+
+@pytest.mark.unit
+async def test_dedup_narrows_to_duplicate_groups_only():
+ users = _FakeUsers([{"_id": "tg-1", "n": 2, "keep": "ObjectId(newer)"}])
+ await _db(users)._deduplicate_users()
+
+ stages = users.pipeline
+ assert {"$match": {"n": {"$gt": 1}}} in stages, "must not walk every user id"
+ # server-side spill instead of an in-memory sort over the whole collection
+ assert users.aggregate_kwargs.get("allowDiskUse") is True
+
+
+@pytest.mark.unit
+async def test_dedup_deletes_per_group_without_a_nin_array():
+ """One ObjectId per user inside a single $nin array is what blew the 16 MB
+ BSON document limit; each delete must carry its own small filter."""
+ users = _FakeUsers(
+ [
+ {"_id": "tg-1", "n": 2, "keep": "ObjectId(keep-1)"},
+ {"_id": "tg-2", "n": 3, "keep": "ObjectId(keep-2)"},
+ ]
+ )
+ await _db(users)._deduplicate_users()
+
+ assert len(users.deletes) == 2
+ for flt in users.deletes:
+ assert "$nin" not in flt, f"bulk $nin filter reintroduced: {flt}"
+ assert set(flt) == {"id", "_id"}
+ assert "$ne" in flt["_id"], "the surviving document must be excluded"
+
+
+@pytest.mark.unit
+async def test_dedup_keeps_the_oldest_by_join_date():
+ users = _FakeUsers([{"_id": "tg-1", "n": 2, "keep": "ObjectId(oldest)"}])
+ await _db(users)._deduplicate_users()
+
+ assert users.pipeline[0] == {"$sort": {"join_date": 1}}
+ group = next(s for s in users.pipeline if "$group" in s)
+ assert group["$group"]["keep"] == {"$first": "$_id"}
+
+
+@pytest.mark.unit
+async def test_dedup_is_a_noop_without_duplicates():
+ users = _FakeUsers([])
+ await _db(users)._deduplicate_users()
+
+ assert users.deletes == []
+
+
+@pytest.mark.unit
+async def test_dedup_never_collapses_documents_with_no_id():
+ """$group folds every missing id into one null bucket. Deleting that
+ bucket would remove unrelated users -- they must be left alone."""
+ users = _FakeUsers([{"_id": None, "n": 5, "keep": "ObjectId(anything)"}])
+ await _db(users)._deduplicate_users()
+
+ assert users.deletes == [], "documents without an id were treated as duplicates"
diff --git a/thunder.sh b/thunder.sh
index 8e84da07..9e8d8e1b 100644
--- a/thunder.sh
+++ b/thunder.sh
@@ -1 +1,10 @@
-python3 update.py && python3 -m Thunder
\ No newline at end of file
+#!/usr/bin/env bash
+# boot orchestration only: best-effort shell-free update; a failing update never blocks boot.
+set -u
+
+# work from the script's directory so manual invocations from any cwd behave
+# like the Docker entrypoint
+cd "$(dirname "$0")" || exit 1
+
+python3 update.py || true
+exec python3 -m Thunder
diff --git a/update.py b/update.py
index 801e08c7..816b64ec 100644
--- a/update.py
+++ b/update.py
@@ -1,41 +1,145 @@
-from os import path as opath, getenv, rename
-from subprocess import run as srun
-from dotenv import load_dotenv
-from Thunder.utils.logger import logger
-
-load_dotenv('config.env', override=True)
-
-UPSTREAM_REPO = getenv('UPSTREAM_REPO', "")
-UPSTREAM_BRANCH = getenv('UPSTREAM_BRANCH', "main")
-
-if UPSTREAM_REPO:
- config_backup = '../config.env.tmp'
-
- try:
- if opath.exists('config.env'):
- rename('config.env', config_backup)
-
- if opath.exists('.git'):
- srun(["rm", "-rf", ".git"])
-
- git_commands = (
- f"git init -q && "
- f"git config --global user.email thunder@update.local && "
- f"git config --global user.name Thunder && "
- f"git add . && "
- f"git commit -sm update -q && "
- f"git remote add origin {UPSTREAM_REPO} && "
- f"git fetch origin -q && "
- f"git reset --hard origin/{UPSTREAM_BRANCH} -q"
- )
-
- result = srun(git_commands, shell=True)
-
- if result.returncode == 0:
- logger.info('Successfully updated with latest commit from UPSTREAM_REPO')
- else:
- logger.error('Something went wrong while updating, check UPSTREAM_REPO if valid or not!')
-
- finally:
- if opath.exists(config_backup):
- rename(config_backup, 'config.env')
+"""Boot-time best-effort self-update.
+
+Non-destructive ``pull --ff-only`` via argv-list subprocess; any failure
+logs and keeps running the old code.
+"""
+
+import os
+import re
+import shutil
+import subprocess
+
+from dotenv import load_dotenv
+
+from Thunder.utils.logger import logger
+
+# Guarantees (the old shell=True chain had none): argv-list only, no
+# destructive git ops, no global config mutation; no-ops cleanly without git.
+
+# Real environment wins over files; .local wins over base (same precedence
+# as Thunder/vars.py — load local first so setdefault keeps its values).
+load_dotenv("config.env.local", override=False)
+load_dotenv("config.env", override=False)
+
+UPSTREAM_REPO = os.getenv("UPSTREAM_REPO", "")
+UPSTREAM_BRANCH = os.getenv("UPSTREAM_BRANCH", "main")
+
+# config.env lives beside the app and must survive the pull
+_CONFIG_BACKUP = "config.env.bak"
+
+
+def _recover_config_backup() -> None:
+ """A crash between _backup_config and _restore_config would otherwise
+ leave the app permanently without config.env (next boot hard-fails).
+ Restore any orphaned backup before doing anything else.
+
+ A config.env that git tracks was just shipped by the upstream pull, not
+ written by the operator (P3-11): the operator's backup wins there too.
+ An untracked config.env is the operator's own (re-created after the
+ crash) and is left alone; the stale backup stays in place for safety.
+ """
+ if not os.path.exists(_CONFIG_BACKUP):
+ return
+ restore = not os.path.exists("config.env")
+ if not restore and os.path.isdir(".git") and shutil.which("git") is not None:
+ try:
+ tracked = subprocess.run(
+ ["git", "ls-files", "--error-unmatch", "config.env"],
+ capture_output=True,
+ timeout=10,
+ )
+ restore = tracked.returncode == 0
+ except (subprocess.SubprocessError, OSError) as e:
+ # a hung or unavailable git must not abort the boot: skip the
+ # restore rather than guessing which config.env is authoritative
+ logger.warning(f"Could not determine whether config.env is tracked: {e}")
+ if restore:
+ try:
+ os.replace(_CONFIG_BACKUP, "config.env")
+ logger.info("Recovered config.env from orphaned backup.")
+ except OSError as e:
+ logger.error(f"Could not recover config.env backup: {e}")
+
+
+def _backup_config() -> bool:
+ try:
+ if os.path.exists("config.env"):
+ os.replace("config.env", _CONFIG_BACKUP)
+ return True
+ except OSError as e:
+ logger.warning(f"Could not back up config.env: {e}")
+ return False
+
+
+def _restore_config(backed_up: bool) -> None:
+ if backed_up and os.path.exists(_CONFIG_BACKUP):
+ try:
+ os.replace(_CONFIG_BACKUP, "config.env")
+ except OSError as e:
+ logger.error(f"Could not restore config.env: {e}")
+
+
+def _redact_credentials(text: str) -> str:
+ """Git echoes remote URLs on failure; strip embedded tokens (user:pass
+ and user@host forms) before the output reaches the logs."""
+ return re.sub(r"(?<=//)[^@/\s]+@", "@", text)
+
+
+def main() -> None:
+ # restore orphans first: early returns below must not skip recovery
+ _recover_config_backup()
+ if not UPSTREAM_REPO:
+ return
+ if UPSTREAM_REPO.startswith("-") or UPSTREAM_BRANCH.startswith("-"):
+ # operator-supplied env vars, but git would treat leading-dash values as options
+ logger.info("UPSTREAM_REPO/UPSTREAM_BRANCH must not start with '-'; skipping self-update.")
+ return
+ if shutil.which("git") is None:
+ logger.info("git not available; skipping self-update (image without git).")
+ return
+ if not os.path.isdir(".git"):
+ logger.info("Not a git repository; skipping self-update.")
+ return
+
+ # defense-in-depth: the argv/dash guards do not stop the git-remote-ext family
+ # (ext::sh -c ...) -- allowlist ordinary transport schemes only. ssh:// is
+ # excluded on purpose: the image ships no keys, so it would fail noisily
+ # on every boot; use https with a token URL instead.
+ if "://" in UPSTREAM_REPO and UPSTREAM_REPO.split("://", 1)[0] not in {
+ "https",
+ "http",
+ "git",
+ }:
+ logger.error("UPSTREAM_REPO uses an unsupported scheme; skipping self-update.")
+ return
+ if UPSTREAM_REPO.startswith("ext::"):
+ logger.error("UPSTREAM_REPO uses a forbidden scheme; skipping self-update.")
+ return
+
+ backed_up = _backup_config()
+ try:
+ # git >= 2.27 warns without the refspec; be explicit.
+ result = subprocess.run(
+ ["git", "pull", "--ff-only", UPSTREAM_REPO, UPSTREAM_BRANCH],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ if result.returncode == 0:
+ logger.info("Self-update pulled latest commit from UPSTREAM_REPO.")
+ else:
+ # keep running the old code; never hard-fail the boot
+ logger.error(
+ "Self-update failed (non-destructive, keeping current code): "
+ f"{_redact_credentials((result.stderr or result.stdout or '').strip()[:500])}"
+ )
+ except subprocess.TimeoutExpired:
+ logger.error("Self-update timed out; keeping current code.")
+ except Exception as e:
+ logger.error(f"Self-update failed: {e}; keeping current code.")
+ finally:
+ _restore_config(backed_up)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..db44bab9
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1858 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+resolution-markers = [
+ "python_full_version >= '3.15' and sys_platform != 'win32'",
+ "python_full_version >= '3.15' and sys_platform == 'win32'",
+ "python_full_version < '3.15' and sys_platform != 'win32'",
+ "python_full_version < '3.15' and sys_platform == 'win32'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "ast-serialize"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/c0/5bb6885a9608d86ee5712c0d88bc405d3a49f3e44231576e130ea2f53d34/ast_serialize-0.9.0.tar.gz", hash = "sha256:79fe8be1c934aa572940d1811d8dbe4d1b6f22291e3f16755c9b062e9ac92fb7", size = 951293, upload-time = "2026-09-02T15:50:45.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/76/497f19d9bdb3899a1efd82e2957f455d0c6e0cb9ebbc254735acb1f74235/ast_serialize-0.9.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ae1c46eb97865823f9843c4b80145e011874923e1a4a44b45738a5309d83e9f5", size = 889442, upload-time = "2026-09-02T15:49:21.144Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c5/9fb64b7106c5534739322c74be7b743c4f2e3b5fd05d5b8e677f05c54d5f/ast_serialize-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af082cb7e6c4fa3a428aa616c13d709a944076eba84a73184de15621cc1a915d", size = 1226721, upload-time = "2026-09-02T15:49:22.612Z" },
+ { url = "https://files.pythonhosted.org/packages/27/67/b550fc81aa0133808410783c6d9a1b925e31610d226e836e21337850af55/ast_serialize-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e9f2540741ad10657a209209f7e5cc6b530eb3ed145fd77258ab43542d96ad7", size = 1207369, upload-time = "2026-09-02T15:49:23.916Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1e/ed9e66deb7da63e44d0c0fd3a8feef698882ed56ea521a29494d4616eb46/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95485d5e8704af2ecc7f723757b88f992ae8122028d687ccf877cad2b4c3da4", size = 1273073, upload-time = "2026-09-02T15:49:25.336Z" },
+ { url = "https://files.pythonhosted.org/packages/68/8f/cd337551d7a68c982425bbf91f183943d7ccc62394002c74807a7f0e60db/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b559cffac5a71a698d9194e4295765ff2132a10fd1284860f02b30f12c1f729e", size = 1279045, upload-time = "2026-09-02T15:49:26.75Z" },
+ { url = "https://files.pythonhosted.org/packages/40/c6/98dc41eb4122d5da83241e805739838ed59e1e1b9006cbed89ded635a17f/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dad8a3f7106efcf252fc289c092ee0cee5c3512c0088bcf3fffa01458323092f", size = 1539300, upload-time = "2026-09-02T15:49:28.213Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/d2/d94cede4b3f2a4e329d8ca92218f0846cfcc9257be91c5bf1168671f4ab5/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24de2bc930b7e1ca86641136b9875a1e5f80f52b484deddc67893eeaf9077bd9", size = 1291957, upload-time = "2026-09-02T15:49:29.643Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/85/8ac18d754225cf13392786b23d4ba84273ceee562699362f22e61942ce64/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ac7b4cdf89ca8318aae157d824017596784982851c8a89a621973f261574696", size = 1291779, upload-time = "2026-09-02T15:49:31.199Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ed/cc757fec9e96e29f19a6f818e05147e4f2949356258a3243412756f22a2e/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:95dcb93f30258dcf09d9b6a302dac9323b70e9df8c18ee0bf4ea1fa7cc5f1875", size = 1299730, upload-time = "2026-09-02T15:49:32.774Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/8c/a575ae0ae954f21a187b4c1d8cec28d81a009693bd13a1388eec72d9b55a/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed5824b4b2fa37ad93fa2db23d8243a3d315b3e2d7ca70f99b2727d8788af05", size = 1344671, upload-time = "2026-09-02T15:49:34.217Z" },
+ { url = "https://files.pythonhosted.org/packages/80/41/0b2b15c0ae5f9a95f433016d1a59a3227eebbb378654d6354206c8ac8e8d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c69f2ce565c786bd3853ce42495e8a63610516f79651c7cb4f2cd0ddfaee52", size = 1448527, upload-time = "2026-09-02T15:49:35.68Z" },
+ { url = "https://files.pythonhosted.org/packages/18/be/ee89cb6a5d3427946532f0611b514befdd69564803e9a9f9ce712f9d2654/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8c824d822a2ac54ec4228b88c0d170ab0024cf285eeee57b9d4f994003fb553", size = 1554045, upload-time = "2026-09-02T15:49:37.15Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/4d/eaada807f98a2f0d370fec4b46c84f0e551a62911e751a76ecb32bef4dde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c55010ea0fafc6bc8231809d328bda781bfe41a01c43522be5eb3713fd855cda", size = 1547578, upload-time = "2026-09-02T15:49:38.791Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/0c/046c531f4af4e1bc3314077a84b3099f38b45e2d969faa24acedb3066d92/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:322282ac5337e5e776bc416f2b5201e680fa4dacf92ea739bd35e46a28a66c41", size = 1671896, upload-time = "2026-09-02T15:49:40.273Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/80/8d32aa0cf4e3e566399b2079a4c47f8ad3c62155f6ee1fe63631b6d3fdde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ded5ed06ec469407d6cd571ace7a7a25809cc388e4bac1dd35c7747469fc7fdf", size = 1472895, upload-time = "2026-09-02T15:49:41.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/b9/8204c7e3d8abd4b0c7a56a8d5cce05fcacfd6a5d63ffbd812b8b94040d6d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3ee40752ddb4fb5c6a67161d13f3ce3df7987dcb9272260542a86b0be1519ae2", size = 1492731, upload-time = "2026-09-02T15:49:43.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/72/ff5c44c19409686798feeb1fbe209f2be78b4b64948d5aa2ddeec8901591/ast_serialize-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:d9c635eacfc02b91da6796d3b5ff9086e511b8a29b19f9b3f4f978b8d170f838", size = 1112847, upload-time = "2026-09-02T15:49:45.181Z" },
+ { url = "https://files.pythonhosted.org/packages/20/75/fa5be1a94d189adafadf9c5f07fffd66af7a8061c4cff92f75285ef79d10/ast_serialize-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:62a96e327e2a178d6c295b10422e95c992a9286f0ec1b2bc7cd5b4873252a38c", size = 1146846, upload-time = "2026-09-02T15:49:46.63Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ef/b2bfb331b6e379d435543f6d3be0b590e7a2f441d31ccc17d75f2d2d7cb8/ast_serialize-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:41da4332492222d56345d5e436eed4fbec76caadee959f6ffa3cd2fc1bd51895", size = 1119605, upload-time = "2026-09-02T15:49:48.14Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f9/a4af1bf8b35927814c09d90c3965dbfaa75c489ba34372bffafbc2209f40/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:383f56e3ae925f154632458f01b4bfcde3dd382f3ec04f5c7f6d72f76524ff48", size = 1226344, upload-time = "2026-09-02T15:49:49.79Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/6d/d3a95823a803c21f5c9df595a0bb93aada22e7aa22bf875fe00d89422d7f/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9ef3d4173907bd19aa8f1683be9f06e7862e6cdf2ca6bca3633305c0df32063", size = 1207384, upload-time = "2026-09-02T15:49:51.22Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/97/6e7f46c8455b738609c29d1b7655307a168c4b40ce4c7a2c678c8ed9cf2e/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9482672ca8ec09f85cd050a053fb88c30c882c8e20ce7a140d8defe19c0ef2eb", size = 1273139, upload-time = "2026-09-02T15:49:52.679Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6e/25b70733f061766865cb04d913dc5332037c595796b871d52ab5b569abb8/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6a568d1d489f0669a31aed90ca4845aa7f08e1b8cd5d05e1905dbdc3ae9b2b0", size = 1278242, upload-time = "2026-09-02T15:49:54.236Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/f0/b7820399d9c5a0b7f07c239b6da93d2e21a1b3785137fa00e16528e414b3/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4dd005d4095a13eb312dc712c943c7730f262b000a87df963925328a38ffdb", size = 1541009, upload-time = "2026-09-02T15:49:56.149Z" },
+ { url = "https://files.pythonhosted.org/packages/08/56/5146f1d2a77516e697f6f42825df79137e43560675cb4605c467775f8b4a/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:207ac73afa1f4654840593853c130eac2591dd942434176eea33f730afb3359b", size = 1290898, upload-time = "2026-09-02T15:49:57.502Z" },
+ { url = "https://files.pythonhosted.org/packages/69/c4/87cd16228796d703de795a369b90b0f57f0f017f90f55c4c5876e3513a03/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae01129e2cc5d57a3c434d8a990019de039350310a2e1dd3c9f61311964cf25", size = 1291742, upload-time = "2026-09-02T15:49:58.982Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/eb/13465c297268c5170b2bb746d75f37a8fad44a94a89b593071affc1071d0/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4c522377f383670abfe21c94edc3032cb3bd34d8fcacd280fa9556907d4edd4b", size = 1300180, upload-time = "2026-09-02T15:50:00.454Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/a7/10b84c4274b2507b0ed9cc1654058ad64bcedb6ac574753d6a461bc6e204/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd886f6e900f5e13f758cb8ef359652e690e4f3f9c257a7269e095940534167", size = 1345857, upload-time = "2026-09-02T15:50:01.875Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/dc/2702182c9773a15de9aabfaf66da7cb87548a56a6bac24f0c9176a4a13c3/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f3f0f3359cf0f22bf096b07021ffe6bf0ec88ac8a2cf7ce5f4701af973112faa", size = 1448544, upload-time = "2026-09-02T15:50:03.496Z" },
+ { url = "https://files.pythonhosted.org/packages/59/b5/eeef2124c9563b9861707ef4db91f153f3bb37b3e0cca9543bb88a4e9e53/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:6c428444656cffbd32c1e76626c6eec5237b58c8fcb0b5d3df75941cd50c4f3c", size = 1551572, upload-time = "2026-09-02T15:50:04.982Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/8c/81d18349f1dffdcfeb80671bd737e342c86348e183692d9d0d8f573d1385/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:54f0babed5e2a4eb86a0716ac612aff33f933e7572e5bc067adcdbe672a26321", size = 1548118, upload-time = "2026-09-02T15:50:06.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1f/339131b60d1b0df13d9f3470cfac70f858b5649188ea01b2e7b39caeb720/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b779fdaee34d19900a5ba5fd6bd4cefe225650081f9de28de256eacee5113c2", size = 1674707, upload-time = "2026-09-02T15:50:07.919Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0a/ca77596fa229d88f96eca180d45dbe8efa11306f8b2b4f4ee301b3fe465f/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4db7f524eaa857fbe650cac33b9cedb5ccda14393d40f640b75dfb06aa13c98c", size = 1473618, upload-time = "2026-09-02T15:50:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/88/5c/6aebeb54dd226b480014ff4488e150aa23b1de3204e2bf3f87de27e6542a/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:d2a37795a90809da6094825e7063118b4cf723b8701b134973b4566ed8b9ea09", size = 1492025, upload-time = "2026-09-02T15:50:10.755Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e6/c355d470a230f778311c28b80f6d934a497d094139f07230433eea18651b/ast_serialize-0.9.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:4411d1cba9eeecb301365343a7e96813b4a44fcdb20181557867ff7e751804cf", size = 1113010, upload-time = "2026-09-02T15:50:12.337Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0d/66609ace58564727b68731293cc986c2ea1d5e6ef40e96571e7fb515f0af/ast_serialize-0.9.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:b5c3724faf780e25def89c369eb6340a15dff06ac348160c61781e2373a4cd10", size = 1146404, upload-time = "2026-09-02T15:50:13.761Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/fd/da28e1c85f05fb9976f247d2a3aefce68866cb2939abcbdbddd9a5e3b835/ast_serialize-0.9.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:1de0933a4c1d104d77d6e75f053f5e628b54cf8f9fea809b8250cb04cda07bd3", size = 1118328, upload-time = "2026-09-02T15:50:15.214Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/487a158a99e4564244e000ed18475255dfea53fd34a84d8ca73633710500/ast_serialize-0.9.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5fc57f17fb4ce49b4eeccfcd2670e4a55659bd740bb8e8aedbe511ccab8b5f03", size = 889484, upload-time = "2026-09-02T15:50:16.643Z" },
+ { url = "https://files.pythonhosted.org/packages/92/e4/175b0a64d6c96bc1b96598c6474ce8d1ef34e0b774bcf7183f4ce696fb10/ast_serialize-0.9.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dac690f99538d9df0d23ce0299e946add2744b007a36b480a292fe361c82553d", size = 1232635, upload-time = "2026-09-02T15:50:18.133Z" },
+ { url = "https://files.pythonhosted.org/packages/28/0c/d51d8463aca43aaa833fdf1f25134d6cc1b483764896decca61306ad1f6e/ast_serialize-0.9.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:2223ead73b5a5399d39610cf9c4164ad0b2bf2025226626b87ae15226d93d3f7", size = 1219313, upload-time = "2026-09-02T15:50:19.497Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/19/c88bdc64f86095a9d6ab325ae422b2a5e1395cd63cd8aa539003d4d4ae1d/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b7c5f5838408fb000d76abd14e886836412b7ec7eccd028dbb5ed5819780008", size = 1279981, upload-time = "2026-09-02T15:50:20.811Z" },
+ { url = "https://files.pythonhosted.org/packages/86/58/a492075826df1753896dc8e8f6ababae4016d8883b670ee3a1c34788b154/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e05701fde79affa1cc53e391867f9da3eb03fa8501f87354292796b0f8398fd", size = 1286319, upload-time = "2026-09-02T15:50:22.203Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/79/3f6754eaa42fd2a6c36aac066890870cd44cbe0e25f75a67b1b99a2f4d82/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d013c36eb2f2ac0cb7d4d0e79918a92ab00fbce8f1542fe47f34a46e06168f82", size = 1551547, upload-time = "2026-09-02T15:50:23.528Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/05/8cfb7caadfaf28febaa6b61d31d778262f87f9366eda4dd9bd07ac940b75/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19cde5c2110f7b90ab1210a599178524f6c9f34862b20ba2b9aa7832c67bb35d", size = 1302468, upload-time = "2026-09-02T15:50:24.99Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e2/750a0b136bb02ff8e4a17d65a3a78cd478ee50724704df8215797a226ba3/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1514f4a39704e2e815f9fc675fc13f19f694f212086b520110840782cf3c5295", size = 1300563, upload-time = "2026-09-02T15:50:26.354Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/17/4c0aa852ff1e4f2d6723e8ce827136c1e1febf2845d7941ccc45426778de/ast_serialize-0.9.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:30651ccdec6d23c49ee4711b1a1096d8dbd3be38eecf2f09fdd98a608ce7ac24", size = 1308999, upload-time = "2026-09-02T15:50:27.901Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/1b/6e73d0a29aedb0db30cc68f2557acaac06cd24c9783ccb90f84f89e4ce87/ast_serialize-0.9.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9a46caf5e3f2cd266e8638b2f4ea8bf54cf376f015f8418397b4633fdb38e9b", size = 1358191, upload-time = "2026-09-02T15:50:29.237Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/38/2cf5d552de99e0e9804a16fea73e54d0a7382498adddf57c0f6dc09cbc70/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aed6e413c6c22a23c33c47a01dd2adce01d7a7ed408748e896903f47d0a1aa47", size = 1458944, upload-time = "2026-09-02T15:50:30.77Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0b/5ef87adf955b6a027f616eb7b55f55a154c35ba600e9dd2d06ad2d30e5c2/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:871fb7c5b049897ee137b67efad7fe4545ad270f7eccd970da877833f8e63aa7", size = 1563421, upload-time = "2026-09-02T15:50:32.188Z" },
+ { url = "https://files.pythonhosted.org/packages/81/dd/9ced05a17feeb0f83e84010d80f5a1b7b7aa19e75f0376f4d3780803654c/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bb378efb5537b43f38660e2e6d6e138a40885cf191d43443bb3ff7ff47e9cd9b", size = 1558536, upload-time = "2026-09-02T15:50:33.861Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8b/8ad486e44fc7081a2471055befc433dddc2e51c3a88dff141b3026f64602/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:b373beff65b01fcffca5aaad3269ae629f3a998b09efdd3635e48039008a5dec", size = 1682749, upload-time = "2026-09-02T15:50:35.257Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/4c/7c282aba9cfb0b92d79fac45c04e4557d9a7f08d872e5a43577a50867e30/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:25c8d517c45cf2b1820fc2af6ac593783654818f79d05646d25d624360678a4e", size = 1482441, upload-time = "2026-09-02T15:50:37.319Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3a/e45914e8cad81b660915f3784d255460a6384183b76bfc2089fdd79ec7df/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1675dc46578298ae00936164a160997801a6ca2385913150d8d16df634296cf3", size = 1499042, upload-time = "2026-09-02T15:50:38.76Z" },
+ { url = "https://files.pythonhosted.org/packages/99/09/6988921dec19c810beef53539fec2e90ae551cd93853b3303a99fe45f772/ast_serialize-0.9.0-cp39-abi3-win32.whl", hash = "sha256:20fce3885eeff05a3d6afefa845c8168016e3ea1f6fc9cdc84c8db28b863a550", size = 1116391, upload-time = "2026-09-02T15:50:40.229Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/eb/839598a22a1f9af56d39e188451cad93dbcb0ce6539a45ac18fb8bf123fa/ast_serialize-0.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:161914666a21d48b681982146ac0fa4086ef099d91c637cf595387f5f06aa099", size = 1156055, upload-time = "2026-09-02T15:50:42.05Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "bandit"
+version = "1.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "pyyaml" },
+ { name = "rich" },
+ { name = "stevedore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" },
+]
+
+[[package]]
+name = "boolean-py"
+version = "5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
+]
+
+[[package]]
+name = "cachecontrol"
+version = "0.14.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
+]
+
+[package.optional-dependencies]
+filecache = [
+ { name = "filelock" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
+ { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
+ { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
+ { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
+ { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
+ { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
+ { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
+ { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
+ { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
+ { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
+ { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
+ { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
+ { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
+ { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
+ { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
+ { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
+ { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
+ { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
+ { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
+ { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
+ { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
+ { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
+ { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
+ { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
+ { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
+ { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
+ { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
+ { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" },
+ { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" },
+ { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" },
+ { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" },
+ { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" },
+ { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" },
+ { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" },
+ { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" },
+ { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" },
+ { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" },
+ { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" },
+ { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" },
+ { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" },
+ { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" },
+ { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" },
+ { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" },
+ { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" },
+ { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" },
+ { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" },
+]
+
+[[package]]
+name = "cyclonedx-python-lib"
+version = "11.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "license-expression" },
+ { name = "packageurl-python" },
+ { name = "py-serializable" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" },
+]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
+[[package]]
+name = "docker"
+version = "7.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.32.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
+ { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
+ { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
+ { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
+ { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
+ { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
+ { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
+ { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
+ { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
+ { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
+ { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
+ { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
+ { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
+ { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
+ { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
+ { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
+ { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
+ { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
+ { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
+ { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
+ { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
+ { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
+ { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
+ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
+]
+
+[[package]]
+name = "license-expression"
+version = "30.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boolean-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" },
+ { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" },
+ { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" },
+ { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" },
+ { url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" },
+ { url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" },
+ { url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" },
+ { url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" },
+ { url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" },
+ { url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" },
+ { url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" },
+ { url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" },
+ { url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" },
+ { url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" },
+ { url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" },
+ { url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" },
+ { url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" },
+ { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" },
+ { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" },
+ { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" },
+ { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" },
+ { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" },
+ { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" },
+ { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" },
+ { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" },
+ { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" },
+ { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" },
+ { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" },
+ { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" },
+ { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" },
+ { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" },
+ { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" },
+ { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" },
+ { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" },
+ { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" },
+ { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" },
+ { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" },
+ { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" },
+ { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" },
+ { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" },
+ { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" },
+ { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" },
+ { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" },
+ { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" },
+ { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" },
+ { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" },
+ { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" },
+ { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" },
+ { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" },
+ { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" },
+ { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
+ { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
+ { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
+ { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
+ { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
+ { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
+ { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
+ { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
+ { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
+ { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
+ { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
+ { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
+ { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
+ { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packageurl-python"
+version = "0.17.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "pip"
+version = "26.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
+]
+
+[[package]]
+name = "pip-api"
+version = "0.0.34"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pip" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
+]
+
+[[package]]
+name = "pip-audit"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cachecontrol", extra = ["filecache"] },
+ { name = "cyclonedx-python-lib" },
+ { name = "packaging" },
+ { name = "pip-api" },
+ { name = "pip-requirements-parser" },
+ { name = "platformdirs" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "tomli" },
+ { name = "tomli-w" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" },
+]
+
+[[package]]
+name = "pip-requirements-parser"
+version = "32.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.11.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
+ { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
+ { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
+ { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
+ { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
+ { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
+ { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
+ { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "py-serializable"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
+]
+
+[[package]]
+name = "pyaes"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pymediainfo-pyrofork"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/43/ebfd048e84bb264bb133d545312e35b49638bcd7d5ad973c023e0026a36b/pymediainfo_pyrofork-6.0.2.tar.gz", hash = "sha256:fce9402edfd1fa09aba7b3cac4c41ba7fcf6820e561b4db4f9c1a1a68c487c36", size = 446514, upload-time = "2024-10-08T14:31:39.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/16/7c2b2f969e84e5f196809c10da6c847c505fb722b9d636bf6f6bf8f2e919/pymediainfo_pyrofork-6.0.2-py2.py3-none-any.whl", hash = "sha256:674fa8e53de861635b9dc4f77c2ad712306a798bf28864952503bf328210c4c3", size = 9356, upload-time = "2024-10-08T14:31:37.054Z" },
+]
+
+[[package]]
+name = "pymongo"
+version = "4.19.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/8b/a9d214044153cb7d9141229d3e1b171cdf4f460fa07cade9354c4ce2f84d/pymongo-4.19.0.tar.gz", hash = "sha256:3c510dd3c5d9b392d3b33bb5d2a594758acfe8f026fca654253f947ce0af9d40", size = 2689381, upload-time = "2026-10-14T19:48:19.629Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/17/67576f517eeb18ce214e483164b0e8e124c3baee07aa114d3a5c5e72d2cb/pymongo-4.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:701c4a102c8794a1f656ff9c06ec9269276fb5f62c268359ee68d46163655b68", size = 826947, upload-time = "2026-10-14T19:46:38.094Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/5a/15074c71298adfe468f7aa02080b2bdfc17bf9752d4855893df96a2b6718/pymongo-4.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae2eb0a729de0b009de52b76003e4f1f19fd28cda88ec7a81c51faf90dd1587b", size = 827243, upload-time = "2026-10-14T19:46:39.827Z" },
+ { url = "https://files.pythonhosted.org/packages/50/45/bf0d840668f8932d6342c026a6ac9070d60c79a18453ab1fea5632688336/pymongo-4.19.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e8e44c4229cfe7e36fc5772b2c4c2d273b141bf9a212829ad5b0cc402efcd629", size = 1048053, upload-time = "2026-10-14T19:46:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/95/46/661e222349c1a9c64d83f859404076fc4e1063e395643f3526e013b5a74c/pymongo-4.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7204210e9a613aef743b9c7a2e1f07406c21090b61b9338e3d96bb8b2b14b36", size = 1058956, upload-time = "2026-10-14T19:46:43.505Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/11/d3e355464b01786a11700e70266d649c29ab281e98c7e32ca4b7ffb2d83c/pymongo-4.19.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab0167d3c99a33a119befa93f1771ef0436832275ed6fd95c68b2535dae3f2e7", size = 1082727, upload-time = "2026-10-14T19:46:45.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/eb/40f52875c43952533f0faa683a607600842df55e58a66d88dab22955f5f2/pymongo-4.19.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df57b703b0b07c35860da7b214735b7750b2f2a5288f296dc08eeaf10cf8c46a", size = 1076511, upload-time = "2026-10-14T19:46:47.067Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/98/ad65d39cab6cf071d09823aa525a0ff531cb9a4868130b9dfc44bb84828b/pymongo-4.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d199721ab77c83a7da83fcd219d3b819c559d8133e66c0d9bec9408001649f7", size = 1058253, upload-time = "2026-10-14T19:46:49.138Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/0b/9ea41c62a2ca75326424eda2e798aa4269d2cfe221c662df5181274728dc/pymongo-4.19.0-cp313-cp313-win32.whl", hash = "sha256:54877c8e89add9ed115316722ead430d422b95d475b4eb57663bc6e017587853", size = 822970, upload-time = "2026-10-14T19:46:50.861Z" },
+ { url = "https://files.pythonhosted.org/packages/73/04/4622fcc48338b1f59318e4488327248dc3e8eeb1c2886c477d319632d803/pymongo-4.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f5719dfbb5527a55dfaf6a68164df118efc13fffd00bc2ee9231488c1e8e03a", size = 829736, upload-time = "2026-10-14T19:46:52.927Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/77/3a15fda4d2bbc91bfb186d72e40528b8bb52ad6fcf336221dc41dbbeafc0/pymongo-4.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:9bf359a18df79981ea775b90c4c1fa044480b8896c0ff45932e568b0aed6a9eb", size = 825616, upload-time = "2026-10-14T19:46:55.076Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/e7/6e62d60303a1e5cc816cefaa4d57d74df8ee65753ee9fe154b5fad851de3/pymongo-4.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:08c354566ab8b5dce6d805f35d61b5575455d3ea1835d7b90151d53e8c32e669", size = 826849, upload-time = "2026-10-14T19:46:56.892Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/68/b2f67b99f22c5543a8be397c0ed8dee526c23717b4491405ae513138d88c/pymongo-4.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06b9ee12c4ceb7fb6ff8a7ab0465814c1cb5e5c6c2c452cb18eab7435b38a5b2", size = 827363, upload-time = "2026-10-14T19:46:58.842Z" },
+ { url = "https://files.pythonhosted.org/packages/02/bb/35e17473d000bc0517190aabe1429853aa142499370dbd6d7ae3743e8833/pymongo-4.19.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec25ab536e42e48fde356c6fc86e66f548e5af0cc584365e2ec34d3683be5a63", size = 1049786, upload-time = "2026-10-14T19:47:00.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2f/83cc2961d977c1ba36662f24ae55c9f5dbee2845ca615146fec0f4eda053/pymongo-4.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e65783e95b37c3387ed1105fe01e2be6b1b394c22331c5e8cc2fed2c3a30a06", size = 1059425, upload-time = "2026-10-14T19:47:02.511Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/94/baa32ef582f9edf3112b00f6e271cf5f83c481edcf999e2f462898990e87/pymongo-4.19.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3264b209b6319cae120306e266ed5fa9c7bc071b73ba5e13cbad23a6cbd73d2", size = 1082787, upload-time = "2026-10-14T19:47:04.38Z" },
+ { url = "https://files.pythonhosted.org/packages/37/eb/949a24776ceba31e9b731f7048dce4fbb913047afd16580a61723143afb9/pymongo-4.19.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:212dbc97f8e813a24639aaaef38503d84f7652d00b88b391f87762ba4c1f1709", size = 1073579, upload-time = "2026-10-14T19:47:06.247Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/b0/a577ab8eff3772cf7036118b4e407a8cbb53add7bbe322f011871eb6db44/pymongo-4.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2faa34469b052635c81dcec6b07fc5757d4aba0ec60f94c6658c7fa6f887bc46", size = 1057865, upload-time = "2026-10-14T19:47:08.076Z" },
+ { url = "https://files.pythonhosted.org/packages/95/cf/81b1d8a35ac3e5d5dcd8fc466f9acdd8f67a5035da130afb0d76e2efd6ac/pymongo-4.19.0-cp314-cp314-win32.whl", hash = "sha256:eee3fc70ea4253c8c7a6bd7917be468c5ef0a2860898766dd55497a563ddda94", size = 823938, upload-time = "2026-10-14T19:47:10.086Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c5/1aa13304c714ad81ab70feb6bd99f6514baafe8e6c84d243ffabae678379/pymongo-4.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac673404456b23c568cea326ab996a6b35a6009e41d42bcb774db025d0918b7d", size = 831074, upload-time = "2026-10-14T19:47:12.088Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/a8/5de505ba380af3d10737a2d0ddd2c6752ff6e9a0fe484c992483efe74889/pymongo-4.19.0-cp314-cp314-win_arm64.whl", hash = "sha256:2bb0e7c422c14ff2b31ec8be3e6ecaad326c17fca17071bcfcd13482584a8e0f", size = 826686, upload-time = "2026-10-14T19:47:13.959Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/fc/eddcc314b76ab9f3ab1417ecc088f88336cc2bca5be1356c8aa3d183dda8/pymongo-4.19.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b01cc054878931ea81fc0a57c4c10489db723b8d7275fb10070f7228149012f1", size = 829839, upload-time = "2026-10-14T19:47:15.761Z" },
+ { url = "https://files.pythonhosted.org/packages/87/51/caa4ac1f33d4b8a4de2469a0624ffc7f7fae7441f7d71d41c2be306734a4/pymongo-4.19.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:823f8b2fb59e4e635e296d5e92efa883e3d01a8faa477d515fc9dfe515368026", size = 830216, upload-time = "2026-10-14T19:47:17.789Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/e7/b3eb14aa900cfe7b6f7c0dd2349b5d0a488c17a9db76a8bfdf8bd30afd9d/pymongo-4.19.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1435721737b46be9bab5aa2374cfe57de934dc4ac421d5473308aa94c9fa39c3", size = 1113851, upload-time = "2026-10-14T19:47:19.743Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b7/ec2c2bdde80e23693703f01805a1e37509e088127177f2d5758ca05c9a79/pymongo-4.19.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dee18feff3203fa128798c6673c7795ef8a46d0b32c0e6b920c7b3f46129447", size = 1133130, upload-time = "2026-10-14T19:47:21.617Z" },
+ { url = "https://files.pythonhosted.org/packages/40/df/4f1bada8fa02babd094a5c4ed8f4ea1dc76cfc1366b26238a2ad1fc55b51/pymongo-4.19.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8d866560dfbe44bc5e1110e96af4b8d92ffe6368c345dac1c36c8060188ebba6", size = 1152659, upload-time = "2026-10-14T19:47:23.572Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/cb/a97d315c4c4e362d1f2e216d306122ae0f713ab457f73730684f3606a349/pymongo-4.19.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:47f04522f786dca82c776d5c3ed3ff9d08d6bf4cd0074c42296da5fac4d816ad", size = 1144177, upload-time = "2026-10-14T19:47:25.554Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/59/2a6c68bdee03f326194361149c68ec6720a22460d11a2a43a0742a7d7fce/pymongo-4.19.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac55cf643eaa6146822f5f05f07be4dedbed906f525bb2ee098a865c4892788a", size = 1125673, upload-time = "2026-10-14T19:47:27.582Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c1/b108dda370e09db7a4dccfb2bb003e769a8dd98513135e4429040cb88b83/pymongo-4.19.0-cp314-cp314t-win32.whl", hash = "sha256:3bcebec2536a9aec1d490ad6fa9fc7ffc3329059fb1f99154efa5d594abdc98c", size = 826636, upload-time = "2026-10-14T19:47:29.463Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/55/a0da8479007f149838c094f6f863fc05c973abf6802654881a4dfc68858e/pymongo-4.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:24668c6990bef96e1558328ba0802279cc1f752a3bcc7b283c2f39099a01e28c", size = 835337, upload-time = "2026-10-14T19:47:31.313Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d0/9837244d18d8280277e7b2e9366ee2b9d35338052362888a4704d77ad633/pymongo-4.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:542b0f4e47fe68e753c85503f8352d4baa81ac73593601c8ede0fa22ba5c0431", size = 827513, upload-time = "2026-10-14T19:47:33.367Z" },
+ { url = "https://files.pythonhosted.org/packages/97/6c/af80cf714a91b41441e9ad0aeac1af2000d902dfef7bac31388ba05bbfe7/pymongo-4.19.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cc81d7ceeb7766254bce7ad7644dddb44241fb57555cd7c71de305b6903493b8", size = 826913, upload-time = "2026-10-14T19:47:35.317Z" },
+ { url = "https://files.pythonhosted.org/packages/95/14/2ed9ee6c83fd05a36d310100562b599ea987d2339c57955b1afba80d07ec/pymongo-4.19.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b602baef46ec5cd876fdf45dfdf864a58f5a507129393b93b8248249008f9a70", size = 827513, upload-time = "2026-10-14T19:47:37.463Z" },
+ { url = "https://files.pythonhosted.org/packages/78/78/cd65885104e7b37f8cb7dd7e33d0b2c2415270afc2644ed643b52f526214/pymongo-4.19.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:179bc536b73fc76ae3d227114123ffc804f002fb45ddd996a81b233e806a0d2d", size = 1052003, upload-time = "2026-10-14T19:47:39.539Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/ed/99fc74ed08dded2351818bf374303ddc400bd2e8b5ab297dac352aa0df56/pymongo-4.19.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bd5e3ecd44d94b4eeef51f7e20a513206f2fceeab9534e9299c31133cc2e42", size = 1061762, upload-time = "2026-10-14T19:47:41.601Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8b/ded0ef32a2c4032cbec796f29b7b6067e76ac27714fbcfe06ce9a969b415/pymongo-4.19.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a38cfd2d81daef820a099c28065c6dc2ec9254ae80fefcf7981ea27e5381159", size = 1084314, upload-time = "2026-10-14T19:47:43.874Z" },
+ { url = "https://files.pythonhosted.org/packages/52/64/82099393a7178c80fe1b16cc5dca94f388dec3df7a3f059a7b831bbf10dd/pymongo-4.19.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:567e509e1e01c956bfd5e60805b7d582aae45eeba34e9690d0da6f09560afb4f", size = 1075560, upload-time = "2026-10-14T19:47:45.904Z" },
+ { url = "https://files.pythonhosted.org/packages/09/d2/1eab760f5dc3d09fbc8fec7ad2474def3c8d2efbeb8550bff12fed61f863/pymongo-4.19.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c3a47a6b325ac605352e9825ef658e6cca4f612e3a09838a564859f7d5435ea", size = 1060541, upload-time = "2026-10-14T19:47:47.885Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7d/426c1b661e8b4bd78671ea063ee66005faff0dfe6731ebdce0fb0000c339/pymongo-4.19.0-cp315-cp315-win32.whl", hash = "sha256:5d684e289cdb687f1508b15a44d3c0268f974c92ba129f658c1ef1fd196854e7", size = 823985, upload-time = "2026-10-14T19:47:50.253Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/e9/f2ece0253d82d34fad0a316ffec848ac4e85357cae849cd5ea29def72ae4/pymongo-4.19.0-cp315-cp315-win_amd64.whl", hash = "sha256:546350d196b01b7feff7f8e6d140b6d4ab47486d5ae70dab858605cdfc2ffe1d", size = 831173, upload-time = "2026-10-14T19:47:52.418Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/e0/be46ba1676cd04f831a9d4f6f8dbe0d3f816034788b8e3157762139f7aa8/pymongo-4.19.0-cp315-cp315-win_arm64.whl", hash = "sha256:d29ea47eebbeec81b67809fbb3440ffc53628d28f5b9f21624eed0038d9fddaa", size = 826603, upload-time = "2026-10-14T19:47:54.538Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/20/3e04d21eab4844372ef141d5cc4f5e03d4fb9ebda057dd5e5ef1db562433/pymongo-4.19.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b7e8b5b546e31ac63255650b0bf764383885a6c657b3269e83b9e1e5de3ed129", size = 829820, upload-time = "2026-10-14T19:47:56.428Z" },
+ { url = "https://files.pythonhosted.org/packages/44/9c/dbad3291c3614a884285d10e2cc123567386d682bf8a08caf5e0a630bf3e/pymongo-4.19.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f21109534f5555cf77689ad323a21fbc07e8a397b34f157938a347725d83b7b5", size = 830313, upload-time = "2026-10-14T19:47:58.457Z" },
+ { url = "https://files.pythonhosted.org/packages/68/2d/17e783859c89e749fe63803a08ab5e85ca0ee8416f0cbe84d5fe6efa2981/pymongo-4.19.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3af5ab5a9e490580d3f40660665f0f4d579a324e25acee6372e1508e4b7c7b7a", size = 1117096, upload-time = "2026-10-14T19:48:00.917Z" },
+ { url = "https://files.pythonhosted.org/packages/34/cf/0b23e363eb5856ecfdf3b7edbdfea7f964da664eb78e507bb8375820c7e5/pymongo-4.19.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb9d9bff4f666405cd9d7a17b6127294394847dce60ca38d8ba45f4879ada6c9", size = 1134854, upload-time = "2026-10-14T19:48:03.05Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b8/60758f35a90729d77fdfd36eeff5ddf191d9f198528074884d816865d942/pymongo-4.19.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be75840640e98ea4b5f150bceda8a55f1085e395732e21da028195da30ae79b5", size = 1154121, upload-time = "2026-10-14T19:48:05.638Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/b0/e2b56cf154bf1dff7deca641de160215f9163253609a8beb780dc35f007b/pymongo-4.19.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa39c6ddaf987a48ef073ff7fc225b84282079a46fbabaea9c5fcb6f89476e44", size = 1143786, upload-time = "2026-10-14T19:48:07.734Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/88/39b61ede07785568d47229a01e7e82fc3903f5cac55ad377e0e64a0d324a/pymongo-4.19.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b92aa4cc4b0bf67a18e3c73062ef70e00ca6921c742aa4d0f4770a493193c661", size = 1126770, upload-time = "2026-10-14T19:48:09.891Z" },
+ { url = "https://files.pythonhosted.org/packages/40/f2/391d41d24384545b2a6ed09694b2444f765a6e20932c75ed4eb507c9ef36/pymongo-4.19.0-cp315-cp315t-win32.whl", hash = "sha256:eececca812e8f5b3c12ad33dc90201ac20f5f193da446f7719f4321a0841387b", size = 826638, upload-time = "2026-10-14T19:48:11.962Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/48/96b923a2d29456896c7f11f8e6104339818112f5a8621f42ba51f131a510/pymongo-4.19.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f17b100fdc16b65c12997ec4fcc78eecc0a6395254c7ec92a4596e855ff1f33a", size = 835209, upload-time = "2026-10-14T19:48:14.063Z" },
+ { url = "https://files.pythonhosted.org/packages/46/6b/2ede9f64d96393e8111d250620f5340d64e62f4617322a43800516027ce9/pymongo-4.19.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bfcb5f8912edd9714a52564ad41c0dcd72e5408d1d3d67b41f6145df4a516318", size = 827452, upload-time = "2026-10-14T19:48:17.534Z" },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
+name = "pyrofork"
+version = "2.3.69"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyaes" },
+ { name = "pymediainfo-pyrofork" },
+ { name = "pysocks" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/86/da/c6e44522450483ca4a42130759a8dbc96e28c35e7ad041e16aca85c45756/pyrofork-2.3.69.tar.gz", hash = "sha256:945b30d50b31819a903749825e2748ac5a6af1e073bf97da8c53e510ff3ed58d", size = 506694, upload-time = "2025-12-10T18:35:57.316Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/ec/9395a0d3196a388a0ccd0994cf2113068b3740ab2615d8bbc1c12d762e42/pyrofork-2.3.69-py3-none-any.whl", hash = "sha256:13f7a7fbfa5ede230df6b6df10fcc2c6b33b4c3d75bf2088a7d32f41621df8e4", size = 5270720, upload-time = "2025-12-10T18:35:55.246Z" },
+]
+
+[package.optional-dependencies]
+speedup = [
+ { name = "tgcrypto-pyrofork" },
+ { name = "uvloop" },
+]
+
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
+ { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
+ { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.16.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
+ { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
+ { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
+ { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
+ { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
+ { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "stevedore"
+version = "5.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" },
+]
+
+[[package]]
+name = "testcontainers"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docker" },
+ { name = "python-dotenv" },
+ { name = "typing-extensions" },
+ { name = "urllib3" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" },
+]
+
+[package.optional-dependencies]
+mongodb = [
+ { name = "pymongo" },
+]
+
+[[package]]
+name = "tgcrypto-pyrofork"
+version = "1.2.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7c/cb/9b26818a3a815eb37a7839d0c8e160c3b7c4de770fdb5ee1ecce96048336/tgcrypto_pyrofork-1.2.8.tar.gz", hash = "sha256:106317b2c42cc5fcd7475a50647fee2da304076cdfcd2444f72d5254927b2afa", size = 37390, upload-time = "2025-10-25T04:05:38.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/59/f230684f3dca1da3a56c843de42fe64df709d41c55a33140ae9a1d3afcd4/tgcrypto_pyrofork-1.2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f28c07ee6c0ef423b3ff14562881f3bbcb6614d5394f378526f32a109650e24", size = 62197, upload-time = "2025-10-25T03:57:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/bb/0193ab5a6012172995fa96a009b79c2b786c0e63106f244ab4b7a9846bc6/tgcrypto_pyrofork-1.2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4886fa409c891e129c6ab439542e0b80b001b31bcefac63509340b0c691f73b", size = 60306, upload-time = "2025-10-24T09:25:31.058Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/3f/850ee3d6def1a07406efa5e951bf1e72a7dfd9ae3bc7bb8f1e3e44c57147/tgcrypto_pyrofork-1.2.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36a71e5cbd14f3226803a2d05c0d3d43e0781565a895d40681ef82410398d950", size = 61711, upload-time = "2025-10-25T03:57:50.985Z" },
+ { url = "https://files.pythonhosted.org/packages/53/55/100fde3d9a2215d25d469a36ae116d533a2455005d4fa83b9cb4caef49fc/tgcrypto_pyrofork-1.2.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ff578ac8b54607e6d536f9593f78d8bea9b99f2e607ea4bd71b1b2a3a5f949c", size = 60528, upload-time = "2025-10-24T09:25:32.644Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/1d764432770162206bbbe916232dc3a3adc0cf2a1a9045be233b3c965471/tgcrypto_pyrofork-1.2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12d237eb8de98fa759df97bdb988dd6f60702c3376dbf16d2babdd2196bfb58a", size = 62377, upload-time = "2025-10-25T03:57:51.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/78/68a5af0e776b65e598f8926c0814d8aa418b424f16e2a611efdcc3ba3695/tgcrypto_pyrofork-1.2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae1a23ed300786e28e8d9c2024effba7efc732d99fd8a2db314c02c35355f01f", size = 60477, upload-time = "2025-10-24T09:25:33.896Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/5c/8859b487bd68987d8d0b73a82d13796ef2213d428df2a2f0330605034c2c/tgcrypto_pyrofork-1.2.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b1538e0c14d2aee1b9dc72cddfd7706d2e4ea768addecc3f12ff8d44f38d3e1", size = 61863, upload-time = "2025-10-25T03:57:52.762Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/65/3f26e9680e312ee4cae8639e37035944037280c9b03bb17c2204020b024f/tgcrypto_pyrofork-1.2.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fe3d75abef53bfbfa6e80dc6d25075f603f3ebe1dafa9d5c09b2780e0ea3a382", size = 60651, upload-time = "2025-10-24T09:25:35.269Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/37/596a1b5d92bd6a7f657840c76a8f4955db614b4c7fe162c9cdcb82aa67e2/tgcrypto_pyrofork-1.2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82bd2e8f249eaef92132ce5a310c27844e9fbb43666e5bfbf6dd1872c1c2eda2", size = 62382, upload-time = "2025-10-25T03:57:53.863Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/44/5c3582787210840bd9e752a0e79d2c7a6b01339d4f11e243d2b79e003644/tgcrypto_pyrofork-1.2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3441ec567f9411ffbe5182fd4fb8c49fbd12faccd71c50fc469b9784e15b04d", size = 60493, upload-time = "2025-10-24T09:25:36.657Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/03/59880402d13eff32ab1649d795ef6296a839c98d37e9b4e5c14f02b7de66/tgcrypto_pyrofork-1.2.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c50a8ddd8e5256528f8318bcafbe1f59f1cc1c300db0ee16ec49955baead861c", size = 61897, upload-time = "2025-10-25T03:57:54.677Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/50/8e944e574f2dd3155db54a17c260cb550953cd7ecbd677c4e3d128e36de3/tgcrypto_pyrofork-1.2.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc888db2675247a1e3d9040577e025d64b66b72702223030b0e18ed10037b99e", size = 60691, upload-time = "2025-10-24T09:25:38.274Z" },
+]
+
+[[package]]
+name = "thunder-filetolink"
+version = "2.2.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "jinja2" },
+ { name = "psutil" },
+ { name = "pymongo" },
+ { name = "pyrofork" },
+ { name = "pyrofork", extra = ["speedup"], marker = "sys_platform != 'win32'" },
+ { name = "python-dotenv" },
+ { name = "uvloop", marker = "sys_platform != 'win32'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "bandit" },
+ { name = "mypy" },
+ { name = "pip-audit" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+ { name = "testcontainers", extra = ["mongodb"] },
+ { name = "vulture" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiohttp", specifier = "==3.14.3" },
+ { name = "jinja2", specifier = "==3.1.6" },
+ { name = "psutil", specifier = "==7.2.2" },
+ { name = "pymongo", specifier = "==4.19.0" },
+ { name = "pyrofork", marker = "sys_platform == 'win32'", specifier = "==2.3.69" },
+ { name = "pyrofork", extras = ["speedup"], marker = "sys_platform != 'win32'", specifier = "==2.3.69" },
+ { name = "python-dotenv", specifier = "==1.2.3" },
+ { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.23.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "bandit", specifier = ">=1.8" },
+ { name = "mypy", specifier = ">=1.13" },
+ { name = "pip-audit", specifier = ">=2.7" },
+ { name = "pytest", specifier = ">=8.4" },
+ { name = "pytest-asyncio", specifier = ">=0.24" },
+ { name = "pytest-cov", specifier = ">=5.0" },
+ { name = "ruff", specifier = ">=0.8" },
+ { name = "testcontainers", extras = ["mongodb"], specifier = ">=4.15" },
+ { name = "vulture", specifier = ">=2.14" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
+ { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
+ { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
+ { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
+ { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
+ { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
+ { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
+ { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
+ { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
+ { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
+]
+
+[[package]]
+name = "vulture"
+version = "2.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ba/8dc25478ed234dacc7d83c671634f347d0bdfb65bf0502f41879cf2f15a9/wrapt-2.4.0.tar.gz", hash = "sha256:7082fc1f94b020ac275870c4af71b09cff22876fe6e9c4c0ad01ea21d217b288", size = 161179, upload-time = "2026-08-30T04:41:51.424Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/86/f9de4e11582ff96ad2199eeeceaa17faa27bbdc599243f520070c4f3de07/wrapt-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5c5c4c728cd22a36e4b8bb5df4a7d3bccaa865d27725b36eeb3b6f18fb2e1bc2", size = 96041, upload-time = "2026-08-30T04:39:59.575Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/ab/1dbf50802bea3b46192fd0dc39bb0eb2e77a064c813b2bbd88d2888ad49f/wrapt-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7de5b8d94417e55c02be50cc226e0ae1209bbc73813bf691dff3979c94438115", size = 96269, upload-time = "2026-08-30T04:40:01.182Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/a3/a3b5cde1cd06e04b6e95134eb3187a0a7da607a530e7795b221d4e4fa819/wrapt-2.4.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6436e2bda993a3eb69a1b317fc831c8ebcafb5704c390859ebd49f81218c4bbb", size = 225787, upload-time = "2026-08-30T04:40:02.715Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f7/d100f6c348b7669f19119cf890dcd4764623e2233af065586d110e0cd99e/wrapt-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e084558fbd112d2e1e34b0f5c71e45a3405bdad51a17150368a959bcf6697964", size = 226649, upload-time = "2026-08-30T04:40:04.647Z" },
+ { url = "https://files.pythonhosted.org/packages/52/c6/3af8df515d5d7e92306957536f3468c6bdfecbe3659f99dbf09a468c2c4c/wrapt-2.4.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c947e18fadfd690c9420c30a96d221feeb93fc8f1cc00509b370ac16c3114", size = 206760, upload-time = "2026-08-30T04:40:06.332Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c5/40d355552bd3eb6c5186e26051c19b573d24d7896de42caa7937d6b5ca9f/wrapt-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8378c4514ac8dcc0ace76044cf87a873e6a52b5e6109834c8fb9037f4441b", size = 223467, upload-time = "2026-08-30T04:40:07.829Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ab/d198eebdb39f0d7e182e771e590a36673489cd58cebdad8aa273dcf28e04/wrapt-2.4.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:93180c2199784dd6a1075b33f9ed636bd0966821edbece6b3d5379b1c4f0bb7d", size = 205358, upload-time = "2026-08-30T04:40:09.344Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/0e/974a60672ad507d39a3d8a1c6351ef37fe65b07240d000ceba5d2b83e9e9/wrapt-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d5e5eb76fb87e62752af751d2dcd9d1cd986b12037d2e1363d109ba716029e8", size = 214654, upload-time = "2026-08-30T04:40:10.923Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/5a/8b2db70206db0a4246758e0472ce344cb9636217113ef70640fc8d2ce874/wrapt-2.4.0-cp313-cp313-win32.whl", hash = "sha256:49bb5a572469e0e18163a8ec2aa972135a0929899ecbe627665f274506e1b5b4", size = 91171, upload-time = "2026-08-30T04:40:12.895Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1e/e782b511c680dbe7369c92e7d981484aacca0cda584da1f28a84cd9a8e1a/wrapt-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1737f46b1e4a81eb93500a7f2854319e1c7a86e8863fb050b7b4daadd5a4178", size = 96178, upload-time = "2026-08-30T04:40:14.336Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/62/095ba31123fa5dd482d6183c05200b061314aabbd5442c010aba4b03ff1c/wrapt-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:f1e9e088094f4895f84ab043e7d59401df137d663efbf1e80c82144882960830", size = 92949, upload-time = "2026-08-30T04:40:15.935Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/dd/1f269e4daf0c992f675e1ca2de6b1683b761c6d0aeb6c7b4b412486823ea/wrapt-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:788e473d1a6786d29d577b1e2bd95e214c09cdafde84907c522c31069c9acfac", size = 96386, upload-time = "2026-08-30T04:40:17.584Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/7ecef06d33c0121c68d66a8a695efe67ebaa57218c1c61c585eca2a6117a/wrapt-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:947bd4b3438167b3638bf5477cb83a068a586ffb6d331ac427f39839c2b93b3c", size = 96532, upload-time = "2026-08-30T04:40:19.116Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e3/8fdc9eba0e6cbbfe8303e1e807d734691309a27970b2ea458d099f1a46b0/wrapt-2.4.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3a69161cae7f0dca44c89c1d14146b4a0508a0c3cad98b3f2db1f4e9016c94ba", size = 228775, upload-time = "2026-08-30T04:40:20.604Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/77/4ac5882abfb29bf9821c5fa5cf9f30241a194e0f47faa2682b9b29765278/wrapt-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0536f5d85ff6a157ebe7e0fe08c5479943742cf1ce59569075a66159efcbc495", size = 229029, upload-time = "2026-08-30T04:40:22.186Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/c5/8a3608311a02faf3e5c072da38d06a7c623150fc258e29f18fe377d91703/wrapt-2.4.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f041ed6a4d571010944bd6cfad9072db463e1851877b6d3227467a44af37456", size = 210436, upload-time = "2026-08-30T04:40:23.953Z" },
+ { url = "https://files.pythonhosted.org/packages/de/90/e0cbc43f435fd39df25460e9f173e7b96f3dac5c7f66be41c7227166f021/wrapt-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7fed45dbadf5d98a52bfff9624d3cca00affeb9543d493c9632b7a53cdd35c9", size = 226586, upload-time = "2026-08-30T04:40:25.507Z" },
+ { url = "https://files.pythonhosted.org/packages/81/6c/7e5f2143228635ec139ef6df733dc477049f7d96a0c49deb23944a73ed6a/wrapt-2.4.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cc2e7c7b6032e11a2b367a9baadaf0c5241feff2d8205260d87f1aa6dbdf84b", size = 208880, upload-time = "2026-08-30T04:40:27.128Z" },
+ { url = "https://files.pythonhosted.org/packages/10/16/1de84402bb7a0916e10739bf6586e031244172b299e87c8cff2a04baf9ff/wrapt-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:72826910a1cf5a081234720fd43011304b899acfee219af49148155b4d795533", size = 216689, upload-time = "2026-08-30T04:40:28.844Z" },
+ { url = "https://files.pythonhosted.org/packages/20/19/cd6bd5050381a541b44be97c4e0994eed60c5f439f4314f95eb5777d6c1a/wrapt-2.4.0-cp314-cp314-win32.whl", hash = "sha256:0eca69c9e93518240abe8801fb9b2726116a6e48172e4564c2651a2e14521747", size = 91581, upload-time = "2026-08-30T04:40:30.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f8/b642f3184619adde676ad449030bcbeae6cc78ea07a92f0b5fddeec4c4e6/wrapt-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:63b94f401d7ae3a9a3027472fd3a3ff38afd2ed293b2f0b3b84a6d133a9f99a3", size = 96510, upload-time = "2026-08-30T04:40:32.1Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/3b/3415a18b91221261eeac85bf8ee23dfb0e2a39d76b9703a797efca177439/wrapt-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:6b3e082d43f592fcd381aee46354a11ce887a813ce5bbcedd9766fd681723c09", size = 93648, upload-time = "2026-08-30T04:40:33.563Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/90/80cf6a09e9599a11249775928df9bb790b82471e4312b847a861ffb2c2ed/wrapt-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09064c7be688c38c3ff125ce86bc26b69b5d78dd56062c3ddd9c814b2a25f1e1", size = 99615, upload-time = "2026-08-30T04:40:35.134Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/da/c1d3245abb911a42584f8f7e9781995bdc41345c7affba75cf7e376c85ac/wrapt-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f8ddff4bbb75916be36da5169b8b9d475b59a1bd24acdb7551bb2c71be9aaac", size = 100031, upload-time = "2026-08-30T04:40:36.641Z" },
+ { url = "https://files.pythonhosted.org/packages/84/46/8ec4941d0abbb010df7caf0a34840ca0128177389843b0f5ef2f9ee48ac5/wrapt-2.4.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9f8017443595870aa31f46125553a5c55ce95a26a267b96261baee6ba566d83", size = 269389, upload-time = "2026-08-30T04:40:38.212Z" },
+ { url = "https://files.pythonhosted.org/packages/14/b5/a0ae1b431cc1f49a545d32b8b678a5788c50583ecf0ecb85dc0c7f95b4f6/wrapt-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:328eb2d978ca3a6ae25f8d8fe560bf8f4bc9778b5932e7b142664eef05b92e8f", size = 281081, upload-time = "2026-08-30T04:40:40.045Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/24/dfaf53dd3bdb0703524a9367b48e2a64ea86433fcc854b5f14be6a8e0e39/wrapt-2.4.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a057d376d994da6bd1bbf955ecfda699aa7353826f98847f5605e1801abdfd4", size = 249637, upload-time = "2026-08-30T04:40:41.657Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/27/bdd82044d7503c2bfa78afcc89881f82a1b82b5d2013aabab853d339ce2a/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3367a5212212c9393e0d3ca6ae029b3a8fa40c5896e4a985d43fe8a4b8322f0d", size = 275322, upload-time = "2026-08-30T04:40:43.408Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/82/04f4228eb3fb348d660dd1ea7225e53665b1809df2273ff4861d4d33b741/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c4fca1e63af6675af3df7cdfcd5a0c878b5e655c7e48611ced9dc8d62183a11d", size = 247292, upload-time = "2026-08-30T04:40:45.457Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/20/67b2968fa9200458446c51b36a435adb6906083428b70fafb4caf92d4dc2/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:694005fdc3002ade0f21641408c588028abde03c85961f3ba7727d8bead3ed6b", size = 264586, upload-time = "2026-08-30T04:40:47.079Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/fd/0db9ba03e08a7663f52455e95520c723f567bc037bffc6699950fcc456c4/wrapt-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:332d9bad7e9b718974bb2a576504c4956f45b4a0fcd7b3bb7827279167550464", size = 93752, upload-time = "2026-08-30T04:40:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/87/ced171220935c696b157207385fa6be5675558a74655479f071d95a00f1d/wrapt-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d57264c9dfcf37d2bf0b0fbec68d0f6184fc5617267619ada04d03e8b0231f3", size = 99890, upload-time = "2026-08-30T04:40:50.407Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/af/4a10c9a6d3b7ae41f830978c28d33a59ceb29537bd6875d2abfe78db4b41/wrapt-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f43af38a642c3d6062e9740d8f5cc0feb5dbe0da516702df892147393b8cb14d", size = 96033, upload-time = "2026-08-30T04:40:51.933Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/df/3a0b6225ab88bd47090df70391c059a3308057638f8fc0ae32e8ac9d1886/wrapt-2.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:430fde1a116df3ceb5c29035de1da6609b70e680d9b8ce3ee624422f3fe0978c", size = 96389, upload-time = "2026-08-30T04:40:53.555Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/6f/803b0d0e14de11781f0e938e6f7d6e29e79652139fe70d7513460357ac78/wrapt-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7d28f8f35a02d49f75f57fa4e755db4ba33f65841c0de64cd65b253916f5bf06", size = 96557, upload-time = "2026-08-30T04:40:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e8/46571e1218d0494604a7aadc4c898c738c4b179052327ee1e57e278cebd6/wrapt-2.4.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efd9a4be6785295e471f71efdf5682bd11d5b822b9665e6e1b4844917cf2f7ac", size = 229230, upload-time = "2026-08-30T04:40:56.703Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2e/0cab15fcaec56096a5734feace3620bc01edc885653be04bd756f84a6784/wrapt-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75529a2fb569a671cf162f762c1b576f569f571b55ec7f3481258ca842ba507f", size = 229444, upload-time = "2026-08-30T04:40:58.51Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/9e/a92c049371a2675f98a0381ab2951f984866d1ba4de0e0771d6a31fdaa2b/wrapt-2.4.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66e7512c0d324cc37bba1def2be1fc365cbb685d3aa393a8f6f4d2d00202881d", size = 212482, upload-time = "2026-08-30T04:41:00.224Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/3b/8b5b57d0ff24edcd3421dbaeb4e94c89be3616824e47708f4e13f25ae3d7/wrapt-2.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5f3bdfc35c83b562fcaebc0f24593045e5ed9f3b633adafd35222718a0ec38fa", size = 227017, upload-time = "2026-08-30T04:41:01.918Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/20/124b40bfd9585848db5a5aa6741d0c8dbf378dd995c6c2d95f090d9cf540/wrapt-2.4.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d5f45bead708e2c0014be5e98531ce7202916b098a208c7be83c6ceb0a2559fa", size = 210498, upload-time = "2026-08-30T04:41:03.617Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/bf/89db9d5a80a9f2af52b24bdfdb5392be80bc0f0fd39fc39d1aab72afd0bd/wrapt-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d294576fddac636589e4deccfe782e8f429da10f167c1985c4d51071de3672b7", size = 217046, upload-time = "2026-08-30T04:41:05.473Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/0b/021c9d6ce64c639894bffdaa7a895ddd4187abfefb2873ce55e536cd9d56/wrapt-2.4.0-cp315-cp315-win32.whl", hash = "sha256:0191d717dfbb8e519e7bfd4775e5b9bd57e359b3a09ab5db1ea47f6025b4d845", size = 91591, upload-time = "2026-08-30T04:41:07.086Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d3/6ebd944041cea0ac4a108a4739510ed2dc891a3f3216e4f7bf0650f5b5a6/wrapt-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:e8df31a126a0a247c1aa379e30873839de03912dea09ca360c680f3625d815df", size = 96517, upload-time = "2026-08-30T04:41:08.671Z" },
+ { url = "https://files.pythonhosted.org/packages/96/84/7c5e52e450f80ba76fd0282dccf7c79cd004ebd8ccabd0903064d3d2c56e/wrapt-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:e9e7e94472f0e3f1447caf27e1939eb384d0e87972a35a05f5c2e0968e9c01af", size = 93652, upload-time = "2026-08-30T04:41:10.258Z" },
+ { url = "https://files.pythonhosted.org/packages/35/89/f08ff45d7646de29750932805cc3b1e86b6ac3128015b293ed45fa8efe86/wrapt-2.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8828369b7d3e93c547cc8ad931b5a57b4e8d174035c82762fb1091e7d05ac9f5", size = 99610, upload-time = "2026-08-30T04:41:11.933Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/c2/f9a3c40901a36c6bb7ecaff8e1e54af78fa7fa0b95a0e54d13d3a24c8a0a/wrapt-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:413e757dce7a43fcda8bb8441994b1127492ffac6a5803af777d44516df8c6e2", size = 100064, upload-time = "2026-08-30T04:41:13.492Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e1/e2437f17f2a1ec292056e2fcafe1248269ebc39502f2ffe79424bf86f8a6/wrapt-2.4.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:75944792cf6b99262d649d55710bf5901f7013fbb212c7a1d736b97a20517607", size = 269421, upload-time = "2026-08-30T04:41:15.238Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d0/c98d6548dc4c7d12ab9baa192234ca1a57e141afd283252b448faddbd9ef/wrapt-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:648d1d4f94e8a0a1656675c755f40d2f0ee5fe92c449ab45326f4ecc2738cbe8", size = 281452, upload-time = "2026-08-30T04:41:16.939Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/57/673168e00aa03725148ce621ed201b75df4e787a57acd48fecefd2725600/wrapt-2.4.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a112a1bfdd2621e4344cb0a32dbaab80636b32dac1b055d03fbb2a67d806d1db", size = 250358, upload-time = "2026-08-30T04:41:18.716Z" },
+ { url = "https://files.pythonhosted.org/packages/78/0b/f2e576de5bf53ef5b578470104ea93f33e273a704c825131bc1719fffc42/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0972cd025f4c86fa2d8abd953d9f875779935343af58b4ce019ff89573fc65bd", size = 275654, upload-time = "2026-08-30T04:41:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/33/7f/9347b2e236346b1ba4cb28b82b205b8a377bb2da9417cb81bbe3d25816d7/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c246aaed719dcdb62eeb7b8d9306a6237777226ef3baad35919c4ae134c91ce7", size = 248662, upload-time = "2026-08-30T04:41:22.371Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/36/3b84d9e1ac8393bf2c94272760a2d361dc394ac30301e6d6dbd6583ade2d/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1656de3835f760781c9b974bce07d8c04edb9c9ad7ad67264aee69cd68a1db09", size = 264813, upload-time = "2026-08-30T04:41:24.116Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a2/de7b1de1702667b4a048318e301e26887268c17b07c8b9797cea06b10aee/wrapt-2.4.0-cp315-cp315t-win32.whl", hash = "sha256:d8e6e1e5dc684dfce7c33fc8b67a08ba2af94f3a45cfc70d5c1d6a839d2caf97", size = 93753, upload-time = "2026-08-30T04:41:25.793Z" },
+ { url = "https://files.pythonhosted.org/packages/09/50/4e7ef58c4eb058861ceddc0d1f94a6ed87f62e1cb27783c60b2897ef7e58/wrapt-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:85ed3c67fd39e8d9a36c224758cb6f2f4eb277d07ea677930caa0008c18ec002", size = 99888, upload-time = "2026-08-30T04:41:27.305Z" },
+ { url = "https://files.pythonhosted.org/packages/68/64/d15740c763dd0ddea2338ad42e3bd4a84f8702e16083e7ff61674c504a13/wrapt-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:36b56a4fba13b34ed8ff307557325fff215de0a58b5dbaef2c50e4d8aa39dbd1", size = 96039, upload-time = "2026-08-30T04:41:29.062Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c8/fafe0002f572ced999c792cfe8b05d39269c63d8193d15d25bd828bcad7a/wrapt-2.4.0-py3-none-any.whl", hash = "sha256:18aabd9301d06026f5900538051773d6f87f65ae02cdc60de482df978513dc0a", size = 73713, upload-time = "2026-08-30T04:41:49.805Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.24.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
+ { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
+ { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
+ { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
+ { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
+ { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
+ { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
+ { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
+ { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
+ { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
+ { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
+]