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 ` | 4.1 s | one test file, or one node with `::` | +| `scripts/dev.sh quick` | 10.2 s | the whole unit tier, no coverage | +| `scripts/dev.sh test` | 15.6 s | the whole tier + the coverage gate | +| `scripts/dev.sh all` | 17.4 s | gates + `test`, i.e. what CI runs | + +Best of three on a Windows host with the dev group synced, at 483 unit tests. +Re-measure before trusting the absolute numbers — they drift with the test +count, and a table nobody updates is a table nobody reads. This table has now +been measured at 414, 451, 479 and 483 tests; `quick` tracked 6.8 s → 9.0 s → +9.8 s → 10.2 s. + +One node costs the same as a whole file (4.1 s) because the floor is the +`pyrofork` import, not the tests: there is nothing to be gained by selecting +narrower. + +Iterate with `gates` and `file`. Run `quick` or `test` **once per commit**, not +after every edit — re-running the full tier per 3-line change is the single +biggest time sink in this repo. `make gates/quick/t FILE=...` are equivalent +for hosts that have `make`. + +Keep new tests fast. Two tests once accounted for 18 s of a 25 s run: one paid +a real `asyncio.sleep` per page it generated, the other waited out the real +production timeout to prove a mechanism. Stub the delay, or shrink the constant +locally and pin the production value in a separate test. + +## Test tiers + +- **Unit** (every PR): `uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35` + Only `Thunder/__main__.py` is omitted from the report (boot/teardown needs a + real connection). `bot/plugins/*` is measured: the directory carries an + `__init__.py` so coverage sees it, and the old omit entry was dead config + that hid ~900 statements of bot surface from the gate. +- **Integration** (opt-in, Docker): `TEST_INTEGRATION=1 uv run pytest -m integration` (testcontainers MongoDB, dev group) +- Characterization tests pin behavior; update deliberately in the behavior PR. + ## Project Structure ```text Thunder/ -├── __init__.py # __version__, StartTime -├── __main__.py # Entry: start_services() via asyncio -├── vars.py # Configuration from env vars +├── __init__.py # __version__, StartTime +├── __main__.py # start_services(), executor pool, sweepers, M13 shutdown +├── vars.py # .env layering, collects ALL validation errors ├── bot/ -│ ├── __init__.py # StreamBot client, multi_clients, work_loads -│ ├── clients.py # Multi-client management -│ └── plugins/ -│ ├── admin.py # Owner commands: /users /broadcast /status /stats /restart /log /authorize /deauthorize /ban /unban /shell /speedtest -│ ├── callbacks.py # Inline keyboard handlers -│ ├── common.py # User commands: /start /help /about /dc /ping -│ └── stream.py # /link (groups), private/channel media handlers -├── server/ -│ ├── __init__.py # web_server() — creates aiohttp app with routes -│ ├── stream_routes.py # HTTP streaming endpoints -│ └── exceptions.py # Custom HTTP exceptions -├── utils/ # 20 modules — see imports below -└── template/ # dl.html, req.html (Jinja2) +│ ├── __init__.py # StreamBot, multi_clients, work_loads +│ ├── registry.py # command registry → menu / help / AGENTS.md (M1) +│ ├── clients.py # multi-client + session chmod 0600 (L5) +│ └── plugins/ # admin (owner) / callbacks (M11) / common (user) / stream (/link, M4b) +├── server/ # __init__ (access log, hashed tokens H10) / stream_routes (/health /status /activate /f /watch) / exceptions +├── utils/ # safe_call, flag_cache, media_types + rate_limiter, decorators, tokens, ... +└── template/ # req.html (video/audio/image/other player) ``` +## Commands (from Thunder/bot/registry.py — keep in sync) + +| Command | Access | Description | +|---|---|---| +| `/start` | user | Start the bot and get a welcome message | +| `/help` | user | Show help and usage instructions | +| `/link` | group | (Group) Generate a direct link for a file or batch | +| `/dc` | user | Retrieve the data center (DC) information of a user or file | +| `/ping` | user | Check the bot's status and response time | +| `/about` | user | Get information about the bot | +| `/users` | owner | Show the total number of users | +| `/status` | owner | View bot details and current workload | +| `/stats` | owner | View usage statistics and resource consumption | +| `/broadcast` | owner | Send a message to all users | +| `/ban` | owner | Ban a user | +| `/unban` | owner | Unban a user | +| `/log` | owner | Send redacted bot logs | +| `/restart` | owner | Update and restart the bot | +| `/shell` | owner | Execute a shell command (requires `ENABLE_SHELL`) | +| `/authorize` | owner | Grant permanent access to a user | +| `/deauthorize` | owner | Remove permanent access from a user | +| `/listauth` | owner | List all authorized users | + +Owner-only commands are hidden from the Telegram command menu. + ## Key Imports ```python -from Thunder.utils.logger import logger # Async-safe QueueHandler logger, writes to Thunder/logs/bot.txt -from Thunder.utils.database import db # AsyncMongoClient singleton -from Thunder.utils.rate_limiter import rate_limiter, request_executor, handle_rate_limited_request -from Thunder.utils.bot_utils import is_admin # async def is_admin(cli, chat_id_val) -> bool — checks bot membership, NOT a decorator -from Thunder.utils.decorators import owner_only # async guard function, not a decorator -from Thunder.vars import Var # All env config +from Thunder.utils.logger import logger, redact_secrets # leveled log + token/Mongo redaction (H10) +from Thunder.utils.database import db # AsyncMongoClient singleton, timeoutMS=5000 +from Thunder.utils.safe_call import tg_call # FloodWait-safe RPC + wrappers (H4a) +from Thunder.utils.flag_cache import flags # TTL+LRU flag cache (H7) +from Thunder.utils.rate_limiter import rate_limiter, handle_rate_limited_request, start_executors +from Thunder.utils.decorators import preflight # gate chain (M12) +from Thunder.vars import Var # all env config ``` ## Code Conventions -- PEP 8, 4-space indent, 120-char lines -- Imports: stdlib → third-party → local -- All I/O is async; use `asyncio.sleep()` not `time.sleep()` -- Catch `FloodWait` from Telegram API with `await asyncio.sleep(e.value)` -- Log with `logger.error(..., exc_info=True)` for exceptions -- Admin access: `filters.user(Var.OWNER_ID)` on Pyrogram handlers (not `is_admin()`) -- Naming: PascalCase classes, snake_case functions/vars, UPPER_SNAKE_CASE constants +- PEP 8, 4-space indent; ruff (E,F,W,I,UP,B,SIM) in CI +- Import order: stdlib → third-party → local; all I/O async (`asyncio.to_thread` for blocking) +- **Never `try/except FloodWait`**: use `tg_call(...)` / `reply_safe` / `send_safe` / `edit_safe` / `delete_safe` / `answer_safe`. Allowed inline: `custom_dl.py` streaming/resume, `rate_limiter.py` worker-requeue, `broadcast.py` classify-only catch, `safe_call.py` itself, the server 503 ladder, `rate_limiter.py` notification catch-and-log. +- Budgets: Mongo `timeoutMS=5000`, TG RPC `TG_RPC_TIMEOUT_SECONDS` (transfers unbounded), shortener/keepalive 10 s +- A new media call site through `tg_call` must be added to `UNBOUNDED_SHAPES` (today `Message.copy` and `Message.reply_document`, used by the ingest forward, `/log` and `/shell`) or pass `timeout=0`; the shape set is pinned by `test_safe_call_budgets.py` +- `html.escape()` all user strings in HTML (M7); fail-closed deny with `MSG_ERROR_TEMP` (H7) +- Owner handlers: `filters.user(Var.OWNER_ID)`; PascalCase / snake_case / UPPER_SNAKE_CASE + +## Access gates (M12 order: banned → private-mode → token; force-sub where applicable; shortener-status is routing, not a gate) + +- Owner bypasses everything, including force-sub; authorized users bypass private-mode + token, but not ban or force-sub. +- `/start` runs only `banned + private-mode` (activation stays reachable). +- `PRIVATE_MODE=True` restricts the whole bot to owner + authorized users. +- No-sender messages (channel posts, anonymous admins) are DENIED wherever a gate is active — fail-closed, never bypass. +- Force-sub admits `OWNER`/`ADMINISTRATOR`/`MEMBER`, or `RESTRICTED` with `is_member`: `get_chat_member` only raises for a user absent from the channel, so banned and left members come back as a `ChatMember` and must be rejected by status. +- New gate = one `PREFLIGHT_GATES` entry + a row above. ## Rate Limiting -Two-tier deque system in `rate_limiter.py`: -- Owners bypass queue entirely -- Authorized users → `priority_queue` (drained first) -- Regular users → `request_queue` -- `QueueFullError` raised on overflow +- Two-tier deque; **strict alternation** while both tiers are populated, so a + continuous owner/authorized stream cannot starve regular users (and vice + versa). A deferred hand-over costs one rotation, never a lost item. +- Queue/wait-estimate UX is protected. +- Charge-at-exec (H6b); FloodWait requeues (max 5); ingest contention requeues + (max 20, counted separately so one cannot mask the other); RPS breaker burst + 2×, dry requeues (H6c); hard-bounded (`MAX_TRACKED_USERS`) + swept 5 min (H6a). +- The per-user table is bounded at **admission**, not only by the sweep: a timer + bounds the steady state, not what can be created between ticks. Eviction is by + **least-recently-active**, never dict order — keys are only inserted when + absent, so dict order is first-ever-seen, and evicting by it hands a currently + active user a fresh rate-limit window. Admission and sweep share one + `_evict_inactive_users` so they cannot disagree. +- That helper **sorts once and drops the prefix**. A `min()`-per-victim loop is + O(n·k) and measured at 66 s of blocking CPU for a table at 8× the cap — inside + the async `sweep()` the sweeper calls directly, so the whole bot freezes under + exactly the id-flood the bound exists to survive. `test_rate_limiter_bounds.py` + pins this with a clock budget, since no functional assertion can see it. +- The RPS breaker reports **nothing** unless `GLOBAL_RPS_LIMIT > 0` or + `GLOBAL_RATE_LIMIT` is on, and both ship disabled. Any code that consults it + as its only pressure signal is dead on a default deployment; `stream.py`'s + batch pacing reads queue depth as well for exactly that reason. + +## Stream slots and the transfer deadline + +`work_loads[client_id]` is the admission counter for `/f/` and `/watch/`. One +`_slot_release` closure per request is shared by the admission ladder, the body +generator's `finally`, and the stall watchdog, so whichever gets there first +releases exactly once and the counter cannot go negative (negative means +`select_optimal_client` believes there is spare capacity, which is worse than +the leak). + +A stalled reader leaves the body generator suspended at a `yield`, so its +`finally` never runs and the slot leaks permanently. A watchdog task reclaims it +at a **wall-clock** deadline — `size / 64 KiB/s`, floored at 120 s and capped at +1800 s. It is a bound on the leak, not a stall detector: there is no idle check, +so a client sustaining less than `size / 1800 s` is cut mid-transfer with +`Content-Length` already sent and must resume with a `Range`. Prompt stall +detection needs progress-based reaping, which is not implemented. + +## Shutdown budget + +The teardown steps share one `_SHUTDOWN_BUDGET_SECONDS` budget (8 s), with a +per-step floor so a spent budget cannot turn `db.close()` into a 0 s timeout and +record a failure that never happened. `cleanup_clients` is the exception: it gets +**its own slice** (`shared=False` in `_teardown_plan`) rather than drawing on the +shared pool — with a shared budget the grant is `min(cap, budget_left)`, so a +scaled cap achieves nothing. That slice is **flat**, not per-client: two hung +clients would need 20 s and the rest of the teardown needs the remainder of 30 s. +A healthy stop spends ~0.1 s per client, so 12 s is only ever approached when +clients are genuinely stuck, and being cut there is a real failure worth +reporting. + +Worst case is `budget + (shared_steps + app_runner) × floor + slice` = 27.5 s, +inside the 30 s Heroku, Render and Kubernetes allow. Note the `_PRE_PLAN_FLOOR_STEPS` +term: `app_runner.cleanup()` is budgeted through the same floor rule but runs +outside `_teardown_plan`, so counting only the plan under-counts by a full floor. + +The four **boot-failure** handlers are bounded separately +(`_BOOT_FAILURE_CANCEL_SECONDS + budget + steps × floor` = 22 s). They used to +pass no deadline at all, which grants every step its full ceiling behind a +30 s cancellation — an 82 s unwind. `test_shutdown_budget.py` holds both bounds. + + +## URL families + +- Canonical `/f/<32-hex>/`, `/watch/f/<32-hex>/` (L4); legacy `/watch/<6-char-hash>/` valid, `ENABLE_LEGACY_LINKS` off → 410 (default on). 20- and 32-hex validate side-by-side forever; legacy pages cached. ## Configuration -Copy `config_sample.env` → `config.env`. Required vars: `API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL`. +Copy `config_sample.env` → `config.env` (+ optional `config.env.local` overrides). +Required: `API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `OWNER_ID` (boot refuses), `DATABASE_URL`. +New env vars ship a safe default + annotated sample entry in the same PR. ## Debugging -- Logs: `Thunder/logs/bot.txt` -- Health check: admin `/status` command -- No linting/formatting tools configured — follow conventions manually -- No formal test suite — verify via bot interaction and link streaming \ No newline at end of file +- Logs `Thunder/logs/bot.txt` (10 MiB × 5; `/log` redacted); `GET /health` (dep-free); `GET /status` (no-store: DC, inflight, touch stats); admin `/stats` (limiter). +- CI bar: ruff, mypy, pytest, pip-audit, bandit, vulture, dependency-count — all green. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..748f3acb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +## Setup + +```bash +# install uv (https://docs.astral.sh/uv/getting-started/installation/), then: +uv sync --frozen --group dev # exact locked env: runtime + dev tools +cp config_sample.env config.env # fill in your values +pre-commit install # optional: same ruff hooks CI runs, before each commit +``` + +Works on Windows too: `uvloop`/`tgcrypto` are marked POSIX-only, so a Windows +dev host gets the full toolchain without the MTProto speedups. `make` recipes +themselves are POSIX shell (use WSL/Git Bash for those). + +## Workflow + +1. Branch from `main` (`feat/...`, `fix/...`, `refactor/...`). +2. `make format lint test` must pass locally; `quality.yml` enforces the same gates. +3. One concern per PR; behavior-preserving refactors carry characterization + tests committed beforehand. +4. New env vars: safe default + annotated entry in `config_sample.env` in the + same PR. +5. No new runtime dependency without a one-line justification; the + dependency-count CI gate fails beyond 7 direct deps. +6. Touching `pyproject.toml` or `uv.lock`? Run `make lock` -- the same drift + gates CI enforces (uv.lock, requirements.txt, requirements.lock). + +## Commands + +- `make format` — ruff autofix + format +- `make lint` — ruff + mypy (blocking: 0 errors expected) +- `make test` — unit tier +- `make integration` — real-Mongo tier (needs Docker) +- `make lock` — dependency drift gates +- `make audit` — pip-audit + bandit + vulture + dependency-count diff --git a/Dockerfile b/Dockerfile index 5e7ede22..83fdd90b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,41 @@ -FROM python:3.13-slim - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /app - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git \ - build-essential \ - libssl-dev \ - && apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . - -RUN pip install --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt - -COPY . . - -CMD ["bash", "thunder.sh"] +FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 +# digest-pinned (multi-arch index); dependabot bumps the tag+digest pair + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# no git in the image: self-update no-ops cleanly, and a container should be +# replaced by pulling a new image, not mutated. +RUN useradd --create-home --shell /bin/bash thunder + +# requirements.lock = hash-pinned FULL graph (uv export of uv.lock); plain +# direct pins would resolve fresh, unpinned transitives at every image build. +COPY requirements.lock . + +# no `pip install --upgrade pip`: it would be the sole unhashed install; +# 3.13-slim ships a pip that fully supports --require-hashes +# +# pyaes (transitive, via pymediainfo-pyrofork) ships an sdist and NO wheel. That +# is fine here only because it is pure Python -- no C extension, no ctypes, no +# cffi -- so pip builds a wheel from the sdist with no compiler. This image is +# slim on purpose and installs no build tooling, so if pyaes ever gains a native +# extension, or if anyone adds `--only-binary=:all:` or `--no-build-isolation`, +# this line fails with a confusing error rather than an obvious one. +RUN pip install --no-cache-dir --require-hashes -r requirements.lock + +COPY --chown=thunder:thunder . . + +RUN mkdir -p /app Thunder/logs && chown -R thunder:thunder /app + +# run as non-root +USER thunder + +# container health follows /health. PORT is read the same way vars.py reads it: +# a malformed value must fail the probe AND the boot, not report healthy while +# the app refuses to start. The split('#') that used to be here made the probe +# accept something the app rejects. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD python3 -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','8080').strip()+'/health',timeout=5)" + +CMD ["bash", "thunder.sh"] diff --git a/LICENSE b/LICENSE index 29f81d81..261eeb9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,201 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..c1ef1e94 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.PHONY: format lint test integration coverage lock audit run clean gates quick t + +# Developer entry points (see CONTRIBUTING.md) +# Recipes need hard TABs; tools run via `uv run` (project env, not the ambient venv). + +format: + uv run ruff check Thunder/ update.py tests/ --fix + uv run ruff format Thunder/ update.py tests/ + +lint: + 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 + +test: + uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + +# --- the fast loop ------------------------------------------------------- +# +# Measured with the dev group synced. Importing pyrogram costs ~2.8s and +# happens in every pytest run, so there is a hard floor of ~3.8s per +# invocation no matter how few tests you select. The expensive part is +# therefore running the WRONG command, not the tool being slow: +# +# make gates 1.5s ruff + ruff-format + mypy, no pytest +# make t 4.0s one test file (or one node with ::) +# make quick 6.8s the whole unit tier, no coverage +# make test 10.7s the whole tier plus the coverage gate +# +# Iterate with `make gates` and `make t `. Run `make quick` or +# `make test` ONCE per commit, not after every edit. + +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 + +# make t FILE=tests/test_unit/test_batch_chunking.py +# make t FILE=tests/test_unit/test_batch_chunking.py::test_chunking_never_drops +t: + uv run pytest -m unit -q --no-cov $(FILE) + +# the whole tier without the coverage gate -- the pre-commit check +quick: + uv run pytest -m unit -q --no-cov + +# CI parity for the opt-in tier (needs Docker): real MongoDB via testcontainers +integration: + TEST_INTEGRATION=1 uv run pytest -m integration + +coverage: + uv run pytest -m unit --cov=Thunder --cov-report=html + +audit: + uv run pip-audit + # -ll = medium severity and above (bandit counts: -l low, -ll medium, -lll high) + uv run bandit -c pyproject.toml -r Thunder update.py -ll --skip B101 + uv run vulture Thunder update.py --min-confidence 80 + @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: dependency count increased beyond 7; justify or remove."; \ + exit 1; \ + fi + +# same drift gates CI enforces; run after touching pyproject/uv.lock +lock: + uv lock --check + uv run --locked python -c "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('#')}; sys.exit('requirements.txt drifted from pyproject [project.dependencies]') if deps != listed else None" + uv export --frozen --no-dev --hashes -o /tmp/requirements.lock.check + grep -v '^#' requirements.lock > /tmp/requirements.lock.committed + grep -v '^#' /tmp/requirements.lock.check > /tmp/requirements.lock.exported + diff -u /tmp/requirements.lock.committed /tmp/requirements.lock.exported + +run: + uv run python -m Thunder + +clean: + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov build dist + find . -type d -name "__pycache__" -not -path "./.venv/*" -exec rm -rf {} + diff --git a/Procfile b/Procfile deleted file mode 100644 index a74dc6b9..00000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: python -m Thunder \ No newline at end of file diff --git a/README.md b/README.md index 4c2549e6..1b1241b2 100644 --- a/README.md +++ b/README.md @@ -1,584 +1,562 @@ -

+

[![Deploy to Railyard](https://app.railyard.run/deploy/badge)](https://app.railyard.run/deploy?repo=https://github.com/fyaz05/FileToLink) - Thunder Logo -

⚡ Thunder

-

- -

- High-Performance Telegram File-to-Link Bot for Direct Links & Streaming -

- -

- Python Version - Pyrofork - License - Telegram Channel -

- -
- -## 📑 Table of Contents - -- [About The Project](#about-the-project) -- [How It Works](#how-it-works) -- [Features](#features) -- [Configuration](#configuration) - - [Essential Configuration](#essential-configuration) - - [Optional Configuration](#optional-configuration) -- [Usage and Commands](#usage-and-commands) - - [Basic Usage](#basic-usage) - - [Commands Reference](#commands-reference) -- [Advanced Feature Setup](#advanced-feature-setup) - - [Token System](#token-system) - - [URL Shortening](#url-shortening) - - [Rate Limiting System](#rate-limiting-system) - - [Network Speed Testing](#network-speed-testing) -- [Deployment Guide](#deployment-guide) - - [Prerequisites](#prerequisites) - - [Installation](#installation) - - [Quick Deploy](#quick-deploy) - - [Deploy to Koyeb](#deploy-to-koyeb) - - [Deploy to Render](#deploy-to-render) - - [Deploy to Railway](#deploy-to-railway) - - [Deploy to Heroku](#deploy-to-heroku) - - [Reverse Proxy Setup](#reverse-proxy-setup) -- [Support & Community](#support--community) - - [Troubleshooting & FAQ](#troubleshooting--faq) - - [Contributing](#contributing) -- [License](#license) -- [Acknowledgments](#acknowledgments) - -
- -## About The Project - -**Thunder** is a powerful Telegram bot that transforms Telegram files into high-speed direct links, perfect for both streaming and rapid downloading. Share files via HTTP(S) links without needing to download them from the Telegram client first. - -### 💡 Perfect For - -- 🚀 Bypassing Telegram's built-in download speed limits -- ☁️ Unlimited cloud storage with fast streaming and download links -- 🎬 Content creators sharing media files -- 👥 Communities distributing resources -- 🎓 Educational platforms sharing materials - -## How It Works - -``` -User Uploads File → Telegram Bot → Forwards to Channel → Generates Direct Link → Direct Download / Streaming -``` - -1. **Upload** → User sends any file to the bot. -2. **Store** → The bot forwards the file to your private storage channel (`BIN_CHANNEL`), where it is permanently saved to generate the link. -3. **Generate** → A unique, permanent link is created. -4. **Stream/Download** → Anyone with the link can stream or download the file directly in their browser. -5. **Balance** → Multi-client support distributes the load for high availability. - -## Features - -#### Core Functionality - -- ✅ **Direct Link Generation** - Convert any Telegram file into a direct HTTP(S) link. -- ✅ **Permanent Links** - Links remain active as long as the file exists in the storage channel. -- ✅ **Browser Streaming & Downloading** - Stream media directly or download files at high speed without a Telegram client. -- ✅ **All File Types** - Supports video, audio, documents, images, and any other file format. -- ✅ **Batch Processing** - Generate links for multiple files at once with a single command. - -#### Performance & Scalability - -- ✅ **Multi-Client Support** - Distributes traffic across multiple Telegram bots to avoid limits and increase throughput. -- ✅ **Async Architecture** - Built with `aiohttp` and `asyncio` for non-blocking, high-performance operations. -- ✅ **MongoDB Integration** - Ensures persistent and reliable data storage. - -#### Security & Control - -- 🔐 **Token Authentication** - Secure user access with a time-limited token system. -- 🛡️ **Admin Controls** - Full suite of commands for user and bot management. -- 👤 **User Authentication** - Require users to join a specific channel before they can use the bot. -- ✅ **Channel/Group Support** - Fully functional in private chats, groups, and channels. - -#### Customization - -- 🌍 **Custom Domain** - Serve files from your own domain for a professional look. -- 🔗 **URL Shortening** - Integrate with URL shortener services for clean, shareable links. -- 🎨 **Custom Templates** - Personalize messages sent by the bot to match your brand. -- 📈 **Media Info Display** - Shows file size, duration, and format details in the response message. - -## Configuration - -Copy `config_sample.env` to `config.env` and fill in your values. - -### Essential Configuration - -| Variable | Description | Example | -| :--- | :--- | :--- | -| `API_ID` | Telegram API ID | `12345678` | -| `API_HASH` | Telegram API Hash | `abc123def456` | -| `BOT_TOKEN` | Bot token from @BotFather | `123456:ABCdefGHI` | -| `BIN_CHANNEL` | Storage channel ID | `-1001234567890` | -| `OWNER_ID` | Owner user ID | `12345678` | -| `DATABASE_URL` | MongoDB connection | `mongodb+srv://...` | -| `FQDN` | Domain/IP address | `f2l.thunder.com` | -| `HAS_SSL` | HTTPS enabled | `True` or `False` | -| `PORT` | Server port | `8080` | -| `NO_PORT` | Hide port in URLs | `True` or `False` | - -### Optional Configuration - -
-Optional Configuration Details - -| Variable | Description | Default | -| :--- | :--- | :--- | -| `MULTI_TOKEN1` | Additional bot token 1 (use MULTI_TOKEN1, MULTI_TOKEN2, etc.) | *(empty)* | -| `FORCE_CHANNEL_ID` | Required channel join | *(empty)* | -| `MAX_BATCH_FILES` | Maximum files in batch processing | `50` | -| `CHANNEL` | Allow processing messages from channels | `False` | -| `BANNED_CHANNELS` | Blocked channel IDs | *(empty)* | -| `SLEEP_THRESHOLD` | Client switch threshold | `300` | -| `WORKERS` | Async workers | `8` | -| `NAME` | Bot name | `ThunderF2L` | -| `BIND_ADDRESS` | Bind address | `0.0.0.0` | -| `PING_INTERVAL` | Ping interval (seconds) | `840` | -| `TOKEN_ENABLED` | Enable tokens | `False` | -| `SHORTEN_ENABLED` | URL shortening for tokens | `False` | -| `SHORTEN_MEDIA_LINKS` | URL shortening for media | `False` | -| `TOKEN_TTL_HOURS` | Token validity duration in hours | `24` | -| `URL_SHORTENER_API_KEY` | Shortener API key | *(empty)* | -| `URL_SHORTENER_SITE` | Shortener service | *(empty)* | -| `SET_COMMANDS` | Auto-set bot commands | `True` | -| `RATE_LIMIT_ENABLED` | Enable rate limiting | `False` | -| `MAX_FILES_PER_PERIOD` | Files per window | `2` | -| `RATE_LIMIT_PERIOD_MINUTES` | Time window | `1` | -| `MAX_QUEUE_SIZE` | Queue size | `100` | -| `GLOBAL_RATE_LIMIT` | Global limiting | `True` | -| `MAX_GLOBAL_REQUESTS_PER_MINUTE` | Global limit | `4` | - -
- -## Usage and Commands - -### Basic Usage - -1. **Start** → Send `/start` to the bot. -2. **Authenticate** → Join required channels (if configured). -3. **Upload** → Send any media file. -4. **Receive** → Get a direct streaming and download link. -5. **Share** → Anyone can access the file via the link. - -### Commands Reference - -#### User Commands - -| Command | Description | -| :--- | :--- | -| `/start` | Start the bot and get a welcome message. Also used for token activation. | -| `/link` | Generates a link. For batches, **reply to the first file** of a group and specify the count. **Example:** `/link 5` will process that file and the next four. | -| `/dc` | Get the data center (DC) of a user or file. Use `/dc id`, or reply to a file or user. | -| `/ping` | Check if the bot is online and measure response time. | -| `/about` | Get information about the bot. | -| `/help` | Show help and usage instructions. | - -#### Admin Commands - -| Command | Description | -| :--- | :--- | -| `/status` | Check bot status, uptime, and resource usage. | -| `/broadcast` | Send a message to all users (supports text, media, buttons). | -| `/stats` | View usage statistics and analytics. | -| `/ban` | Ban a user or channel (reply to message or use user/channel ID). | -| `/unban` | Unban a user or channel. | -| `/log` | Send bot logs. | -| `/restart` | Restart the bot. | -| `/shell` | Execute a shell command. | -| `/speedtest` | Run network speed test and display comprehensive results. | -| `/users` | Show total number of users. | -| `/authorize` | Permanently authorize a user to use the bot (bypasses token system). | -| `/deauthorize` | Remove permanent authorization from a user. | -| `/listauth` | List all permanently authorized users. | - -
-

BotFather Commands Setup

- -```text -start - Initialize bot -link - Generate direct link -dc - Get data center info -ping - Check bot status -about - Bot information -help - Show help guide -status - [Admin] System status -stats - [Admin] Usage statistics -broadcast - [Admin] Message all users -ban - [Admin] Ban user -unban - [Admin] Unban user -users - [Admin] User count -authorize - [Admin] Grant access -deauthorize - [Admin] Revoke access -listauth - [Admin] List authorized -log - [Admin] Send bot logs -restart - [Admin] Restart the bot -shell - [Admin] Execute shell command -speedtest - [Admin] Run network speed test -``` - -
- -## Advanced Feature Setup - -### Token System - -Enable controlled access with tokens: - -1. Set `TOKEN_ENABLED=True` in your `config.env`. -2. Users receive automatic tokens on first use. -3. Admins can grant permanent authorization with `/authorize` to bypass tokens. -4. Tokens include activation links for secure access. - -### URL Shortening - -Configure URL shortening for cleaner links: - -```env -SHORTEN_ENABLED=True -SHORTEN_MEDIA_LINKS=True -URL_SHORTENER_API_KEY=your_api_key -URL_SHORTENER_SITE=shortener.example.com -``` - -### Rate Limiting System - -Thunder implements a sophisticated multi-tier rate limiting system designed for high-performance file sharing: - -#### **Priority Queue Architecture** - -- **Owner Priority**: Complete bypass of all rate limits. -- **Authorized Users**: Dedicated priority queue with faster processing. -- **Regular Users**: Standard queue with fair scheduling. - -#### **Multi-Level Rate Limiting** - -- **Per-User Limits**: Configurable files per time window. -- **Global Limits**: System-wide request throttling. -- **Sliding Window**: Time-based rate limiting with automatic cleanup. - -#### **Smart Queue Management** - -- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued. -- **Queue Size Limits**: Configurable maximum queue size. -- **Flood Protection**: Built-in protection against Telegram flood waits. - -### Network Speed Testing - -Monitor server performance with built-in speed testing: - -```bash -/speedtest -``` - -Features include download/upload speeds, latency measurements, and shareable result images for performance monitoring. - -## Deployment Guide - -This section covers the complete setup process for deploying Thunder, from prerequisites to production deployment. - -### Prerequisites - -| Requirement | Description | Source | -| :--- | :--- | :--- | -| Python 3.13 | Programming language | [python.org](https://python.org) | -| MongoDB | Database | [mongodb.com](https://mongodb.com) | -| Telegram API | API credentials | [my.telegram.org](https://my.telegram.org/apps) | -| Bot Token | From @BotFather | [@BotFather](https://t.me/BotFather) | -| Public Server | VPS/Dedicated server | Any provider | -| Storage Channel | For file storage | Create in Telegram | - -### Installation - -#### Docker Installation (Recommended) - -```bash -# 1. Clone repository -git clone https://github.com/fyaz05/FileToLink.git -cd FileToLink - -# 2. Configure -cp config_sample.env config.env -nano config.env # Edit your settings - -# 3. Build and run -docker build -t thunder . -docker run -d --name thunder -p 8080:8080 thunder -``` - -
-Manual Installation - -```bash -# 1. Clone repository -git clone https://github.com/fyaz05/FileToLink.git -cd FileToLink - -# 2. Setup virtual environment -python3 -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate - -# 3. Install dependencies -pip install -r requirements.txt - -# 4. Configure -cp config_sample.env config.env -nano config.env - -# 5. Run bot -python -m Thunder -``` - -> **Tip:** Start with the essential configuration to get Thunder running, then add optional features as needed. - -
- -## Quick Deploy - -### Deploy to Koyeb - -[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=docker&image=docker.io/fyaz05/thunder:latest&name=thunder&ports=8080;http;/&env[API_ID]=&env[API_HASH]=&env[BOT_TOKEN]=&env[BIN_CHANNEL]=&env[OWNER_ID]=&env[DATABASE_URL]=&env[FQDN]=) - -After deployment, to add any additional environment variables, use the Koyeb dashboard under **Settings** → **Environment Variables**. - -### Deploy to Render - -1. Open [Render Dashboard](https://dashboard.render.com) → **New** → **Web Service** -2. Choose **Existing Image**: `fyaz05/thunder:latest` -3. Add your environment variables -4. Click **Deploy** - -### Deploy to Railway - -1. Open [Railway](https://railway.app) → **New Project** → **Deploy Service** -2. Choose **Docker Image**: `fyaz05/thunder:latest` -3. Add your environment variables -4. Click **Deploy** - -### Deploy to Heroku - -1. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and login. -2. Create an app in the EU region (required for GDPR compliance): - ```bash - heroku create your-app-name --region eu - heroku stack:set container - ``` -3. Set your config vars: - ```bash - heroku config:set API_ID="your_id" API_HASH="your_hash" BOT_TOKEN="your_token" \ - BIN_CHANNEL="-100xxx" OWNER_ID="your_id" FQDN="your-app-name.herokuapp.com" \ - HAS_SSL="True" NO_PORT="True" PORT="8080" - ``` -4. Set `DATABASE_URL` via the Heroku Dashboard or API (ampersand in MongoDB URL causes shell issues). -5. Deploy via source upload (Heroku does not support direct git push with API key auth): - ```bash - # Create source tarball - tar -czf source.tar.gz --exclude='.git' --exclude='__pycache__' . - # Upload via Heroku Builds API — see devcenter.heroku.com/articles/build-and-release-using-the-api - ``` -6. Scale the dyno: - ```bash - heroku ps:scale web=1 - ``` -7. Set `UPSTREAM_REPO` for auto-updates on dyno restart: - ```bash - heroku config:set UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" UPSTREAM_BRANCH="main" - ``` - -> **Note:** Heroku provides HTTPS automatically. Set `FQDN` to `your-app-name.herokuapp.com` and `HAS_SSL` to `True`. - -> **Note:** See the [Configuration](#configuration) section for required environment variables. - -## Reverse Proxy Setup - -
-Reverse Proxy Guide - -This guide will help you set up a secure reverse proxy using **NGINX** for your file streaming bot with **Cloudflare SSL protection**. - ---- - -#### ✅ What You Need - -- A **VPS or server** running Ubuntu/Debian with NGINX installed. -- Your **file streaming bot** running on a local port (e.g., `8080`). -- A **subdomain** (e.g., `f2l.thunder.com`) set up in **Cloudflare**. -- **Cloudflare Origin Certificate** files: `cert.pem` and `key.key`. - ---- - -#### 🔐 Step 1: Configure Cloudflare - -- **DNS**: Add an `A` record for your subdomain pointing to your server's IP. Ensure **Proxy Status** is **Proxied (orange cloud)**. -- **SSL**: In the **SSL/TLS** tab, set the encryption mode to **Full (strict)**. - ---- - -#### 🛡️ Step 2: Set Up SSL Certificates on Server - -Create a folder for your certificates and place `cert.pem` and `key.key` inside. Secure the private key. - -```bash -sudo mkdir -p /etc/ssl/cloudflare/f2l.thunder.com -# Move/copy your cert.pem and key.key files into this directory -sudo chmod 600 /etc/ssl/cloudflare/f2l.thunder.com/key.key -sudo chmod 644 /etc/ssl/cloudflare/f2l.thunder.com/cert.pem -``` - ---- - -#### 🛠️ Step 3: Create NGINX Configuration - -Create a new file at `/etc/nginx/sites-available/f2l.thunder.conf` and paste the following, replacing `f2l.thunder.com` and `8080` with your values. - -```nginx -server { - listen 443 ssl; - listen [::]:443 ssl; - server_name f2l.thunder.com; - - # SSL Configuration - ssl_certificate /etc/ssl/cloudflare/f2l.thunder.com/cert.pem; - ssl_certificate_key /etc/ssl/cloudflare/f2l.thunder.com/key.key; - - # Basic security - add_header X-Frame-Options DENY; - add_header X-Content-Type-Options nosniff; - - location / { - # Forward requests to your bot - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Settings for file streaming - proxy_buffering off; - proxy_request_buffering off; - client_max_body_size 0; - } -} - -# Redirect HTTP to HTTPS -server { - listen 80; - listen [::]:80; - server_name f2l.thunder.com; - return 301 https://$host$request_uri; -} -``` - ---- - -#### 🔄 Step 4: Test and Apply Changes - -Enable the configuration, test it, and reload NGINX. - -```bash -sudo ln -s /etc/nginx/sites-available/f2l.thunder.conf /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx -``` - -Your reverse proxy is now securely streaming files behind Cloudflare! - -
- -## Support & Community - -### Troubleshooting & FAQ - -#### **Initial Setup** - -**Q: Why isn't my bot responding after setup?** -A: This is usually a configuration issue. Please check the following: - -1. **Verify `config.env`**: Make sure all essential variables (`API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL`) are filled in correctly. -2. **Use `config.env` Only**: Do not edit `vars.py` or `config_sample.env`. The bot is designed to only read your settings from `config.env`. -3. **Check Logs**: Review the console logs on your server or hosting platform (Koyeb, Render, Heroku) for any startup errors. - -**Q: What do I use for the `FQDN` variable?** -A: It's the public URL or IP address of your bot. - -- **With a Domain**: Use your subdomain (e.g., `f2l.thunder.com`). -- **On Koyeb/Render/Heroku**: Use the public URL provided by the platform. -- **On a VPS**: Use your server's public IP address. - -**Q: Why are my links not working on a VPS?** -A: For links to work on a VPS, the URL must include the port number (e.g., `http://YOUR_VPS_IP:8080`). Ensure that `NO_PORT` is set to `False` in `config.env` and that your server is configured to allow traffic through that port. - -#### **Common Errors** - -**Q: Why are my links showing a "Resource Not Found" error or not working?** -A: This error means the bot can't access the file. Check these three things: - -1. **Invalid Token**: Your `BOT_TOKEN` or one of the `MULTI_TOKEN`s might be wrong. Double-check them with @BotFather. -2. **Missing Admin Rights**: The bot and **all** your client accounts must be **administrators** in the `BIN_CHANNEL`. -3. **File Deleted**: The link will break if the file was deleted from your `BIN_CHANNEL`. - -**Q: Why isn't video or audio playing correctly in my browser?** -A: Your browser likely doesn't support the file's audio or video format (codec). This is a browser limitation, not a bot issue. - -- **Solution**: For perfect playback, copy the link and play it in a dedicated media player. Recommended players include **VLC Media Player**, **MX Player**, **PotPlayer**, **IINA**, and **MPV**. - -**Q: Why does the bot sometimes become unresponsive?** -A: This is likely a **Telegram Flood Wait**. To prevent spam, Telegram temporarily limits accounts that make too many requests. The bot is designed to handle this automatically by pausing and will resume on its own once the limit is lifted. - -#### **Performance** - -**Q: How can I fix slow download and streaming speeds?** -A: If your speeds are slow, here’s how to fix it: - -- **Add More Clients**: This is the best solution. Add `MULTI_TOKEN`s to your `config.env` to distribute the workload and increase throughput. -- **Use DC4 Accounts**: For top performance, use Telegram accounts from **Data Center 4 (DC4)**, as they often have the fastest connection. Use `/dc` to check an account's data center. -- **Upgrade Your Server**: A server with a slow network will bottleneck your speeds. Consider upgrading your VPS plan. - -#### **Bot Usage** - -**Q: How do I generate links for multiple files at once?** -A: The `/link` command can process multiple files sent in sequence. To use it, **reply to the first file** of the series with the command and the total count. - -- **Example**: For a series of 5 files, reply to the very first file with `/link 5`. - -**Q: Can I mix tokens from different accounts and data centers?** -A: Yes. Mixing clients from different accounts and data centers (like DC1, DC4, and DC5) is a great way to improve bot performance and reliability. - -### Contributing - -Contributions are welcome! Please follow these steps: - -1. Fork the repository. -2. Create a new feature branch (`git checkout -b feature/amazing-feature`). -3. Commit your changes (`git commit -m 'Add some amazing feature'`). -4. Push to the branch (`git push origin feature/amazing-feature`). -5. Open a Pull Request. - -## License - -Licensed under the [Apache License 2.0](LICENSE). See the `LICENSE` file for details. - -## Acknowledgments - -- [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) - Telegram MTProto API Framework -- [aiohttp](https://github.com/aio-libs/aiohttp) - Asynchronous HTTP Client/Server -- [PyMongo](https://github.com/mongodb/mongo-python-driver) - Asynchronous MongoDB Driver -- [TgCrypto](https://github.com/pyrogram/tgcrypto) - High-performance cryptography library - -## ⚠️ Disclaimer - -This project is not affiliated with Telegram. Use it responsibly and in compliance with Telegram's Terms of Service and all applicable local regulations. - ---- - -

- ⭐ Star this project if you find it useful!
- Report Bug • - Request Feature -

+ Thunder Logo +

⚡ Thunder

+

+ +

+ High-Performance Telegram File-to-Link Bot for Direct Links & Streaming +

+ +

+ Python Version + Pyrofork + License + Telegram Channel +

+ +
+ +## 📑 Table of Contents + +- [About The Project](#about-the-project) +- [How It Works](#how-it-works) +- [Features](#features) +- [Configuration](#configuration) + - [Essential Configuration](#essential-configuration) + - [Optional Configuration](#optional-configuration) +- [Usage and Commands](#usage-and-commands) + - [Basic Usage](#basic-usage) + - [Commands Reference](#commands-reference) +- [Advanced Feature Setup](#advanced-feature-setup) + - [Token System](#token-system) + - [URL Shortening](#url-shortening) + - [Rate Limiting System](#rate-limiting-system) +- [Deployment Guide](#deployment-guide) + - [Prerequisites](#prerequisites) + - [Installation](#installation) + - [Quick Deploy](#quick-deploy) + - [Deploy to Koyeb](#deploy-to-koyeb) + - [Deploy to Render](#deploy-to-render) + - [Deploy to Railway](#deploy-to-railway) + - [Deploy to Heroku](#deploy-to-heroku) + - [Reverse Proxy Setup](#reverse-proxy-setup) +- [Support & Community](#support--community) + - [Troubleshooting & FAQ](#troubleshooting--faq) + - [Contributing](#contributing) +- [License](#license) +- [Acknowledgments](#acknowledgments) + +
+ +## About The Project + +**Thunder** is a powerful Telegram bot that transforms Telegram files into high-speed direct links, perfect for both streaming and rapid downloading. Share files via HTTP(S) links without needing to download them from the Telegram client first. + +### 💡 Perfect For + +- 🚀 Bypassing Telegram's built-in download speed limits +- ☁️ Unlimited cloud storage with fast streaming and download links +- 🎬 Content creators sharing media files +- 👥 Communities distributing resources +- 🎓 Educational platforms sharing materials + +## How It Works + +``` +User Uploads File → Telegram Bot → Forwards to Channel → Generates Direct Link → Direct Download / Streaming +``` + +1. **Upload** → User sends any file to the bot. +2. **Store** → The bot forwards the file to your private storage channel (`BIN_CHANNEL`), where it is permanently saved to generate the link. +3. **Generate** → A unique, permanent link is created. +4. **Stream/Download** → Anyone with the link can stream or download the file directly in their browser. +5. **Balance** → Multi-client support distributes the load for high availability. + +## Features + +#### Core Functionality + +- ✅ **Direct Link Generation** - Convert any Telegram file into a direct HTTP(S) link. +- ✅ **Permanent Links** - Links remain active as long as the file exists in the storage channel. +- ✅ **Browser Streaming & Downloading** - Stream media directly or download files at high speed without a Telegram client. +- ✅ **All File Types** - Supports video, audio, documents, images, and any other file format. +- ✅ **Batch Processing** - Generate links for multiple files at once with a single command. + +#### Performance & Scalability + +- ✅ **Multi-Client Support** - Distributes traffic across multiple Telegram bots to avoid limits and increase throughput. +- ✅ **Async Architecture** - Built with `aiohttp` and `asyncio` for non-blocking, high-performance operations. +- ✅ **MongoDB Integration** - Ensures persistent and reliable data storage. + +#### Security & Control + +- 🔐 **Token Authentication** - Secure user access with a time-limited token system. +- 🛡️ **Admin Controls** - Full suite of commands for user and bot management. +- 👤 **User Authentication** - Require users to join a specific channel before they can use the bot. +- ✅ **Channel/Group Support** - Fully functional in private chats, groups, and channels (channels require `CHANNEL=True` plus the bot as channel admin; channel posts are ignored under `PRIVATE_MODE` or for banned channels). + +#### Customization + +- 🌍 **Custom Domain** - Serve files from your own domain for a professional look. +- 🔗 **URL Shortening** - Integrate with URL shortener services for clean, shareable links. +- 🎨 **Custom Templates** - Personalize messages sent by the bot to match your brand. +- 📈 **Media Info Display** - Shows file size, duration, and format details in the response message. + +## Configuration + +Copy `config_sample.env` to `config.env` and fill in your values. + +> **Tip:** for machine-local overrides, create a `config.env.local`. It is +> loaded after `config.env` and its values win (precedence: real +> environment > `config.env.local` > `config.env`). Keep it out of version +> control for machine-specific tweaks. + +### Essential Configuration + +| Variable | Description | Example | +| :--- | :--- | :--- | +| `API_ID` | Telegram API ID | `12345678` | +| `API_HASH` | Telegram API Hash | `abc123def456` | +| `BOT_TOKEN` | Bot token from @BotFather | `123456:ABCdefGHI` | +| `BIN_CHANNEL` | Storage channel ID | `-1001234567890` | +| `OWNER_ID` | Owner user ID | `12345678` | +| `DATABASE_URL` | MongoDB connection | `mongodb+srv://...` | + +Those six are the only variables the bot refuses to boot without. PaaS +companions — `FQDN`, `HAS_SSL`, `PORT`, `NO_PORT` — matter only when the +platform proxies the app; the bot infers them and logs what it chose. + +### Optional Configuration + +Essential knobs are in the table above; everything else with safe defaults is documented once in config_sample.env (single source — do not duplicate values here). + +## Usage and Commands + +### Basic Usage + +1. **Start** → Send `/start` to the bot. +2. **Authenticate** → Join required channels (if configured). +3. **Upload** → Send any media file. +4. **Receive** → Get a direct streaming and download link. +5. **Share** → Anyone can access the file via the link. + +### Commands Reference + +#### User Commands + +| Command | Description | +| :--- | :--- | +| `/start` | Start the bot and get a welcome message. Also used for token activation. | +| `/link` | Generates a link. In groups the bot must be an admin. For batches, **reply to the first file** of a group and specify the count. **Example:** `/link 5` will process that file and the next four. | +| `/dc` | Get the data center (DC) of a user or file. Use `/dc id`, or reply to a file or user. | +| `/ping` | Check if the bot is online and measure response time. | +| `/about` | Get information about the bot. | +| `/help` | Show help and usage instructions. | + +#### Admin Commands + +| Command | Description | +| :--- | :--- | +| `/status` | Check bot status, uptime, and resource usage. | +| `/broadcast` | Send a message to all users (supports text, media, buttons). | +| `/stats` | View usage statistics and analytics. | +| `/ban` | Ban a user or channel (reply to message or use user/channel ID). | +| `/unban` | Unban a user or channel. | +| `/log` | Send bot logs. | +| `/restart` | Restart the bot. | +| `/shell` | Execute a shell command (requires ENABLE_SHELL=True). | +| `/users` | Show total number of users. | +| `/authorize` | Permanently authorize a user to use the bot (bypasses token system). | +| `/deauthorize` | Remove permanent authorization from a user. | +| `/listauth` | List all permanently authorized users. | + +
+

BotFather Commands Setup

+ +```text +start - Initialize bot +link - Generate direct link +dc - Get data center info +ping - Check bot status +about - Bot information +help - Show help guide +status - [Admin] System status +stats - [Admin] Usage statistics +broadcast - [Admin] Message all users +ban - [Admin] Ban user +unban - [Admin] Unban user +users - [Admin] User count +authorize - [Admin] Grant access +deauthorize - [Admin] Revoke access +listauth - [Admin] List authorized +log - [Admin] Send bot logs +restart - [Admin] Restart the bot +shell - [Admin] Execute shell command +``` + +
+ +## Advanced Feature Setup + +### Token System + +Enable controlled access with tokens: + +1. Set `TOKEN_ENABLED=True` in your `config.env`. +2. Users receive automatic tokens on first use. +3. Admins can grant permanent authorization with `/authorize` to bypass tokens. +4. Tokens include activation links for secure access. + +> **Note**: with `TOKEN_ENABLED=True` (or `PRIVATE_MODE=True`), messages +> without an attributable sender — e.g. channel posts or anonymous-admin +> messages in the bot's own channel — are rejected by design. Interact with +> the bot from a personal account so your user ID can be checked against +> the token/ban gates. + +### URL Shortening + +Configure URL shortening for cleaner links: + +```env +SHORTEN_ENABLED=True +SHORTEN_MEDIA_LINKS=True +URL_SHORTENER_API_KEY=your_api_key +URL_SHORTENER_SITE=shortener.example.com +``` + +### Rate Limiting System + +Thunder implements a sophisticated multi-tier rate limiting system designed for high-performance file sharing: + +#### **Priority Queue Architecture** + +- **Owner Priority**: Complete bypass of all rate limits. +- **Authorized Users**: Dedicated priority queue with faster processing. +- **Regular Users**: Standard queue with fair scheduling. + +#### **Multi-Level Rate Limiting** + +- **Per-User Limits**: Configurable files per time window. +- **Global Limits**: System-wide request throttling. +- **Sliding Window**: Time-based rate limiting with automatic cleanup. + +#### **Smart Queue Management** + +- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued. +- **Queue Size Limits**: Configurable maximum queue size. +- **Flood Protection**: Built-in protection against Telegram flood waits. + +## Deployment Guide + +This section covers the complete setup process for deploying Thunder, from prerequisites to production deployment. + +### Prerequisites + +| Requirement | Description | Source | +| :--- | :--- | :--- | +| Python 3.13 | Programming language | [python.org](https://python.org) | +| MongoDB | Database | [mongodb.com](https://mongodb.com) | +| Telegram API | API credentials | [my.telegram.org](https://my.telegram.org/apps) | +| Bot Token | From @BotFather | [@BotFather](https://t.me/BotFather) | +| Public Server | VPS/Dedicated server | Any provider | +| Storage Channel | For file storage | Create in Telegram | + +### Installation + +#### Docker Installation (Recommended) + +```bash +# 1. Clone repository +git clone https://github.com/fyaz05/FileToLink.git +cd FileToLink + +# 2. Configure +cp config_sample.env config.env +nano config.env # Edit your settings + +# 3. Build and run +docker build -t thunder . +# config.env is excluded from the build context; mount it at runtime +docker run -d --name thunder -p 8080:8080 \ + -v $(pwd)/config.env:/app/config.env:ro thunder +# Local dev with MongoDB instead: docker compose up --build +``` + +
+Manual Installation + +```bash +# 1. Clone repository +git clone https://github.com/fyaz05/FileToLink.git +cd FileToLink + +# 2. Setup virtual environment +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 3. Install dependencies +pip install -r requirements.txt # direct pins; Docker uses the hash-pinned requirements.lock +# uvloop/tgcrypto are marked POSIX-only, so this also works on Windows +# (the MTProto speedups are simply skipped there) + +# 4. Configure +cp config_sample.env config.env +nano config.env + +# 5. Run bot +python -m Thunder +``` + +> **Tip:** Start with the essential configuration to get Thunder running, then add optional features as needed. + +
+ +## Quick Deploy + +### Deploy to Koyeb + +[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=docker&image=docker.io/fyaz05/thunder:latest&name=thunder&ports=8080;http;/&env[API_ID]=&env[API_HASH]=&env[BOT_TOKEN]=&env[BIN_CHANNEL]=&env[OWNER_ID]=&env[DATABASE_URL]=&env[FQDN]=) + +After deployment, to add any additional environment variables, use the Koyeb dashboard under **Settings** → **Environment Variables**. + +### Deploy to Render + +`render.yaml` builds from this repository's `Dockerfile` (`runtime: docker`), +so Render builds the image rather than pulling `fyaz05/thunder:latest`: + +1. Open [Render Dashboard](https://dashboard.render.com) → **New** → **Web Service** +2. Connect the repository — the blueprint is auto-detected, or pick + **Existing Image**: `fyaz05/thunder:latest` to run the published build +3. Add your environment variables +4. Click **Deploy** + +### Deploy to Railway + +1. Open [Railway](https://railway.app) → **New Project** → **Deploy Service** +2. Choose **Docker Image**: `fyaz05/thunder:latest` +3. Add your environment variables +4. Click **Deploy** + +### Deploy to Heroku + +1. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and login. +2. Create an app in the EU region (required for GDPR compliance): + ```bash + heroku create your-app-name --region eu + heroku stack:set container + ``` +3. Set your config vars (never set `PORT` — Heroku assigns it dynamically and + the app reads `$PORT` automatically): + ```bash + heroku config:set API_ID="your_id" API_HASH="your_hash" BOT_TOKEN="your_token" \ + BIN_CHANNEL="-100xxx" OWNER_ID="your_id" FQDN="your-app-name.herokuapp.com" \ + HAS_SSL="True" NO_PORT="True" + ``` +4. Set `DATABASE_URL` via the Heroku Dashboard or API (ampersand in MongoDB URL causes shell issues). +5. Deploy by pushing the branch (container stack builds `Dockerfile` via `heroku.yml`): + ```bash + git push heroku main --force + ``` +6. Scale the dyno: + ```bash + heroku ps:scale web=1 + ``` +7. Redeploy to update (pull a new image / push again) — `UPSTREAM_REPO` has no + effect on Docker/Heroku images (no git inside), so leave it unset: + ```bash + git push heroku main --force + ``` + +> **Note:** Heroku provides HTTPS automatically. Set `FQDN` to `your-app-name.herokuapp.com` and `HAS_SSL` to `True`. + +> **Note:** See the [Configuration](#configuration) section for required environment variables. + +## Reverse Proxy Setup + +
+Reverse Proxy Guide + +This guide will help you set up a secure reverse proxy using **NGINX** for your file streaming bot with **Cloudflare SSL protection**. + +--- + +#### ✅ What You Need + +- A **VPS or server** running Ubuntu/Debian with NGINX installed. +- Your **file streaming bot** running on a local port (e.g., `8080`). +- A **subdomain** (e.g., `f2l.thunder.com`) set up in **Cloudflare**. +- **Cloudflare Origin Certificate** files: `cert.pem` and `key.key`. + +--- + +#### 🔐 Step 1: Configure Cloudflare + +- **DNS**: Add an `A` record for your subdomain pointing to your server's IP. Ensure **Proxy Status** is **Proxied (orange cloud)**. +- **SSL**: In the **SSL/TLS** tab, set the encryption mode to **Full (strict)**. + +--- + +#### 🛡️ Step 2: Set Up SSL Certificates on Server + +Create a folder for your certificates and place `cert.pem` and `key.key` inside. Secure the private key. + +```bash +sudo mkdir -p /etc/ssl/cloudflare/f2l.thunder.com +# Move/copy your cert.pem and key.key files into this directory +sudo chmod 600 /etc/ssl/cloudflare/f2l.thunder.com/key.key +sudo chmod 644 /etc/ssl/cloudflare/f2l.thunder.com/cert.pem +``` + +--- + +#### 🛠️ Step 3: Create NGINX Configuration + +Create a new file at `/etc/nginx/sites-available/f2l.thunder.conf` and paste the following, replacing `f2l.thunder.com` and `8080` with your values. + +```nginx +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name f2l.thunder.com; + + # SSL Configuration + ssl_certificate /etc/ssl/cloudflare/f2l.thunder.com/cert.pem; + ssl_certificate_key /etc/ssl/cloudflare/f2l.thunder.com/key.key; + + # Basic security + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + + location / { + # Forward requests to your bot + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Settings for file streaming + proxy_buffering off; + proxy_request_buffering off; + client_max_body_size 0; + } +} + +# Redirect HTTP to HTTPS +server { + listen 80; + listen [::]:80; + server_name f2l.thunder.com; + return 301 https://$host$request_uri; +} +``` + +--- + +#### 🔄 Step 4: Test and Apply Changes + +Enable the configuration, test it, and reload NGINX. + +```bash +sudo ln -s /etc/nginx/sites-available/f2l.thunder.conf /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +Your reverse proxy is now securely streaming files behind Cloudflare! + +
+ +## Support & Community + +### Troubleshooting & FAQ + +#### **Initial Setup** + +**Q: Why isn't my bot responding after setup?** +A: This is usually a configuration issue. Please check the following: + +1. **Verify `config.env`**: Make sure all essential variables (`API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `OWNER_ID`, `DATABASE_URL`) are filled in correctly. +2. **Use `config.env` (plus optional `config.env.local` overrides)**: Do not edit `vars.py` or `config_sample.env`. The bot reads your settings from `config.env` and, if present, `config.env.local` (local layer wins). +3. **Check Logs**: Review the console logs on your server or hosting platform (Koyeb, Render, Heroku) for any startup errors. + +**Q: What do I use for the `FQDN` variable?** +A: It's the public URL or IP address of your bot. + +- **With a Domain**: Use your subdomain (e.g., `f2l.thunder.com`). +- **On Koyeb/Render/Heroku**: Use the public URL provided by the platform. +- **On a VPS**: Use your server's public IP address. + +**Q: Why are my links not working on a VPS?** +A: For links to work on a VPS, the URL must include the port number (e.g., `http://YOUR_VPS_IP:8080`). Ensure that `NO_PORT` is set to `False` in `config.env` and that your server is configured to allow traffic through that port. + +#### **Common Errors** + +**Q: Why are my links showing a "Resource Not Found" error or not working?** +A: This error means the bot can't access the file. Check these three things: + +1. **Invalid Token**: Your `BOT_TOKEN` or one of the `MULTI_TOKEN1..N` values might be wrong. Double-check them with @BotFather. +2. **Missing Admin Rights**: The bot and **all** your client accounts must be **administrators** in the `BIN_CHANNEL`. +3. **File Deleted**: The link will break if the file was deleted from your `BIN_CHANNEL`. + +**Q: Why isn't video or audio playing correctly in my browser?** +A: Your browser likely doesn't support the file's audio or video format (codec). This is a browser limitation, not a bot issue. + +- **Solution**: For perfect playback, copy the link and play it in a dedicated media player. Recommended players include **VLC Media Player**, **MX Player**, **PotPlayer**, **IINA**, and **MPV**. + +**Q: Why does the bot sometimes become unresponsive?** +A: This is likely a **Telegram Flood Wait**. To prevent spam, Telegram temporarily limits accounts that make too many requests. The bot is designed to handle this automatically by pausing and will resume on its own once the limit is lifted. + +#### **Performance** + +**Q: How can I fix slow download and streaming speeds?** +A: If your speeds are slow, here’s how to fix it: + +- **Add More Clients**: This is the best solution. Add `MULTI_TOKEN1`, `MULTI_TOKEN2`, ... to your `config.env` to distribute the workload and increase throughput. +- **Use DC4 Accounts**: For top performance, use Telegram accounts from **Data Center 4 (DC4)**, as they often have the fastest connection. Use `/dc` to check an account's data center. +- **Upgrade Your Server**: A server with a slow network will bottleneck your speeds. Consider upgrading your VPS plan. + +#### **Bot Usage** + +**Q: How do I generate links for multiple files at once?** +A: The `/link` command can process multiple files sent in sequence. To use it, **reply to the first file** of the series with the command and the total count. + +- **Example**: For a series of 5 files, reply to the very first file with `/link 5`. + +**Q: Can I mix tokens from different accounts and data centers?** +A: Yes. Mixing clients from different accounts and data centers (like DC1, DC4, and DC5) is a great way to improve bot performance and reliability. + +### Contributing + +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow — including the local quality gates (`make format lint test`) that CI enforces on every PR. + +## License + +Licensed under the [Apache License 2.0](LICENSE). See the `LICENSE` file for details. + +The published Docker image links against [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) +and `tgcrypto`, both **LGPL-3.0-or-later**. Apache-2.0 is compatible with +importing them, but redistributing the image as a binary requires shipping +those components' source and permitting relinking. Upstream Pyrofork was +archived in September 2026, so a maintained fork is the planned exit. + +## Acknowledgments + +- [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) - Telegram MTProto API Framework +- [aiohttp](https://github.com/aio-libs/aiohttp) - Asynchronous HTTP Client/Server +- [PyMongo](https://github.com/mongodb/mongo-python-driver) - Asynchronous MongoDB Driver +- [TgCrypto](https://github.com/pyrogram/tgcrypto) - High-performance cryptography library + +## ⚠️ Disclaimer + +This project is not affiliated with Telegram. Use it responsibly and in compliance with Telegram's Terms of Service and all applicable local regulations. + +--- + +

+ ⭐ Star this project if you find it useful!
+ Report Bug • + Request Feature +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..32873ebb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Reporting a vulnerability + +Please open a private security advisory via GitHub's **Report a vulnerability** +button on the Security tab, or contact the owner directly. Do not open a +public issue for security reports. + +## Scope notes + +- `/shell` is disabled by default; enable only with `ENABLE_SHELL=True` on + trusted deployments (owner-only regardless). +- Session files are chmod 0600 after startup; keep them out of backups. +- `/log` uploads are regex-redacted (bot tokens, Mongo URIs) before upload. +- HTTP access logs carry 8-hex path pseudonyms, never tokens or hashes. diff --git a/Thunder/__init__.py b/Thunder/__init__.py index 4e32f5df..cf3eaf77 100644 --- a/Thunder/__init__.py +++ b/Thunder/__init__.py @@ -1,6 +1,10 @@ -# Thunder/__init__.py - -import time - -StartTime = time.time() -__version__ = "2.1.0" +import os +import time + +StartTime = time.time() + +# Build-time injectable version -- Docker/PaaS may set APP_VERSION; +# the pyproject.toml [project] version is the default. Exposed by /status and /stats. +# NOTE: snapshots at import, so env-only (Docker -e / systemd) -- config.env +# loads later in vars.py and cannot set this. +__version__ = os.getenv("APP_VERSION", "2.2.0") diff --git a/Thunder/__main__.py b/Thunder/__main__.py index a6c5473a..a8e604e3 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -1,328 +1,575 @@ -# Thunder/__main__.py - -import asyncio -import glob -import importlib.util -import sys -from datetime import datetime - -from pathlib import Path - -if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - -try: - from uvloop import install - install() -except ImportError: - pass -from aiohttp import web -from pyrogram import idle -from pyrogram.errors import FloodWait, MessageNotModified - -from Thunder import __version__ -from Thunder.bot import StreamBot -from Thunder.bot.clients import cleanup_clients, initialize_clients -from Thunder.server import web_server -from Thunder.utils.commands import set_commands -from Thunder.utils.database import db -from Thunder.utils.keepalive import ping_server -from Thunder.utils.canonical_files import drain_background_touch_tasks -from Thunder.utils.logger import logger -from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE -from Thunder.utils.rate_limiter import rate_limiter, request_executor -from Thunder.utils.tokens import cleanup_expired_tokens -from Thunder.vars import Var - - -PLUGIN_PATH = "Thunder/bot/plugins/*.py" -VERSION = __version__ - - -def print_banner(): - banner = f""" -╔═══════════════════════════════════════════════════════════════════╗ -║ ║ -║ ████████╗██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██████╗ ║ -║ ╚══██╔══╝██║ ██║██║ ██║████╗ ██║██╔══██╗██╔════╝██╔══██╗ ║ -║ ██║ ███████║██║ ██║██╔██╗ ██║██║ ██║█████╗ ██████╔╝ ║ -║ ██║ ██╔══██║██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔══██╗ ║ -║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██████╔╝███████╗██║ ██║ ║ -║ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ║ -║ ║ -║ File Streaming Bot v{VERSION} ║ -╚═══════════════════════════════════════════════════════════════════╝ -""" - print(banner) - - -def schedule_index_ensure() -> None: - task = asyncio.create_task( - db.ensure_indexes(raise_on_error=False), - name="ensure_database_indexes" - ) - - def _log_index_failure(done_task: asyncio.Task) -> None: - try: - created_indexes = done_task.result() - if created_indexes: - print(" ✓ Database indexes ensured.") - else: - print(" ▶ Database indexes could not be ensured during startup.") - except Exception as e: - logger.error(f"Background database index ensure failed: {e}", exc_info=True) - - task.add_done_callback(_log_index_failure) - - -async def import_plugins(): - print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") - plugins = glob.glob(PLUGIN_PATH) - if not plugins: - print(" ▶ No plugins found to import!") - return 0 - - success_count = 0 - failed_plugins = [] - - for file_path in plugins: - try: - plugin_path = Path(file_path) - plugin_name = plugin_path.stem - import_path = f"Thunder.bot.plugins.{plugin_name}" - - spec = importlib.util.spec_from_file_location( - import_path, plugin_path - ) - if spec is None or spec.loader is None: - logger.error(f"Invalid plugin specification for {plugin_name}") - failed_plugins.append(plugin_name) - continue - - module = importlib.util.module_from_spec(spec) - sys.modules[import_path] = module - spec.loader.exec_module(module) - success_count += 1 - - except Exception as e: - plugin_name = Path(file_path).stem - logger.error(f" ✖ Failed to import plugin {plugin_name}: {e}") - failed_plugins.append(plugin_name) - - print( - f" ▶ Total: {len(plugins)} | Success: {success_count} | " - f"Failed: {len(failed_plugins)}" - ) - if failed_plugins: - print(f" ▶ Failed plugins: {', '.join(failed_plugins)}") - - return success_count - - -async def start_services(): - start_time = datetime.now() - print_banner() - print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") - - print(" ▶ Starting Telegram Bot initialization...") - try: - try: - await StreamBot.start() - except FloodWait as e: - logger.debug(f"FloodWait in bot start, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.start() - - try: - bot_info = await StreamBot.get_me() - except FloodWait as e: - logger.debug(f"FloodWait in get_me, sleeping for {e.value}s") - await asyncio.sleep(e.value) - bot_info = await StreamBot.get_me() - - StreamBot.username = bot_info.username - print(f" ✓ Bot initialized successfully as @{StreamBot.username}") - - await set_commands() - print(" ✓ Bot commands set successfully.") - schedule_index_ensure() - - restart_message_data = await db.get_restart_message() - if restart_message_data: - try: - try: - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except FloodWait as e: - logger.debug(f"FloodWait in restart message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except MessageNotModified: - pass - await db.delete_restart_message( - restart_message_data["message_id"] - ) - except Exception as e: - logger.error( - f"Error processing restart message: {e}", exc_info=True - ) - else: - pass - - except Exception as e: - logger.error( - f" ✖ Failed to initialize Telegram Bot: {e}", exc_info=True - ) - return - - print(" ▶ Starting Client initialization...") - try: - await initialize_clients() - except Exception as e: - logger.error(f" ✖ Failed to initialize clients: {e}", exc_info=True) - return - - await import_plugins() - - print(" ▶ Starting Request Executor initialization...") - try: - request_executor_task = asyncio.create_task( - request_executor(), name="request_executor_task" - ) - print(" ✓ Request executor service started") - except Exception as e: - logger.error( - f" ✖ Failed to start request executor: {e}", exc_info=True - ) - return - - print(" ▶ Starting Web Server initialization...") - try: - app_runner = web.AppRunner(await web_server()) - await app_runner.setup() - bind_address = Var.BIND_ADDRESS - site = web.TCPSite(app_runner, bind_address, Var.PORT) - await site.start() - - keepalive_task = asyncio.create_task( - ping_server(), name="keepalive_task" - ) - print(" ✓ Keep-alive service started") - token_cleanup_task = asyncio.create_task( - schedule_token_cleanup(), name="token_cleanup_task" - ) - - except Exception as e: - logger.error(f" ✖ Failed to start Web Server: {e}", exc_info=True) - if 'request_executor_task' in locals() and not request_executor_task.done(): - request_executor_task.cancel() - try: - await request_executor_task - except asyncio.CancelledError: - pass - try: - await StreamBot.stop() - except Exception: - pass - try: - await cleanup_clients() - except Exception: - pass - try: - await rate_limiter.shutdown() - except Exception: - pass - try: - await db.close() - except Exception as e: - logger.error(f"Error during database cleanup: {e}", exc_info=True) - try: - await drain_background_touch_tasks() - except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) - return - - elapsed_time = (datetime.now() - start_time).total_seconds() - print("╠═══════════════════════════════════════════════════════════╣") - print(f" ▶ Bot Name: {bot_info.first_name}") - print(f" ▶ Username: @{bot_info.username}") - print(f" ▶ Server: {bind_address}:{Var.PORT}") - print(f" ▶ Startup Time: {elapsed_time:.2f} seconds") - print("╚═══════════════════════════════════════════════════════════╝") - print(" ▶ Bot is now running! Press CTRL+C to stop.") - - background_tasks = [ - request_executor_task, - keepalive_task, - token_cleanup_task - ] - - try: - await idle() - finally: - print(" ▶ Shutting down services...") - - for task in background_tasks: - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - try: - await rate_limiter.shutdown() - except Exception as e: - logger.error(f"Error during rate limiter cleanup: {e}") - - try: - await cleanup_clients() - except Exception as e: - logger.error(f"Error during client cleanup: {e}") - - try: - await drain_background_touch_tasks() - except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) - - if 'app_runner' in locals() and app_runner is not None: - try: - await app_runner.cleanup() - except Exception as e: - logger.error(f"Error during web server cleanup: {e}") - - try: - await db.close() - print(" ✓ Database connection closed") - except Exception as e: - logger.error("Error during database cleanup", exc_info=True) - - -async def schedule_token_cleanup(): - while True: - try: - await asyncio.sleep(3 * 3600) - await cleanup_expired_tokens() - except asyncio.CancelledError: - logger.debug("schedule_token_cleanup cancelled cleanly.") - break - except Exception as e: - logger.error(f"Token cleanup error: {e}", exc_info=True) - -if __name__ == '__main__': - try: - loop = asyncio.get_event_loop() - loop.run_until_complete(start_services()) - except KeyboardInterrupt: - print("╔═══════════════════════════════════════════════════════════╗") - print("║ Bot stopped by user (CTRL+C) ║") - print("╚═══════════════════════════════════════════════════════════╝") - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") +import asyncio +import glob +import importlib.util +import os +import sys +import time +from pathlib import Path +from typing import NamedTuple + +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +try: + from uvloop import install + + install() +except ImportError: + pass +from aiohttp import web +from pyrogram import idle +from pyrogram.errors import MessageNotModified + +from Thunder import __version__ +from Thunder.bot import StreamBot, work_loads +from Thunder.bot.clients import ( + cleanup_clients, + harden_session_files, + initialize_clients, +) +from Thunder.server import web_server +from Thunder.utils.canonical_files import drain_background_touch_tasks, touch_buffer_stats +from Thunder.utils.commands import set_commands +from Thunder.utils.database import db +from Thunder.utils.flag_cache import flags +from Thunder.utils.keepalive import ping_server +from Thunder.utils.logger import logger +from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE +from Thunder.utils.rate_limiter import rate_limiter, start_executors +from Thunder.utils.safe_call import tg_call +from Thunder.utils.shortener import close_shortener +from Thunder.utils.tokens import cleanup_expired_tokens +from Thunder.vars import Var + +# [!_]* excludes __init__.py (the package marker) and any _helper module: +# import_plugins loads each match as a plugin, so only handler modules belong +PLUGIN_PATH = "Thunder/bot/plugins/[!_]*.py" +VERSION = __version__ + + +def print_banner(): + banner = f""" +╔═══════════════════════════════════════════════════════════════════╗ +║ ║ +║ ████████╗██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██████╗ ║ +║ ╚══██╔══╝██║ ██║██║ ██║████╗ ██║██╔══██╗██╔══╝██╔══██╗ ║ +║ ██║ ███████║██║ ██║██╔██╗ ██║██║ ██║█████╗ ██████╔╝ ║ +║ ██║ ██╔══██║██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔══██╗ ║ +║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██████╔╝███████╗██║ ██║ ║ +║ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ║ +║ ║ +║ File Streaming Bot v{VERSION} ║ +╚═══════════════════════════════════════════════════════════════════╝ +""" + print(banner) + + +def schedule_index_ensure() -> asyncio.Task: + task = asyncio.create_task( + db.ensure_indexes(raise_on_error=False), name="ensure_database_indexes" + ) + + def _log_index_failure(done_task: asyncio.Task) -> None: + try: + created_indexes = done_task.result() + if created_indexes: + print(" ✓ Database indexes ensured.") + else: + print(" ▶ Database indexes could not be ensured during startup.") + except asyncio.CancelledError: + return + except Exception as e: + logger.error(f"Background database index ensure failed: {e}", exc_info=True) + + task.add_done_callback(_log_index_failure) + return task + + +async def import_plugins(): + print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") + plugins = sorted(glob.glob(PLUGIN_PATH)) # deterministic registration order + if not plugins: + print(" ▶ No plugins found to import!") + return 0 + + success_count = 0 + failed_plugins = [] + + for file_path in plugins: + try: + plugin_path = Path(file_path) + plugin_name = plugin_path.stem + import_path = f"Thunder.bot.plugins.{plugin_name}" + + spec = importlib.util.spec_from_file_location(import_path, plugin_path) + if spec is None or spec.loader is None: + logger.error(f"Invalid plugin specification for {plugin_name}") + failed_plugins.append(plugin_name) + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[import_path] = module + spec.loader.exec_module(module) + success_count += 1 + + except Exception as e: + plugin_name = Path(file_path).stem + logger.error(f" ✖ Failed to import plugin {plugin_name}: {e}") + failed_plugins.append(plugin_name) + + print(f" ▶ Total: {len(plugins)} | Success: {success_count} | Failed: {len(failed_plugins)}") + if failed_plugins: + print(f" ▶ Failed plugins: {', '.join(failed_plugins)}") + + return success_count + + +async def start_services(): + start_time = time.monotonic() + background_tasks: list[asyncio.Task] = [] + print_banner() + print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") + + print(" ▶ Starting Telegram Bot initialization...") + try: + # session bootstrap (connect + auth) legitimately outlives the + # 30s lightweight-RPC budget + await tg_call(StreamBot.start, timeout=90.0) + bot_info = await tg_call(StreamBot.get_me) + # pyrogram sets Client.username dynamically, so mypy cannot see it; pin it for /status. + username = bot_info.username + StreamBot.username = username # type: ignore[attr-defined] + print(f" ✓ Bot initialized successfully as @{username}") + + await set_commands() + print(" ✓ Bot commands set successfully.") + # managed background task: cancelled + awaited at shutdown + background_tasks.append(schedule_index_ensure()) + harden_session_files() + + restart_message_data = await db.get_restart_message() + if restart_message_data: + edited_ok = True + try: + await tg_call( + StreamBot.edit_message_text, + chat_id=restart_message_data["chat_id"], + message_id=restart_message_data["message_id"], + text=MSG_ADMIN_RESTART_DONE, + retries=1, + ) + except MessageNotModified: + pass # a previous boot already wrote it; still clear below + except Exception as e: + logger.error(f"Error processing restart message: {e}", exc_info=True) + edited_ok = False + if edited_ok: + try: + await db.delete_restart_message(restart_message_data["message_id"]) + except Exception as e: + logger.error(f"Error clearing restart marker: {e}", exc_info=True) + + except Exception as e: + logger.error(f" ✖ Failed to initialize Telegram Bot: {e}", exc_info=True) + # the index-ensure task may already be scheduled -- stop it against a + # closing client BEFORE db.close. Bounded: these paths used to allow + # 10-30s of cancellation plus every step's full ceiling. + bf = _boot_failure_deadline() + await _cancel_tasks(background_tasks, timeout=_BOOT_FAILURE_CANCEL_SECONDS) + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)", deadline=bf) + await _safe_teardown_step(db.close, "database (boot failure)", deadline=bf) + # a failed boot must exit non-zero or container restart policies never fire. + raise SystemExit(1) from e + + print(" ▶ Starting Client initialization...") + try: + await initialize_clients() + except Exception as e: + logger.error(f" ✖ Failed to initialize clients: {e}", exc_info=True) + # index-ensure may be live already -- stop it before db.close + bf = _boot_failure_deadline() + await _cancel_tasks(background_tasks, timeout=_BOOT_FAILURE_CANCEL_SECONDS) + await _safe_teardown_step(cleanup_clients, "clients (boot failure)", deadline=bf) + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)", deadline=bf) + await _safe_teardown_step(db.close, "database (boot failure)", deadline=bf) + raise SystemExit(1) from e + + await import_plugins() + + print(" ▶ Starting Request Executor initialization...") + try: + # worker pool; registered early so a later boot failure cancels live workers too + executor_tasks = start_executors() + background_tasks.extend(executor_tasks) + print(f" ✓ Request executor pool started ({len(executor_tasks)} workers)") + except Exception as e: + logger.error(f" ✖ Failed to start request executor: {e}", exc_info=True) + # six steps behind a 30s cancel was an 82s unwind against a 30s grace + bf = _boot_failure_deadline() + await _cancel_tasks(background_tasks, timeout=_BOOT_FAILURE_CANCEL_SECONDS) + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter", deadline=bf) + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer", deadline=bf) + await _safe_teardown_step(close_shortener, "shortener", deadline=bf) + await _safe_teardown_step(cleanup_clients, "clients (boot failure)", deadline=bf) + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)", deadline=bf) + await _safe_teardown_step(db.close, "database (boot failure)", deadline=bf) + raise SystemExit(1) from e + + print(" ▶ Starting Web Server initialization...") + try: + app_runner = web.AppRunner(await web_server(), access_log=None) + await app_runner.setup() + bind_address = Var.BIND_ADDRESS + site = web.TCPSite(app_runner, bind_address, Var.PORT) + await site.start() + + keepalive_task = asyncio.create_task(ping_server(), name="keepalive_task") + background_tasks.append(keepalive_task) + print(" ✓ Keep-alive service started") + token_cleanup_task = asyncio.create_task( + schedule_token_cleanup(), name="token_cleanup_task" + ) + background_tasks.append(token_cleanup_task) + # bounded bookkeeping -- periodic sweepers + limiter_sweeper_task = asyncio.create_task( + schedule_limiter_sweep(), name="limiter_sweeper_task" + ) + background_tasks.append(limiter_sweeper_task) + flag_sweeper_task = asyncio.create_task(flags.run_sweeper(), name="flag_cache_sweeper_task") + background_tasks.append(flag_sweeper_task) + + except Exception as e: + logger.error(f" ✖ Failed to start Web Server: {e}", exc_info=True) + bf = _boot_failure_deadline() + await _cancel_tasks(background_tasks, timeout=_BOOT_FAILURE_CANCEL_SECONDS) + # touch buffer must flush BEFORE db.close, or _bulk_flush discards increments + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter", deadline=bf) + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer", deadline=bf) + await _safe_teardown_step(close_shortener, "shortener", deadline=bf) + await _safe_teardown_step(cleanup_clients, "clients", deadline=bf) + await _safe_teardown_step(db.close, "database", deadline=bf) + raise SystemExit(1) from e + + elapsed_time = time.monotonic() - start_time + print("╠═══════════════════════════════════════════════════════════╣") + print(f" ▶ Bot Name: {bot_info.first_name}") + print(f" ▶ Username: @{bot_info.username}") + print(f" ▶ Server: {bind_address}:{Var.PORT}") + print(f" ▶ Startup Time: {elapsed_time:.2f} seconds") + print("╚═══════════════════════════════════════════════════════════╝") + print(" ▶ Bot is now running! Press CTRL+C to stop.") + + try: + await idle() + finally: + # ordered teardown with a bounded drain + error aggregation + await shutdown_services(background_tasks, app_runner) + + +async def _cancel_tasks(tasks: list[asyncio.Task], timeout: float) -> None: + """Cancel + bounded wait; re-cancel stragglers so teardown always runs. + + ``asyncio.wait`` alone returns still-running tasks in ``pending`` -- without + the re-cancel a cancellation-ignoring task would skip every step below. + """ + for t in tasks: + t.cancel() + if tasks: + _done, pending = await asyncio.wait(tasks, timeout=timeout) + for t in pending: + t.cancel() + logger.warning(f"Background task {t.get_name()} ignored cancellation; re-cancelled") + + +# Shutdown has to finish inside the container's stop grace period: SIGKILL +# mid-teardown skips the touch flush. The graceful steps share ONE budget +# instead of each taking its own generous timeout, but every teardown step +# still gets a floor: a spent budget must not turn db.close() into a 0 s +# timeout, which would record a failure that never happened and exit non-zero +# on a perfectly healthy stop. +# +# The bound is _SHUTDOWN_BUDGET_SECONDS + steps * _TEARDOWN_FLOOR_SECONDS, and +# it must fit the SMALLEST grace period we actually ship for, not compose's +# comfortable one: Heroku, Render and Kubernetes all SIGKILL at 30s by default +# and neither app.json nor render.yaml can raise it. A 40s budget x 2s floors +# gave a 50s worst case that could not fit any of them, so a stop could be +# killed before the touch flush ran -- the exact outcome this budget exists to +# prevent. The assert below makes that structural: retune the constants and the +# import fails rather than the deploy. +# +# docker-compose still gets 60s (stop_grace_period), which is comfortable. +_MIN_PAAS_GRACE_SECONDS = 30.0 +# The shared budget covers the graceful path's phases 1-3 (background-task wait, +# app_runner.cleanup, stream drain) AND the four cheap teardown steps. It used to +# be 20s and to cover the clients step too, which made the shared budget the +# binding constraint there: `_safe_teardown_step` grants +# min(ceiling, budget_left), so a 10s-per-client ceiling could never exceed the +# remaining budget no matter how it was scaled. Two clients needing 10s each +# consumed the pool four other steps also draw from and were cut anyway -- +# recorded as an error, exit 1, restart loop. The clients step now has its own +# slice, sized below, and is NOT charged to this budget. +_SHUTDOWN_BUDGET_SECONDS = 8.0 +_TEARDOWN_STEP_SECONDS = 8.0 +_TEARDOWN_FLOOR_SECONDS = 1.5 +# Steps in _teardown_plan that draw on the shared budget. The clients step is +# deliberately not in this count; it has its own slice. +_TEARDOWN_STEP_COUNT = 4 + +# cleanup_clients stops each client individually with a 10s timeout, so a naive +# per-client ceiling would scale with the client count. It CANNOT here: two +# clients would need 20s, and the shared budget plus the floors need the rest of +# the 30s. An earlier version computed min(12, n * 10), which made the scaling +# silently decorative for every n >= 2 while the code and its tests still +# claimed it scaled -- the ceiling always won. +# +# So this is a FLAT slice, sized for a healthy stop plus one stuck client. A +# healthy stop spends ~0.1s per client, so 12s covers far more clients than any +# deployment runs; it is approached only when clients are genuinely stuck, and +# being cut there is a real failure worth reporting rather than a spurious one. +_CLIENT_STOP_SECONDS = 12.0 + +# Floors are granted even after the budget is spent, so the worst case is the +# budget plus one floor for EVERY step that can be granted one -- not just the +# plan's shared steps. app_runner.cleanup() is budgeted through _step_budget, +# which follows the same max(..., FLOOR) rule, and it runs OUTSIDE +# _teardown_plan, so counting only the plan's steps under-counted the bound by +# a full floor. +_PRE_PLAN_FLOOR_STEPS = 1 # app_runner.cleanup() +_SHUTDOWN_WORST_CASE_SECONDS = ( + _SHUTDOWN_BUDGET_SECONDS + + (_TEARDOWN_STEP_COUNT + _PRE_PLAN_FLOOR_STEPS) * _TEARDOWN_FLOOR_SECONDS + + _CLIENT_STOP_SECONDS +) + +assert _SHUTDOWN_WORST_CASE_SECONDS < _MIN_PAAS_GRACE_SECONDS, ( + f"shutdown can take {_SHUTDOWN_WORST_CASE_SECONDS:.1f}s but the PaaS manifests " + f"only allow {_MIN_PAAS_GRACE_SECONDS:.0f}s -- a stop would be SIGKILLed before " + "the touch flush and db.close() run" +) + +# The boot-failure handlers are bounded too. They used to call +# _safe_teardown_step with no deadline, which made every step take its FULL +# ceiling, behind a _cancel_tasks allowed 30s on two of the paths -- an 82s +# worst case against a 30s grace, with the import assert above blind to it +# because it only does arithmetic over constants and never looks at a call site. +# +# They share the budget and the floors, so their worst case is +# cancel + budget + (steps * floor). They do NOT get the clients slice: a boot +# failure already exits non-zero, so spending 12s on client shutdown buys +# nothing, and being cut there costs no more than the exit already does. +_BOOT_FAILURE_CANCEL_SECONDS = 5.0 +_BOOT_FAILURE_STEP_COUNT = 6 # the largest of the four paths +_BOOT_FAILURE_WORST_CASE_SECONDS = ( + _BOOT_FAILURE_CANCEL_SECONDS + + _SHUTDOWN_BUDGET_SECONDS + + _BOOT_FAILURE_STEP_COUNT * _TEARDOWN_FLOOR_SECONDS +) + +assert _BOOT_FAILURE_WORST_CASE_SECONDS < _MIN_PAAS_GRACE_SECONDS, ( + f"a failed boot can take {_BOOT_FAILURE_WORST_CASE_SECONDS:.1f}s to unwind but " + f"the PaaS manifests only allow {_MIN_PAAS_GRACE_SECONDS:.0f}s -- the touch " + "flush and db.close() would be SIGKILLed" +) + + +def _budget_left(deadline: float) -> float: + """Seconds remaining in the shared shutdown budget, floored at 0.""" + return max(deadline - asyncio.get_running_loop().time(), 0.0) + + +def _boot_failure_deadline() -> float: + """Deadline for a boot-failure unwind. + + These handlers used to pass no deadline at all, which made _safe_teardown_step + grant every step its FULL ceiling -- see _BOOT_FAILURE_WORST_CASE_SECONDS. + """ + return asyncio.get_running_loop().time() + _SHUTDOWN_BUDGET_SECONDS + + +def _step_budget(deadline: float) -> float: + """Timeout for one shutdown step: what is left of the budget, capped per + step but never below the floor.""" + return min(_TEARDOWN_STEP_SECONDS, max(_budget_left(deadline), _TEARDOWN_FLOOR_SECONDS)) + + +class _Step(NamedTuple): + """One teardown step and how it is budgeted. + + ``shared=False`` means this step does NOT draw on the common budget and gets + its full ``cap`` instead. That is the whole point for cleanup_clients: with + ``shared=True`` the grant is min(cap, budget_left), so the common budget + silently becomes the binding constraint and scaling ``cap`` achieves nothing. + """ + + name: str + run: object + cap: float | None = None + shared: bool = True + + +def _teardown_plan() -> list[_Step]: + """The ordered teardown steps, with the per-step ceiling for each. + + Declarative so the ordering AND each step's budgeting are inspectable: a + test that only watched the clock could not tell a correct cap from one that + was never wired up (the scaled cap being far larger than any test's + patience) -- and could not see that a correctly-scaled cap is inert while the + shared budget overrides it. + Order matters: the touch flush must precede db.close(). + """ + return [ + _Step("rate limiter", rate_limiter.shutdown), + _Step("touch buffer", drain_background_touch_tasks), + _Step("shortener", close_shortener), + # 10s per client is its own internal timeout; this flat slice is sized + # for a healthy stop plus one stuck client. Charging it to the shared + # budget is what made the old "scaled" ceiling inert. + _Step("clients", cleanup_clients, _CLIENT_STOP_SECONDS, shared=False), + _Step("database", db.close), + ] + + +async def _safe_teardown_step( + step, + name: str, + errors: list | None = None, + deadline: float | None = None, + *, + cap: float | None = None, + shared: bool = True, +): + """Run one teardown step on its share of the budget. + + ``cap`` overrides the per-step ceiling for a step whose own work scales with + something the budget cannot know -- cleanup_clients stops every client + individually, so a fixed 8s cut a perfectly healthy multi-client stop and + turned it into a recorded failure. + + ``shared=False`` exempts the step from the common budget entirely, which is + what actually makes a scaled ``cap`` mean anything. + """ + ceiling = _TEARDOWN_STEP_SECONDS if cap is None else cap + if deadline is None or not shared: + budget = ceiling + else: + budget = min(ceiling, max(_budget_left(deadline), _TEARDOWN_FLOOR_SECONDS)) + try: + await asyncio.wait_for(step(), timeout=budget) + except asyncio.CancelledError as e: + # shutdown completeness wins over cancellation propagation: record + # and continue with the remaining steps instead of aborting them + logger.warning(f"{name} cleanup cancelled: {e}") + if errors is not None: + errors.append((name, e)) + except Exception as e: + logger.error(f"Error during {name} cleanup: {e}", exc_info=True) + if errors is not None: + errors.append((name, e)) + + +async def shutdown_services(background_tasks, app_runner) -> None: + """Restart-marker-safe, bounded drain, aggregated errors.""" + print(" ▶ Shutting down services...") + errors: list = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + _SHUTDOWN_BUDGET_SECONDS + + # 1. stop accepting new work + for task in background_tasks: + if not task.done(): + task.cancel() + + # one bounded wait for the whole batch (per-task waits would stack) + if background_tasks: + done, pending = await asyncio.wait( + background_tasks, timeout=min(10.0, _budget_left(deadline)) + ) + for t in pending: + t.cancel() + logger.warning(f"Background task {t.get_name()} ignored cancellation; re-cancelled") + for t in done: + if t.cancelled(): + continue + exc = t.exception() + if exc is not None: + # worker/index failures are fatal; sweeper/keepalive/token + # blips must not force a container restart loop + if t.get_name().startswith(("request_executor", "ensure_")): + errors.append((t.get_name(), exc)) + logger.error(f"Background task {t.get_name()} failed at shutdown: {exc}") + + # 2. stop accepting new HTTP before draining in-flight streams + if app_runner is not None: + try: + await asyncio.wait_for(app_runner.cleanup(), timeout=_step_budget(deadline)) + except Exception as e: + errors.append(("web server", e)) + logger.error(f"Error during web server cleanup: {e}") + + # 3. bounded drain: wait for in-flight streams, never past the budget + drain_deadline = min(loop.time() + 30.0, deadline) + while sum(work_loads.values()) > 0 and loop.time() < drain_deadline: + await asyncio.sleep(0.25) + remaining = sum(work_loads.values()) + if remaining: + logger.warning(f"Drain deadline hit with {remaining} stream(s) still active.") + + # 4. ordered teardown + for step in _teardown_plan(): + await _safe_teardown_step( + step.run, step.name, errors, deadline, cap=step.cap, shared=step.shared + ) + if _budget_left(deadline) <= 0: + logger.warning("Shutdown budget exhausted; teardown steps ran on the floor timeout.") + if not errors: + print(" ✓ Database connection closed") + + logger.info(f"Touch buffer final state: {touch_buffer_stats()}") + + # aggregate, log everything, exit non-zero when anything failed + if errors: + logger.error( + f"Shutdown completed with {len(errors)} error(s): " + + ", ".join(name for name, _ in errors) + ) + sys.exit(1) + + +async def schedule_token_cleanup(): + while True: + try: + await asyncio.sleep(3 * 3600) + await cleanup_expired_tokens() + except asyncio.CancelledError: + logger.debug("schedule_token_cleanup cancelled cleanly.") + break + except Exception as e: + logger.error(f"Token cleanup error: {e}", exc_info=True) + + +async def schedule_limiter_sweep(): + """Prune limiter bookkeeping every 5 minutes.""" + while True: + try: + await asyncio.sleep(300) + stats = await rate_limiter.sweep() + logger.debug(f"Limiter sweep: {stats}") + except asyncio.CancelledError: + logger.debug("schedule_limiter_sweep cancelled cleanly.") + break + except Exception as e: + logger.error(f"Limiter sweep error: {e}", exc_info=True) + + +if __name__ == "__main__": + # Restrictive umask covers session keys before harden_session_files runs; + # logs/ predates it, but log content is redacted. + os.umask(0o077) + try: + asyncio.run(start_services()) + except KeyboardInterrupt: + print("╔═══════════════════════════════════════════════════════════╗") + print("║ Bot stopped by user (CTRL+C) ║") + print("╚═══════════════════════════════════════════════════════════╝") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") diff --git a/Thunder/bot/__init__.py b/Thunder/bot/__init__.py index 1e28c42b..fa38684d 100644 --- a/Thunder/bot/__init__.py +++ b/Thunder/bot/__init__.py @@ -1,6 +1,7 @@ # Thunder/bot/__init__.py from pyrogram import Client + from Thunder.vars import Var StreamBot = Client( @@ -13,5 +14,5 @@ max_concurrent_transmissions=1000, ) -multi_clients = {} -work_loads = {} +multi_clients: dict[int, Client] = {} +work_loads: dict[int, int] = {} diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py index 14cf307d..5a41b264 100644 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -1,82 +1,96 @@ -# Thunder/bot/clients.py - -import asyncio - -from pyrogram import Client -from pyrogram.errors import FloodWait - -from Thunder.bot import StreamBot, multi_clients, work_loads -from Thunder.utils.config_parser import TokenParser -from Thunder.utils.logger import logger -from Thunder.vars import Var - -async def cleanup_clients(): - for client in multi_clients.values(): - try: - try: - await client.stop() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.stop() - except Exception as e: - logger.error(f"Error stopping client: {e}", exc_info=True) - -async def initialize_clients(): - print("╠══════════════════ INITIALIZING CLIENTS ═══════════════════╣") - multi_clients[0] = StreamBot - work_loads[0] = 0 - print(" ✓ Primary client initialized") - try: - all_tokens = TokenParser().parse_from_env() - if not all_tokens: - print(" ◎ No additional clients found.") - return - except Exception as e: - logger.error(f" ✖ Error parsing additional tokens: {e}", exc_info=True) - print(" ▶ Primary client will be used.") - return - - async def start_client(client_id, token): - try: - if client_id == len(all_tokens): - await asyncio.sleep(2) - client = Client( - api_hash=Var.API_HASH, - api_id=Var.API_ID, - bot_token=token, - in_memory=True, - name=str(client_id), - no_updates=True, - max_concurrent_transmissions=1000, - sleep_threshold=Var.SLEEP_THRESHOLD - ) - try: - await client.start() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.start() - work_loads[client_id] = 0 - print(f" ◎ Client ID {client_id} started") - return client_id, client - except Exception as e: - logger.error(f" ✖ Failed to start Client ID {client_id}. Error: {e}", exc_info=True) - return None - - clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token]) - clients = [client for client in clients if client] - - multi_clients.update(dict(clients)) - - if len(multi_clients) > 1: - Var.MULTI_CLIENT = True - print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") - print(f" ◎ Total Clients: {len(multi_clients)} (Including primary client)") - - print(" ▶ Initial workload distribution:") - for client_id, load in work_loads.items(): - print(f" • Client {client_id}: {load} tasks") - - else: - print("╠═══════════════════════════════════════════════════════════╣") - print(" ▶ No additional clients were initialized") - print(" ▶ Primary client will handle all requests") +import asyncio +import glob +import os + +from pyrogram import Client + +from Thunder.bot import StreamBot, multi_clients, work_loads +from Thunder.utils.config_parser import TokenParser +from Thunder.utils.logger import logger +from Thunder.utils.safe_call import tg_call +from Thunder.vars import Var + + +def harden_session_files() -> None: + """Session files hold credentials; chmod 0600 best-effort.""" + for path in glob.glob("*.session"): + try: + os.chmod(path, 0o600) + logger.debug(f"Hardened session file permissions: {path}") + except OSError as e: + logger.warning(f"Could not chmod {path}: {e}") + + +async def cleanup_clients(): + for client in multi_clients.values(): + try: + # short per-client budget: the outer teardown bounds the total, + # and a hung stop must not eat the whole budget for the rest + await tg_call(client.stop, timeout=10.0) + except Exception as e: + logger.error(f"Error stopping client: {e}", exc_info=True) + + +async def initialize_clients(): + print("╠══════════════════ INITIALIZING CLIENTS ═══════════════════╣") + multi_clients[0] = StreamBot + work_loads[0] = 0 + print(" ✓ Primary client initialized") + try: + all_tokens = TokenParser().parse_from_env() + if not all_tokens: + print(" ◎ No additional clients found.") + return + except Exception as e: + logger.error(f" ✖ Error parsing additional tokens: {e}", exc_info=True) + print(" ▶ Primary client will be used.") + return + + async def start_client(client_id, token): + client: Client | None = None + try: + client = Client( + api_hash=Var.API_HASH, + api_id=Var.API_ID, + bot_token=token, + in_memory=True, + name=str(client_id), + no_updates=True, + max_concurrent_transmissions=1000, + sleep_threshold=Var.SLEEP_THRESHOLD, + ) + # session bootstrap may exceed the 30s RPC budget. + await tg_call(client.start, timeout=90.0) + work_loads[client_id] = 0 + print(f" ◎ Client ID {client_id} started") + return client_id, client + except Exception as e: + logger.error(f" ✖ Failed to start Client ID {client_id}. Error: {e}", exc_info=True) + # a client that connected before failing is left running and never + # stopped, so its MTProto session survives every reboot attempt + if client is not None: + try: + await tg_call(client.stop, timeout=10.0) + except Exception as stop_err: + logger.debug(f" ▶ Client ID {client_id} stop after failed start: {stop_err}") + return None + + clients = await asyncio.gather( + *[start_client(i, token) for i, token in all_tokens.items() if token] + ) + clients = [client for client in clients if client] + + multi_clients.update(dict(clients)) + + if len(multi_clients) > 1: + print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") + print(f" ◎ Total Clients: {len(multi_clients)} (Including primary client)") + + print(" ▶ Initial workload distribution:") + for client_id, load in work_loads.items(): + print(f" • Client {client_id}: {load} tasks") + + else: + print("╠═══════════════════════════════════════════════════════════╣") + print(" ▶ No additional clients were initialized") + print(" ▶ Primary client will handle all requests") diff --git a/Thunder/bot/plugins/__init__.py b/Thunder/bot/plugins/__init__.py new file mode 100644 index 00000000..9de4da14 --- /dev/null +++ b/Thunder/bot/plugins/__init__.py @@ -0,0 +1,10 @@ +"""Bot command handlers (admin / callbacks / common / stream). + +Loaded by ``Thunder.__main__.import_plugins`` through +``importlib.util.spec_from_file_location``, which needs no package marker -- +but without this file the directory is a namespace package, and coverage.py +only measures files in an importable package. The omit entry that used to +hide these modules from the report was therefore dead config: 900+ statements +of bot surface were neither tested nor measured. With this file in place they +are measured honestly. +""" diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index 82d0e729..c7aebbdc 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -1,529 +1,588 @@ -# Thunder/bot/plugins/admin.py - -import asyncio -import html -import os -import shutil -import sys -import time -from io import BytesIO - -import psutil -from pyrogram import filters -from pyrogram.client import Client -from pyrogram.enums import ParseMode -from pyrogram.errors import FloodWait, MessageNotModified -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message - -from Thunder import StartTime, __version__ -from Thunder.bot import StreamBot, multi_clients, work_loads -from Thunder.utils.bot_utils import get_user, reply -from Thunder.utils.broadcast import broadcast_message -from Thunder.utils.database import db -from Thunder.utils.human_readable import humanbytes -from Thunder.utils.logger import LOG_FILE, logger -from Thunder.utils.messages import ( - MSG_ADMIN_AUTH_LIST_HEADER, MSG_ADMIN_NO_BAN_REASON, - MSG_ADMIN_USER_BANNED, MSG_ADMIN_USER_UNBANNED, MSG_AUTHORIZE_FAILED, - MSG_AUTHORIZE_SUCCESS, MSG_AUTHORIZE_USAGE, MSG_AUTH_USER_INFO, - MSG_BAN_REASON_SUFFIX, MSG_BAN_USAGE, MSG_BROADCAST_USAGE, - MSG_BUTTON_CLOSE, MSG_CANNOT_BAN_OWNER, MSG_CHANNEL_BANNED, - MSG_CHANNEL_BANNED_REASON_SUFFIX, MSG_CHANNEL_NOT_BANNED, - MSG_CHANNEL_UNBANNED, MSG_DB_ERROR, MSG_DB_STATS, - MSG_DEAUTHORIZE_FAILED, MSG_DEAUTHORIZE_SUCCESS, - MSG_DEAUTHORIZE_USAGE, MSG_ERROR_GENERIC, MSG_INVALID_BROADCAST_CMD, - MSG_INVALID_USER_ID, MSG_LOG_FILE_CAPTION, MSG_LOG_FILE_EMPTY, - MSG_LOG_FILE_MISSING, MSG_NO_AUTH_USERS, MSG_RESTARTING, MSG_SHELL_ERROR, - MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, MSG_SHELL_OUTPUT, - MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, - MSG_SPEEDTEST_ERROR, MSG_SPEEDTEST_INIT, MSG_SPEEDTEST_RESULT, - MSG_STATUS_ERROR, MSG_SYSTEM_STATS, MSG_SYSTEM_STATUS, - MSG_UNBAN_USAGE, MSG_USER_BANNED_NOTIFICATION, - MSG_USER_NOT_IN_BAN_LIST, MSG_USER_UNBANNED_NOTIFICATION, - MSG_WORKLOAD_ITEM -) -from Thunder.utils.time_format import get_readable_time -from Thunder.utils.tokens import authorize, deauthorize, list_allowed -from Thunder.utils.speedtest import run_speedtest -from Thunder.vars import Var - +import asyncio +import contextlib +import html +import os +import time +from io import BytesIO +from pathlib import Path +from typing import Any + +import psutil +from pyrogram import filters +from pyrogram.client import Client +from pyrogram.enums import ParseMode +from pyrogram.errors import MessageNotModified +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, User + +from Thunder import StartTime, __version__ +from Thunder.bot import StreamBot, multi_clients, work_loads +from Thunder.bot.clients import cleanup_clients +from Thunder.utils.bot_utils import reply +from Thunder.utils.broadcast import broadcast_message +from Thunder.utils.database import db +from Thunder.utils.flag_cache import flags +from Thunder.utils.human_readable import humanbytes +from Thunder.utils.logger import LOG_FILE, logger, redact_secrets +from Thunder.utils.messages import ( + MSG_ADMIN_AUTH_LIST_HEADER, + MSG_ADMIN_AUTH_OWNER_FOOTER, + MSG_ADMIN_NO_BAN_REASON, + MSG_ADMIN_USER_BANNED, + MSG_ADMIN_USER_UNBANNED, + MSG_AUTH_USER_INFO, + MSG_AUTHORIZE_FAILED, + MSG_AUTHORIZE_SUCCESS, + MSG_AUTHORIZE_USAGE, + MSG_BAN_REASON_SUFFIX, + MSG_BAN_USAGE, + MSG_BROADCAST_USAGE, + MSG_BUTTON_CLOSE, + MSG_CANNOT_BAN_OWNER, + MSG_CANNOT_BAN_SELF, + MSG_CHANNEL_BANNED, + MSG_CHANNEL_BANNED_REASON_SUFFIX, + MSG_CHANNEL_NOT_BANNED, + MSG_CHANNEL_UNBANNED, + MSG_DB_ERROR, + MSG_DB_STATS, + MSG_DEAUTHORIZE_FAILED, + MSG_DEAUTHORIZE_SUCCESS, + MSG_DEAUTHORIZE_USAGE, + MSG_ERROR_GENERIC, + MSG_INVALID_BROADCAST_CMD, + MSG_INVALID_USER_ID, + MSG_LISTAUTH_PARTIAL, + MSG_LOG_FILE_CAPTION_SIZED, + MSG_LOG_FILE_EMPTY, + MSG_LOG_FILE_MISSING, + MSG_NO_AUTH_USERS, + MSG_RESTARTING, + MSG_SHELL_DISABLED, + MSG_SHELL_ERROR, + MSG_SHELL_EXECUTING, + MSG_SHELL_NO_OUTPUT, + MSG_SHELL_OUTPUT_CAPTION, + MSG_SHELL_OUTPUT_STDERR, + MSG_SHELL_OUTPUT_STDOUT, + MSG_SHELL_USAGE, + MSG_STATUS_ERROR, + MSG_SYSTEM_STATS, + MSG_SYSTEM_STATUS, + MSG_UNBAN_USAGE, + MSG_USER_BANNED_NOTIFICATION, + MSG_USER_NOT_IN_BAN_LIST, + MSG_USER_UNBANNED_NOTIFICATION, + MSG_WORKLOAD_ITEM, + TELEGRAM_MAX_UNITS, + paginate_lines, +) +from Thunder.utils.rate_limiter import rate_limiter +from Thunder.utils.safe_call import ( + delete_safe, + edit_safe, + send_safe, + tg_call, +) +from Thunder.utils.time_format import get_readable_time +from Thunder.utils.tokens import authorize, deauthorize, list_allowed +from Thunder.vars import Var + owner_filter = filters.private & filters.user(Var.OWNER_ID) -_MARKDOWN_ESCAPE_TRANS = str.maketrans({ - "\\": "\\\\", - "_": "\\_", - "*": "\\*", - "[": "\\[", - "]": "\\]", - "`": "\\`", -}) - - -def _escape_markdown(text: str) -> str: - return text.translate(_MARKDOWN_ESCAPE_TRANS) - - -@StreamBot.on_message(filters.command("users") & owner_filter) -async def get_total_users(client: Client, message: Message): - try: - total = await db.total_users_count() - await reply(message, - text=MSG_DB_STATS.format(total_users=total), - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in get_total_users: {e}", exc_info=True) - await reply(message, text=MSG_DB_ERROR) - - -@StreamBot.on_message(filters.command("broadcast") & owner_filter) -async def broadcast_handler(client: Client, message: Message): - mode = "all" - if len(message.command) > 1: - arg = message.command[1].lower().strip() - if arg in ("help", "--help", "-h"): - return await reply(message, text=MSG_BROADCAST_USAGE, parse_mode=ParseMode.MARKDOWN) - if arg == "authorized": - mode = "authorized" - elif arg == "regular": - mode = "regular" - else: - safe_arg = arg.replace("`", "'") - await reply( - message, - text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", - parse_mode=ParseMode.MARKDOWN - ) - return - - if not message.reply_to_message: - return await reply(message, text=MSG_INVALID_BROADCAST_CMD) - - await broadcast_message(client, message, mode=mode) - - -@StreamBot.on_message(filters.command("status") & owner_filter) -async def show_status(client: Client, message: Message): - try: - uptime_str = get_readable_time(int(time.time() - StartTime)) - workload_items = "" - sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) - for client_id, load_val in sorted_workloads: - workload_items += MSG_WORKLOAD_ITEM.format( - bot_name=f"🔹 Client {client_id}", load=load_val) - - total_workload = sum(work_loads.values()) - status_text_str = MSG_SYSTEM_STATUS.format( - uptime=uptime_str, active_bots=len(multi_clients), - total_workload=total_workload, workload_items=workload_items, - version=__version__) - await reply(message, - text=status_text_str, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in show_status: {e}", exc_info=True) - await reply(message, text=MSG_STATUS_ERROR) - - -@StreamBot.on_message(filters.command("stats") & owner_filter) -async def show_stats(client: Client, message: Message): - try: - sys_uptime = await asyncio.to_thread(psutil.boot_time) - sys_uptime_str = get_readable_time(int(time.time() - sys_uptime)) - bot_uptime_str = get_readable_time(int(time.time() - StartTime)) - net_io_counters = await asyncio.to_thread(psutil.net_io_counters) - cpu_percent = await asyncio.to_thread(psutil.cpu_percent, interval=0.5) - cpu_cores = await asyncio.to_thread(psutil.cpu_count, logical=False) - cpu_freq = await asyncio.to_thread(psutil.cpu_freq) - cpu_freq_ghz = f"{cpu_freq.current / 1000:.2f}" if cpu_freq else "N/A" - ram_info = await asyncio.to_thread(psutil.virtual_memory) - ram_total = humanbytes(ram_info.total) - ram_used = humanbytes(ram_info.used) - ram_free = humanbytes(ram_info.free) - - total_disk, used_disk, free_disk = await asyncio.to_thread( - shutil.disk_usage, '.') - - stats_text_val = MSG_SYSTEM_STATS.format( - sys_uptime=sys_uptime_str, - bot_uptime=bot_uptime_str, - cpu_percent=cpu_percent, - cpu_cores=cpu_cores, - cpu_freq=cpu_freq_ghz, - ram_total=ram_total, - ram_used=ram_used, - ram_free=ram_free, - disk_percent=psutil.disk_usage('.').percent, - total=humanbytes(total_disk), - used=humanbytes(used_disk), - free=humanbytes(free_disk), - upload=humanbytes(net_io_counters.bytes_sent), - download=humanbytes(net_io_counters.bytes_recv) - ) - - await reply(message, - text=stats_text_val, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in show_stats: {e}", exc_info=True) - await reply(message, text=MSG_STATUS_ERROR) - - -@StreamBot.on_message(filters.command("restart") & owner_filter) -async def restart_bot(client: Client, message: Message): - msg = await reply(message, text=MSG_RESTARTING) - await db.add_restart_message(msg.id, message.chat.id) - os.execv("/bin/bash", ["bash", "thunder.sh"]) - - -@StreamBot.on_message(filters.command("log") & owner_filter) -async def send_logs(client: Client, message: Message): - if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: - await reply( - message, - text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY) - ) - return - - try: - try: - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) - except FloodWait as e: - logger.debug(f"FloodWait in log file sending, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) - except Exception as e: - logger.error(f"Error sending log file: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("authorize") & owner_filter) -async def authorize_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply( - message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - - try: - user_id = int(message.command[1]) - success = await authorize(user_id, message.from_user.id) - await reply(message, - text=((MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_AUTHORIZE_FAILED.format(user_id=user_id)))) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in authorize_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("deauthorize") & owner_filter) -async def deauthorize_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply( - message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - - try: - user_id = int(message.command[1]) - success = await deauthorize(user_id) - await reply(message, - text=((MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id)))) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in deauthorize_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("listauth") & owner_filter) -async def list_authorized_command(client: Client, message: Message): - users = await list_allowed() - if not users: - return await reply( - message, text=MSG_NO_AUTH_USERS) - +# headroom under the 4096 cap for the markup and the markup-escaped records +_PAGE_BUDGET_UNITS = TELEGRAM_MAX_UNITS - 600 +_PAGE_SEND_DELAY_SECONDS = 0.5 + + +async def _sleep_between_pages() -> None: + """The pacing delay, behind a name. + + Tests patch THIS rather than `admin.asyncio.sleep`, which is the asyncio + module itself: patching through it replaces sleep for the whole process, + including anything the test framework awaits during teardown. + """ + await asyncio.sleep(_PAGE_SEND_DELAY_SECONDS) + + +# /log tail cap +_LOG_TAIL_BYTES = 5 * 1024 * 1024 + + +def _invalidate_gates() -> None: + """Flush flag cache so admin changes apply immediately.""" + flags.clear() + + +@StreamBot.on_message(filters.command("users") & owner_filter) +async def get_total_users(client: Client, message: Message): + try: + total = await db.total_users_count() + await reply( + message, + text=MSG_DB_STATS.format(total_users=total), + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) + except Exception as e: + logger.error(f"Error in get_total_users: {e}", exc_info=True) + await reply(message, text=MSG_DB_ERROR) + + +@StreamBot.on_message(filters.command("broadcast") & owner_filter) +async def broadcast_handler(client: Client, message: Message): + mode = "all" + if len(message.command) > 1: + arg = message.command[1].lower().strip() + if arg in ("help", "--help", "-h"): + return await reply(message, text=MSG_BROADCAST_USAGE, parse_mode=ParseMode.MARKDOWN) + if arg == "authorized": + mode = "authorized" + elif arg == "regular": + mode = "regular" + else: + # code spans are literal under MARKDOWN: only neutralize backticks, + # an html.escape here would leak "<" into the span + safe_arg = arg.replace("`", "'") + await reply( + message, + text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", + parse_mode=ParseMode.MARKDOWN, + ) + return + + if not message.reply_to_message: + return await reply(message, text=MSG_INVALID_BROADCAST_CMD) + + await broadcast_message(client, message, mode=mode) + + +@StreamBot.on_message(filters.command("status") & owner_filter) +async def show_status(client: Client, message: Message): + try: + uptime_str = get_readable_time(int(time.time() - StartTime)) + workload_items = "" + sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) + for client_id, load_val in sorted_workloads: + workload_items += MSG_WORKLOAD_ITEM.format( + bot_name=f"🔹 Client {client_id}", load=load_val + ) + + total_workload = sum(work_loads.values()) + bot_username = getattr(getattr(client, "me", None), "username", None) or "?" + status_text_str = MSG_SYSTEM_STATUS.format( + uptime=uptime_str, + bot_username=bot_username, + active_bots=len(multi_clients), + total_workload=total_workload, + workload_items=workload_items, + version=__version__, + ) + await reply( + message, + text=status_text_str, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) + except Exception as e: + logger.error(f"Error in show_status: {e}", exc_info=True) + await reply(message, text=MSG_STATUS_ERROR) + + +@StreamBot.on_message(filters.command("stats") & owner_filter) +async def show_stats(client: Client, message: Message): + try: + sys_uptime = await asyncio.to_thread(psutil.boot_time) + sys_uptime_str = get_readable_time(int(time.time() - sys_uptime)) + bot_uptime_str = get_readable_time(int(time.time() - StartTime)) + net_io_counters = await asyncio.to_thread(psutil.net_io_counters) + cpu_percent = await asyncio.to_thread(psutil.cpu_percent, interval=0.5) + cpu_cores = await asyncio.to_thread(psutil.cpu_count, logical=False) + cpu_freq = await asyncio.to_thread(psutil.cpu_freq) + cpu_freq_ghz = f"{cpu_freq.current / 1000:.2f}" if cpu_freq else "N/A" + ram_info = await asyncio.to_thread(psutil.virtual_memory) + ram_total = humanbytes(ram_info.total) + ram_used = humanbytes(ram_info.used) + ram_free = humanbytes(ram_info.free) + + disk = await asyncio.to_thread(psutil.disk_usage, ".") + total_disk, used_disk, free_disk = disk.total, disk.used, disk.free + # psutil off the event loop. + disk_percent = disk.percent + + limiter_line = ( + ", ".join(f"{k}={v}" for k, v in rate_limiter.occupancy().items()) or "disabled" + ) + + stats_text_val = MSG_SYSTEM_STATS.format( + sys_uptime=sys_uptime_str, + bot_uptime=bot_uptime_str, + cpu_percent=cpu_percent, + cpu_cores=cpu_cores, + cpu_freq=cpu_freq_ghz, + ram_total=ram_total, + ram_used=ram_used, + ram_free=ram_free, + disk_percent=disk_percent, + total=humanbytes(total_disk), + used=humanbytes(used_disk), + free=humanbytes(free_disk), + upload=humanbytes(net_io_counters.bytes_sent), + download=humanbytes(net_io_counters.bytes_recv), + limiter=limiter_line, + ) + + await reply( + message, + text=stats_text_val, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) + except Exception as e: + logger.error(f"Error in show_stats: {e}", exc_info=True) + await reply(message, text=MSG_STATUS_ERROR) + + +@StreamBot.on_message(filters.command("restart") & owner_filter) +async def restart_bot(client: Client, message: Message): + msg = await reply(message, text=MSG_RESTARTING) + await db.add_restart_message(msg.id, message.chat.id) + # drain touch buffer here; execv skips finally blocks. + from Thunder.utils.canonical_files import drain_background_touch_tasks + + await drain_background_touch_tasks() + # Abbreviated teardown: execv replaces the process without running the full + # graceful shutdown, so stop the clients + DB best-effort first. + # Bounded: a hung RPC must never wedge the restart. + for step, name in ((cleanup_clients, "clients"), (db.close, "database")): + try: + await asyncio.wait_for(step(), timeout=10) + except Exception as e: + logger.warning(f"Restart teardown: {name} cleanup incomplete: {e}") + # absolute path: exec'ing into thunder.sh must work from any cwd + script = Path(__file__).resolve().parents[3] / "thunder.sh" + os.execv("/bin/bash", ["bash", str(script)]) + + +@StreamBot.on_message(filters.command("log") & owner_filter) +async def send_logs(client: Client, message: Message): + if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: + await reply( + message, + text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY), + ) + return + + try: + # capped redacted tail; IO off the event loop. + def _read_redacted_tail() -> str: + with open(LOG_FILE, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - _LOG_TAIL_BYTES)) + return redact_secrets(f.read().decode("utf-8", errors="replace")) + + payload = await asyncio.to_thread(_read_redacted_tail) + + doc = BytesIO(payload.encode("utf-8")) + doc.name = "bot_redacted.txt" + caption = MSG_LOG_FILE_CAPTION_SIZED.format( + tailed=humanbytes(len(doc.getvalue())), + total=humanbytes(os.path.getsize(LOG_FILE)), + ) + await tg_call(message.reply_document, doc, caption=caption, retries=1) + except Exception as e: + logger.error(f"Error sending log file: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("authorize") & owner_filter) +async def authorize_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply(message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + + try: + user_id = int(message.command[1]) + success = await authorize(user_id, message.from_user.id) + if success: + _invalidate_gates() + await reply( + message, + text=( + MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) + if success + else MSG_AUTHORIZE_FAILED.format(user_id=user_id) + ), + ) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in authorize_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("deauthorize") & owner_filter) +async def deauthorize_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply(message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + + try: + user_id = int(message.command[1]) + success = await deauthorize(user_id) + if success: + _invalidate_gates() + await reply( + message, + text=( + MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) + if success + else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id) + ), + ) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in deauthorize_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("listauth") & owner_filter) +async def list_authorized_command(client: Client, message: Message): + users = await list_allowed() + if not users: + return await reply(message, text=MSG_NO_AUTH_USERS) + + # escape display names; batched get_users avoids N+1 FloodWaits. + id_to_user: dict[int, Any] = {} + try: + all_ids = [u["user_id"] for u in users] + tg_users_all: list = [] + for i in range(0, len(all_ids), 200): + chunk = all_ids[i : i + 200] + tg_users = await tg_call(client.get_users, chunk, retries=1) + if isinstance(tg_users, User): + tg_users = [tg_users] + tg_users_all.extend(tg_users or []) + id_to_user = {u.id: u for u in tg_users_all if u} + except Exception: + logger.error("Failed to batch-fetch tg_users for /listauth", exc_info=True) + text = MSG_ADMIN_AUTH_LIST_HEADER for i, user in enumerate(users, 1): display_name = "Unknown" - try: - tg_user = await get_user(client, user['user_id']) - if tg_user is not None: - raw_display_name = f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" - display_name = _escape_markdown(raw_display_name) - except Exception: - logger.error("Failed to fetch tg_user for user_id=%s", user['user_id'], exc_info=True) + tg_user = id_to_user.get(user["user_id"]) + if tg_user is not None: + raw_display_name = ( + f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" + ) + display_name = html.escape(raw_display_name) text += MSG_AUTH_USER_INFO.format( i=i, display_name=display_name, - user_id=user['user_id'], - authorized_by=user['authorized_by'], - auth_time=user['authorized_at'] + user_id=user["user_id"], + # these come from the authorization records, not from Telegram: + # escape anyway, they render inside an HTML message (M7) + authorized_by=html.escape(str(user["authorized_by"])), + auth_time=html.escape(str(user["authorized_at"])), ) - - await reply(message, - text=text, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - - -@StreamBot.on_message(filters.command("ban") & owner_filter) -async def ban_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply(message, text=MSG_BAN_USAGE) - - try: - target_id = int(message.command[1]) - reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON - banned_by_id = message.from_user.id if message.from_user else None - - if target_id == Var.OWNER_ID: - return await reply(message, text=MSG_CANNOT_BAN_OWNER) - - if target_id < 0: - await db.add_banned_channel( - channel_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_CHANNEL_BANNED.format(channel_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.leave_chat(target_id) - except FloodWait as e: - logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.leave_chat(target_id) - except Exception as e: - logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) - else: - await db.add_banned_user( - user_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_BAN_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) - - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in ban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("unban") & owner_filter) -async def unban_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply(message, text=MSG_UNBAN_USAGE) - - try: - target_id = int(message.command[1]) - - if target_id < 0: - if await db.remove_banned_channel(channel_id=target_id): - await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) - else: - await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) - else: - if await db.remove_banned_user(user_id=target_id): - await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) - try: - try: - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) - else: - await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in unban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("shell") & owner_filter) -async def run_shell_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply( - message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) - - command = " ".join(message.command[1:]) - status_msg = await reply(message, - text=MSG_SHELL_EXECUTING.format( - command=html.escape(command)), - parse_mode=ParseMode.HTML) - - try: - process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - output = "" - if stdout: - output += MSG_SHELL_OUTPUT_STDOUT.format( - output=html.escape(stdout.decode(errors='ignore'))) - if stderr: - output += MSG_SHELL_OUTPUT_STDERR.format( - error=html.escape(stderr.decode(errors='ignore'))) - - output = output.strip() or MSG_SHELL_NO_OUTPUT - - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - - if len(output) > 4096: - file = BytesIO(output.encode()) - file.name = "shell_output.txt" - try: - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - except FloodWait as e: - logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - else: - await reply(message, text=output, parse_mode=ParseMode.HTML) - - except Exception as e: - try: - try: - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except FloodWait as e: - logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except MessageNotModified: - pass - except Exception: - await reply( - message, - text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - - -@StreamBot.on_message(filters.command("speedtest") & owner_filter) -async def speedtest_command(client: Client, message: Message): - status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) - try: - result_dict, image_url = await run_speedtest() - if result_dict is None: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - return - - result_text = _format_speedtest_result(result_dict) - await _send_result(message, status_msg, result_text, image_url) - except Exception as e: - logger.error(f"Error in speedtest_command: {e}", exc_info=True) - try: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - except Exception: - await reply(message, text=MSG_SPEEDTEST_ERROR) - - -def _format_speedtest_result(result_dict: dict) -> str: - s, c = result_dict['server'], result_dict['client'] - return MSG_SPEEDTEST_RESULT.format( - download_mbps=_fmt(result_dict['download_mbps']), - upload_mbps=_fmt(result_dict['upload_mbps']), - download_bps=humanbytes(result_dict['download_bps']), - upload_bps=humanbytes(result_dict['upload_bps']), - ping=_fmt(result_dict['ping']), - timestamp=result_dict['timestamp'], - bytes_sent=humanbytes(result_dict['bytes_sent']), - bytes_received=humanbytes(result_dict['bytes_received']), - server_name=s['name'], - server_country=f"{s['country']} ({s['cc']})", - server_sponsor=s['sponsor'], - server_latency=_fmt(s['latency']), - server_lat=_fmt(s['lat'], 4), - server_lon=_fmt(s['lon'], 4), - client_ip=c['ip'], - client_lat=_fmt(c['lat'], 4), - client_lon=_fmt(c['lon'], 4), - client_isp=c['isp'], - client_isprating=c['isprating'], - client_country=c['country'] - ) - - -async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): - if image_url: - try: - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - else: - try: - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except MessageNotModified: - pass - - -def _fmt(value, decimals: int = 2) -> str: - return f"{float(value):.{decimals}f}" + + text += MSG_ADMIN_AUTH_OWNER_FOOTER.format(owner_id=Var.OWNER_ID) + + # long auth lists exceed Telegram's 4096 cap: page on line breaks, measured + # in UTF-16 units because that is what the cap counts + pages = paginate_lines(text, _PAGE_BUDGET_UNITS) + failed_pages = 0 + for idx, page in enumerate(pages): + try: + await reply( + message, + text=page, + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) + except Exception as e: + # continue, not break: each page is independently sized, so one + # rejected page says nothing about the next. `break` here silently + # truncated the list on an audit command and the owner had no way + # to tell the list had ended early. + logger.error(f"Failed to send /listauth page: {e}", exc_info=True) + failed_pages += 1 + # pace only *between* pages: a burst of replies is what trips FloodWait + if idx + 1 < len(pages): + await _sleep_between_pages() + + if failed_pages: + await reply( + message, + text=MSG_LISTAUTH_PARTIAL.format(failed_pages=failed_pages, total_pages=len(pages)), + parse_mode=ParseMode.HTML, + ) + + +@StreamBot.on_message(filters.command("ban") & owner_filter) +async def ban_command(client: Client, message: Message): + if len(message.command) < 2: + return await reply(message, text=MSG_BAN_USAGE) + + try: + target_id = int(message.command[1]) + # M7: store raw reason; escape at HTML render. + has_reason = len(message.command) > 2 + reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON + banned_by_id = message.from_user.id if message.from_user else None + + if target_id == Var.OWNER_ID: + return await reply(message, text=MSG_CANNOT_BAN_OWNER) + + if message.from_user and target_id == message.from_user.id: + return await reply(message, text=MSG_CANNOT_BAN_SELF) + + if target_id < 0: + await db.add_banned_channel(channel_id=target_id, reason=reason, banned_by=banned_by_id) + _invalidate_gates() + text = MSG_CHANNEL_BANNED.format(channel_id=target_id) + if has_reason: + text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=html.escape(reason)) + await reply(message, text=text, parse_mode=ParseMode.HTML) + try: + await tg_call(client.leave_chat, target_id, retries=1) + except Exception as e: + logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) + else: + await db.add_banned_user(user_id=target_id, reason=reason, banned_by=banned_by_id) + _invalidate_gates() + text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) + if has_reason: + text += MSG_BAN_REASON_SUFFIX.format(reason=html.escape(reason)) + await reply(message, text=text, parse_mode=ParseMode.HTML) + try: + await send_safe(client, target_id, text=MSG_USER_BANNED_NOTIFICATION) + except Exception as e: + logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) + + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in ban_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("unban") & owner_filter) +async def unban_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply(message, text=MSG_UNBAN_USAGE) + + try: + target_id = int(message.command[1]) + + if target_id < 0: + if await db.remove_banned_channel(channel_id=target_id): + _invalidate_gates() + await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) + else: + await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) + else: + if await db.remove_banned_user(user_id=target_id): + _invalidate_gates() + await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) + try: + await send_safe(client, target_id, text=MSG_USER_UNBANNED_NOTIFICATION) + except Exception as e: + logger.warning( + f"Could not notify unbanned user {target_id}: {e}", exc_info=True + ) + else: + await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in unban_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("shell") & owner_filter) +async def run_shell_command(client: Client, message: Message): + # /shell is opt-in. + if not Var.ENABLE_SHELL: + return await reply(message, text=MSG_SHELL_DISABLED, parse_mode=ParseMode.HTML) + + if len(message.command) < 2: + return await reply(message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) + + command = " ".join(message.command[1:]) + # log who ran what. + logger.info( + f"/shell invoked by {message.from_user.id if message.from_user else 'unknown'}: {command}" + ) + + status_msg = await reply( + message, + text=MSG_SHELL_EXECUTING.format(command=html.escape(command)), + parse_mode=ParseMode.HTML, + ) + + try: + process = await asyncio.create_subprocess_shell( + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) + except TimeoutError: + with contextlib.suppress(ProcessLookupError): + process.kill() + await process.communicate() + raise TimeoutError("shell command exceeded 60s") from None + + output = "" + if stdout: + output += MSG_SHELL_OUTPUT_STDOUT.format( + output=html.escape(stdout.decode(errors="ignore")) + ) + if stderr: + output += MSG_SHELL_OUTPUT_STDERR.format( + error=html.escape(stderr.decode(errors="ignore")) + ) + + output = output.strip() or MSG_SHELL_NO_OUTPUT + + try: + await delete_safe(status_msg) + except Exception: + pass + + if len(output) > 4096: + file = BytesIO(output.encode()) + file.name = "shell_output.txt" + await tg_call( + message.reply_document, + file, + caption=MSG_SHELL_OUTPUT_CAPTION.format(command=html.escape(command)), + retries=1, + ) + else: + await reply(message, text=output, parse_mode=ParseMode.HTML) + + except Exception as e: + try: + await edit_safe( + status_msg, + MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML, + ) + except MessageNotModified: + pass + except Exception: + await reply( + message, + text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML, + ) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index ab6d71a1..73c87320 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -1,223 +1,186 @@ -# Thunder/bot/plugins/callbacks.py - -import asyncio - -from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden -from pyrogram.types import (CallbackQuery, InlineKeyboardButton, - InlineKeyboardMarkup) - -from Thunder.bot import StreamBot -from Thunder.utils.broadcast import broadcast_ids -from Thunder.utils.decorators import owner_only -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BROADCAST_CANCEL, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, - MSG_BUTTON_GET_HELP, MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, - MSG_ERROR_BROADCAST_INSTRUCTION, MSG_ERROR_BROADCAST_RESTART, - MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_HELP -) -from Thunder.vars import Var - -async def get_force_channel_button(client: Client): - if not Var.FORCE_CHANNEL_ID: - return None - try: - try: - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) - except FloodWait as e: - await asyncio.sleep(e.value) - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) - if chat: - invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None) - if invite_link: - return [InlineKeyboardButton( - MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), - url=invite_link - )] - except Exception as e: - logger.error(f"Error getting force channel button: {e}", exc_info=True) - return None - -@StreamBot.on_callback_query(filters.regex(r"^help_command$")) -async def help_callback(client: Client, callback_query: CallbackQuery): - try: - await callback_query.answer() - buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - force_button = await get_force_channel_button(client) - if force_button: - buttons.append(force_button) - buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass - except Exception as e: - logger.error(f"Error in help callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^about_command$")) -async def about_callback(client: Client, callback_query: CallbackQuery): - try: - await callback_query.answer() - buttons = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [ - InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] - ] - try: - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass - except Exception as e: - logger.error(f"Error in about callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$")) -async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery): - if not await owner_only(client, callback_query): - return - try: - try: - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - buttons = [ - [ - InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] - ] - try: - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except Exception as e: - logger.error(f"Error in restart broadcast callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^close_panel$")) -async def close_panel_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer() - try: - try: - await callback_query.message.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete callback query message due to permissions. Message ID: {callback_query.message.id}") - except Exception as e: - logger.error(f"Error deleting callback query message: {e}", exc_info=True) - - if callback_query.message.reply_to_message: - try: - reply_msg = callback_query.message.reply_to_message - try: - await reply_msg.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await reply_msg.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete replied message due to permissions. Message ID: {reply_msg.id}") - except Exception as e: - logger.error(f"Error deleting replied message: {e}", exc_info=True) - except Exception as e: - logger.error(f"General error in close panel callback: {e}", exc_info=True) - -@StreamBot.on_callback_query(filters.regex(r"^cancel_")) -async def cancel_broadcast(client: Client, callback_query: CallbackQuery): - try: - broadcast_id = callback_query.data.split("_")[1] - if broadcast_id in broadcast_ids: - broadcast_ids[broadcast_id]["cancelled"] = True - try: - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - else: - try: - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except Exception as e: - logger.error(f"Error in cancel broadcast callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query() -async def fallback_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except Exception as e: - logger.error(f"Error in fallback callback: {e}", exc_info=True) +import functools +import secrets + +from pyrogram import Client, enums, filters +from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup + +from Thunder.bot import StreamBot +from Thunder.utils.broadcast import broadcast_ids +from Thunder.utils.commands import build_help_text +from Thunder.utils.decorators import owner_only +from Thunder.utils.force_channel import get_force_info +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_ABOUT, + MSG_BROADCAST_CANCEL, + MSG_BUTTON_ABOUT, + MSG_BUTTON_CLOSE, + MSG_BUTTON_GET_HELP, + MSG_BUTTON_GITHUB, + MSG_BUTTON_JOIN_CHANNEL, + MSG_ERROR_CALLBACK_UNSUPPORTED, + MSG_ERROR_CLOSE_NOT_ALLOWED, + MSG_ERROR_UNEXPECTED, +) +from Thunder.utils.safe_call import answer_safe, delete_safe, edit_safe +from Thunder.vars import Var + + +def guard_callback(fn): + """Panic isolation for callbacks; answers query so no stale spinner.""" + + @functools.wraps(fn) + async def wrapper(client: Client, callback_query: CallbackQuery): + try: + return await fn(client, callback_query) + except Exception as e: + error_id = secrets.token_hex(6) + logger.error(f"Callback error {error_id} in {fn.__name__}: {e}", exc_info=True) + try: + await answer_safe(callback_query, MSG_ERROR_UNEXPECTED, show_alert=True) + except Exception: + pass + try: + from Thunder.utils.bot_utils import notify_own + from Thunder.utils.messages import MSG_CRITICAL_ERROR + + await notify_own( + client, + MSG_CRITICAL_ERROR.format( + error=f"callback:{fn.__name__}: {e}", error_id=error_id + ), + ) + except Exception: + logger.debug("Owner notification for callback failure also failed", exc_info=True) + + return wrapper + + +async def get_force_channel_button(client: Client): + if not Var.FORCE_CHANNEL_ID: + return None + try: + link, title = await get_force_info(client) + if link: + return [ + InlineKeyboardButton( + MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title or "Channel"), + url=link, + ) + ] + except Exception as e: + logger.error(f"Error getting force channel button: {e}", exc_info=True) + return None + + +@StreamBot.on_callback_query(filters.regex(r"^help_command$")) +@guard_callback +async def help_callback(client: Client, callback_query: CallbackQuery): + # Intentionally ungated: these panels expose static help text, no link + # generation and no token use, and the gate chain has to run against the + # *clicker* (callback_query.message belongs to the bot) -- doing that would + # need a Message shim that silently swallows each gate's denial reply. The + # panel itself is only ever sent after /help cleared GATES_START. + await answer_safe(callback_query) + buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] + force_button = await get_force_channel_button(client) + if force_button: + buttons.append(force_button) + buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) + help_text = build_help_text(Var.MAX_BATCH_FILES) + try: + await edit_safe( + callback_query.message, + help_text, + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] + disable_web_page_preview=True, + ) + except Exception as e: + logger.debug(f"Could not edit help panel: {e}") + + +@StreamBot.on_callback_query(filters.regex(r"^about_command$")) +@guard_callback +async def about_callback(client: Client, callback_query: CallbackQuery): + await answer_safe(callback_query) + buttons = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], + ] + try: + await edit_safe( + callback_query.message, + MSG_ABOUT, + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] + disable_web_page_preview=True, + ) + except Exception as e: + logger.debug(f"Could not edit about panel: {e}") + + +@StreamBot.on_callback_query(filters.regex(r"^close_panel$")) +@guard_callback +async def close_panel_callback(client: Client, callback_query: CallbackQuery): + closer_id = callback_query.from_user.id if callback_query.from_user else None + message = callback_query.message + + is_allowed = False + if closer_id == Var.OWNER_ID: + is_allowed = True + elif closer_id is not None and message is not None: + if message.reply_to_message and message.reply_to_message.from_user: + is_allowed = closer_id == message.reply_to_message.from_user.id + if not is_allowed and message.chat and message.chat.type == enums.ChatType.PRIVATE: + is_allowed = closer_id == message.chat.id + + if not is_allowed: + await answer_safe(callback_query, MSG_ERROR_CLOSE_NOT_ALLOWED, show_alert=True) + return + + await answer_safe(callback_query) + if message: + try: + await delete_safe(message) + except Exception as e: + logger.debug( + f"Failed to delete callback query message {getattr(message, 'id', '?')}: {e}" + ) + + +@StreamBot.on_callback_query(filters.regex(r"^cancel_")) +@guard_callback +async def cancel_broadcast(client: Client, callback_query: CallbackQuery): + if not await owner_only(client, callback_query): + return + raw = callback_query.data or "" + if isinstance(raw, bytes): + # stub-typed str|bytes|None; decode defensively, never crash the panel + raw = raw.decode("utf-8", errors="replace") + parts = raw.split("_", 1) + broadcast_id = parts[1] if len(parts) > 1 else "" + entry = broadcast_ids.get(broadcast_id) + if entry is not None: + entry["cancelled"] = True + # answer on both paths: the success branch only edited the message, so + # the client's button kept spinning until Telegram timed it out + await answer_safe(callback_query) + try: + await edit_safe( + callback_query.message, MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) + ) + except Exception as e: + logger.debug(f"Could not edit cancel panel: {e}") + else: + await answer_safe( + callback_query, MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), show_alert=True + ) + + +@StreamBot.on_callback_query() +@guard_callback +async def fallback_callback(client: Client, callback_query: CallbackQuery): + """Catch-all for unknown/stale buttons.""" + await answer_safe(callback_query, MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 2b6344dd..88a1bc91 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -1,280 +1,312 @@ -# Thunder/bot/plugins/common.py - -import asyncio -import time -from datetime import datetime, timedelta - -from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified -from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, - Message, User) - -from Thunder.bot import StreamBot -from Thunder.utils.bot_utils import (gen_dc_txt, get_user, log_newusr, - reply_user_err) -from Thunder.utils.database import db -from Thunder.utils.decorators import check_banned -from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid -from Thunder.utils.force_channel import force_channel_check, get_force_info -from Thunder.utils.human_readable import humanbytes -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, MSG_BUTTON_GET_HELP, - MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, MSG_BUTTON_VIEW_PROFILE, - MSG_COMMUNITY_CHANNEL, MSG_DC_ANON_ERROR, MSG_DC_FILE_ERROR, - MSG_DC_FILE_INFO, MSG_DC_INVALID_USAGE, MSG_DC_UNKNOWN, - MSG_ERROR_USER_INFO, MSG_FILE_TYPE_ANIMATION, MSG_FILE_TYPE_AUDIO, - MSG_FILE_TYPE_DOCUMENT, MSG_FILE_TYPE_PHOTO, MSG_FILE_TYPE_STICKER, - MSG_FILE_TYPE_UNKNOWN, MSG_FILE_TYPE_VIDEO, MSG_FILE_TYPE_VIDEO_NOTE, - MSG_FILE_TYPE_VOICE, MSG_HELP, MSG_PING_RESPONSE, MSG_PING_START, - MSG_TOKEN_ACTIVATED, MSG_TOKEN_FAILED, MSG_TOKEN_INVALID, MSG_WELCOME -) -from Thunder.vars import Var - -@StreamBot.on_message(filters.command("start") & filters.private) -async def start_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - user = msg.from_user - if user: - await log_newusr(bot, user.id, user.first_name) - - if len(msg.command) == 2: - payload = msg.command[1] - - if payload == "start": - pass - else: - token = await db.token_col.find_one({"token": payload}) - if token: - if token["user_id"] != user.id: - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - - if token.get("activated"): - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - - now = datetime.utcnow() - exp = now + timedelta(hours=Var.TOKEN_TTL_HOURS) - - await db.token_col.update_one( - {"token": payload, "user_id": user.id}, - {"$set": {"activated": True, "created_at": now, "expires_at": exp}} - ) - - hrs = round((exp - now).total_seconds() / 3600, 1) - try: - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - else: - try: - return await msg.reply_text(text=MSG_TOKEN_INVALID) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_INVALID) - - txt = MSG_WELCOME.format(user_name=user.first_name if user else "Unknown") - link, title = await get_force_info(bot) - if link: - txt += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=title)}" - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -@StreamBot.on_message(filters.command("help") & filters.private) -async def help_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if msg.from_user: - await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - - txt = MSG_HELP.format(max_files=Var.MAX_BATCH_FILES) - btns = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - - link, title = await get_force_info(bot) - if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - - btns.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -@StreamBot.on_message(filters.command("about") & filters.private) -async def about_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if msg.from_user: - await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - try: - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) - -async def send_user_dc(msg: Message, user: User): - txt = await gen_dc_txt(user) - url = f"https://t.me/{user.username}" if user.username else f"tg://user?id={user.id}" - btns = [ - [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], - [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -async def send_file_dc(msg: Message, file_msg: Message): - try: - fname = get_fname(file_msg) or "Untitled File" - fsize = humanbytes(get_fsize(file_msg)) - - type_map = { - "document": MSG_FILE_TYPE_DOCUMENT, - "photo": MSG_FILE_TYPE_PHOTO, - "video": MSG_FILE_TYPE_VIDEO, - "audio": MSG_FILE_TYPE_AUDIO, - "voice": MSG_FILE_TYPE_VOICE, - "sticker": MSG_FILE_TYPE_STICKER, - "animation": MSG_FILE_TYPE_ANIMATION, - "video_note": MSG_FILE_TYPE_VIDEO_NOTE - } - - file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") - type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) - - dc_id = MSG_DC_UNKNOWN - fid = parse_fid(file_msg) - if fid: - dc_id = fid.dc_id - - txt = MSG_DC_FILE_INFO.format( - file_name=fname, - file_size=fsize, - file_type=type_display, - dc_id=dc_id - ) - - btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - - except Exception as e: - logger.error(f"File DC error: {e}", exc_info=True) - await reply_user_err(msg, MSG_DC_FILE_ERROR) - -@StreamBot.on_message(filters.command("dc")) -async def dc_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if not await force_channel_check(bot, msg): - return - if not msg.from_user and not msg.reply_to_message: - return await reply_user_err(msg, MSG_DC_ANON_ERROR) - - args = msg.text.strip().split(maxsplit=1) - if len(args) > 1: - user = await get_user(bot, args[1].strip()) - if user: - await send_user_dc(msg, user) - else: - await reply_user_err(msg, MSG_ERROR_USER_INFO) - return - - if msg.reply_to_message: - ref = msg.reply_to_message - if ref.media: - await send_file_dc(msg, ref) - elif ref.from_user: - await send_user_dc(msg, ref.from_user) - else: - await reply_user_err(msg, MSG_DC_INVALID_USAGE) - return - - if msg.from_user: - await send_user_dc(msg, msg.from_user) - else: - await reply_user_err(msg, MSG_DC_ANON_ERROR) - -@StreamBot.on_message(filters.command("ping") & filters.private) -async def ping_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if not await force_channel_check(bot, msg): - return - start = time.time() - try: - sent = await msg.reply_text(text=MSG_PING_START) - except FloodWait as e: - await asyncio.sleep(e.value) - sent = await msg.reply_text(text=MSG_PING_START) - end = time.time() - ms = (end - start) * 1000 - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - try: - await sent.edit_text( - MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await sent.edit_text( - MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True - ) - except MessageNotModified: - pass +import html +import time + +from pyrogram import Client, filters +from pyrogram.enums import ParseMode +from pyrogram.errors import MessageNotModified +from pyrogram.types import ( + InlineKeyboardButton, + InlineKeyboardButtonBuy, + InlineKeyboardMarkup, + Message, + User, +) + +from Thunder.bot import StreamBot +from Thunder.utils.bot_utils import gen_dc_txt, get_user, log_newusr, reply_user_err +from Thunder.utils.commands import build_help_text +from Thunder.utils.decorators import GATES_START, preflight +from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid +from Thunder.utils.force_channel import get_force_info +from Thunder.utils.human_readable import humanbytes +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_ABOUT, + MSG_BUTTON_ABOUT, + MSG_BUTTON_CLOSE, + MSG_BUTTON_GET_HELP, + MSG_BUTTON_GITHUB, + MSG_BUTTON_JOIN_CHANNEL, + MSG_BUTTON_VIEW_PROFILE, + MSG_COMMUNITY_CHANNEL, + MSG_DC_ANON_ERROR, + MSG_DC_FILE_ERROR, + MSG_DC_FILE_INFO, + MSG_DC_INVALID_USAGE, + MSG_DC_UNKNOWN, + MSG_ERROR_USER_INFO, + MSG_FILE_TYPE_ANIMATION, + MSG_FILE_TYPE_AUDIO, + MSG_FILE_TYPE_DOCUMENT, + MSG_FILE_TYPE_PHOTO, + MSG_FILE_TYPE_STICKER, + MSG_FILE_TYPE_UNKNOWN, + MSG_FILE_TYPE_VIDEO, + MSG_FILE_TYPE_VIDEO_NOTE, + MSG_FILE_TYPE_VOICE, + MSG_LINK_PRIVATE_HINT, + MSG_PING_RESPONSE, + MSG_PING_START, + MSG_TOKEN_ACTIVATED, + MSG_TOKEN_FAILED, + MSG_WELCOME, +) +from Thunder.utils.safe_call import edit_safe, reply_safe +from Thunder.utils.tokens import consume +from Thunder.vars import Var + + +@StreamBot.on_message(filters.command("start") & filters.private) +async def start_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + user = msg.from_user + if user: + await log_newusr(bot, user.id, user.first_name) + + if len(msg.command) == 2: + payload = msg.command[1] + + if payload != "start" and user is not None and Var.TOKEN_ENABLED: + status, hours = await consume(payload, user.id) + if status == "wrong_user": + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format( + reason="This activation link is not for your account." + ), + parse_mode=ParseMode.HTML, + ) + if status == "already": + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format(reason="Token has already been activated."), + parse_mode=ParseMode.HTML, + ) + if status == "ok": + return await reply_safe( + msg, + text=MSG_TOKEN_ACTIVATED.format(duration_hours=hours), + parse_mode=ParseMode.HTML, + ) + # no button here: payload is a consumed/foreign/unknown token; the + # "click the button below" copy of MSG_TOKEN_INVALID would be a dead end + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format(reason="The token is invalid or has expired."), + parse_mode=ParseMode.HTML, + ) + + txt = MSG_WELCOME.format( + user_name=html.escape(user.first_name or "Unknown") if user else "Unknown", + max_files=Var.MAX_BATCH_FILES, + ) + link, title = await get_force_info(bot) + if link: + txt += "\n\n" + MSG_COMMUNITY_CHANNEL.format(channel_title=html.escape(title or "Channel")) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command"), + ], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], + ] + + if link: + btns.append( + [InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)] + ) + + await reply_safe( + msg, text=txt, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("help") & filters.private) +async def help_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + txt = build_help_text(Var.MAX_BATCH_FILES) + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")] + ] + + link, title = await get_force_info(bot) + if link: + btns.append( + [InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)] + ) + + btns.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) + await reply_safe( + msg, text=txt, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("about") & filters.private) +async def about_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], + ] + + await reply_safe( + msg, text=MSG_ABOUT, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("link") & filters.private) +async def link_private_hint(bot: Client, msg: Message): + # gated like the other info commands: banned users see the ban notice, + # private-mode outsiders are denied, everyone else gets the hint + if await preflight(bot, msg, gates=GATES_START) is None: + return + await reply_safe(msg, text=MSG_LINK_PRIVATE_HINT, parse_mode=ParseMode.MARKDOWN) + + +async def send_user_dc(msg: Message, user: User): + txt = await gen_dc_txt(user) + url = f"https://t.me/{user.username}" if user.username else f"tg://user?id={user.id}" + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")], + ] + await reply_safe( + msg, + text=txt, + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup(btns), + ) + + +async def send_file_dc(msg: Message, file_msg: Message): + try: + fname = get_fname(file_msg) or "Untitled File" + fsize = humanbytes(get_fsize(file_msg)) + + type_map = { + "document": MSG_FILE_TYPE_DOCUMENT, + "photo": MSG_FILE_TYPE_PHOTO, + "video": MSG_FILE_TYPE_VIDEO, + "audio": MSG_FILE_TYPE_AUDIO, + "voice": MSG_FILE_TYPE_VOICE, + "sticker": MSG_FILE_TYPE_STICKER, + "animation": MSG_FILE_TYPE_ANIMATION, + "video_note": MSG_FILE_TYPE_VIDEO_NOTE, + } + + file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") + type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) + + dc_id: int | str = MSG_DC_UNKNOWN + fid = parse_fid(file_msg) + if fid: + dc_id = fid.dc_id + + txt = MSG_DC_FILE_INFO.format( + file_name=html.escape(fname, quote=False), + file_size=fsize, + file_type=type_display, + dc_id=dc_id, + ) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + await reply_safe( + msg, + text=txt, + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup(btns), + ) + + except Exception as e: + logger.error(f"File DC error: {e}", exc_info=True) + await reply_user_err(msg, MSG_DC_FILE_ERROR) + + +@StreamBot.on_message(filters.command("dc")) +async def dc_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + from Thunder.utils.decorators import force_sub_gate + + if not await force_sub_gate(bot, msg): + return + if not msg.from_user and not msg.reply_to_message: + return await reply_user_err(msg, MSG_DC_ANON_ERROR) + + args = (msg.text or msg.caption or "").strip().split(maxsplit=1) + if len(args) > 1: + user = await get_user(bot, args[1].strip()) + if user: + await send_user_dc(msg, user) + else: + await reply_user_err(msg, MSG_ERROR_USER_INFO) + return + + if msg.reply_to_message: + ref = msg.reply_to_message + if ref.media: + await send_file_dc(msg, ref) + elif ref.from_user: + await send_user_dc(msg, ref.from_user) + else: + await reply_user_err(msg, MSG_DC_INVALID_USAGE) + return + + if msg.from_user: + await send_user_dc(msg, msg.from_user) + else: + await reply_user_err(msg, MSG_DC_ANON_ERROR) + + +@StreamBot.on_message(filters.command("ping") & filters.private) +async def ping_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + from Thunder.utils.decorators import force_sub_gate + + if not await force_sub_gate(bot, msg): + return + start = time.time() + try: + sent = await reply_safe(msg, text=MSG_PING_START) + except Exception: + return + end = time.time() + ms = (end - start) * 1000 + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ] + ] + + try: + await edit_safe( + sent, + MSG_PING_RESPONSE.format(time_taken_ms=ms), + reply_markup=InlineKeyboardMarkup(btns), + disable_web_page_preview=True, + ) + except MessageNotModified: + pass + except Exception as e: + logger.debug(f"Could not edit ping message: {e}") diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 02c3368a..c8607cd6 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -1,356 +1,433 @@ -# Thunder/bot/plugins/stream.py - -import asyncio -import secrets -from typing import Any, Dict, Optional - -from pyrogram import Client, enums, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden, MessageIdInvalid -from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, - Message) +import asyncio +import html +import secrets +import time +from typing import Any + +from pyrogram import Client, enums, filters +from pyrogram.errors import MessageDeleteForbidden, MessageIdInvalid, MessageNotModified +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.bot import StreamBot -from Thunder.utils.bot_utils import (gen_canonical_links, gen_links, is_admin, - log_newusr, notify_own, reply_user_err) -from Thunder.utils.canonical_files import get_or_create_canonical_file +from Thunder.utils.bot_utils import ( + format_link_message, + gen_canonical_links, + gen_links, + is_admin, + log_newusr, + notify_own, + reply_user_err, +) +from Thunder.utils.canonical_files import IngestContended, get_or_create_canonical_file from Thunder.utils.database import db -from Thunder.utils.decorators import (check_banned, get_shortener_status, - require_token) -from Thunder.utils.force_channel import force_channel_check -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_BATCH_LINKS_READY, MSG_BUTTON_DOWNLOAD, MSG_BUTTON_START_CHAT, - MSG_BUTTON_STREAM_NOW, MSG_CRITICAL_ERROR, MSG_DM_BATCH_PREFIX, - MSG_DM_SINGLE_PREFIX, MSG_ERROR_DM_FAILED, MSG_ERROR_INVALID_NUMBER, - MSG_ERROR_NO_FILE, MSG_ERROR_NOT_ADMIN, MSG_ERROR_NUMBER_RANGE, - MSG_ERROR_PROCESSING_MEDIA, MSG_ERROR_REPLY_FILE, MSG_ERROR_START_BOT, - MSG_LINKS, MSG_NEW_FILE_REQUEST, MSG_PROCESSING_BATCH, - MSG_PROCESSING_FILE, MSG_PROCESSING_REQUEST, MSG_PROCESSING_RESULT, - MSG_PROCESSING_STATUS -) -from Thunder.utils.rate_limiter import handle_rate_limited_request -from Thunder.vars import Var - -BATCH_SIZE = 10 -LINK_CHUNK_SIZE = 20 -BATCH_UPDATE_INTERVAL = 5 -MESSAGE_DELAY = 0.5 - - -async def fwd_media(m_msg: Message) -> Optional[Message]: - try: - try: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL) - except FloodWait as e: - await asyncio.sleep(e.value) - return await 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: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) - except FloodWait as e: - await asyncio.sleep(e.value) - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) - logger.error(f"Error fwd_media copy: {e}", exc_info=True) - return None - - -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) -> Optional[bool]: - if not await check_banned(client, message): - return None - if not await require_token(client, message): - return None - if not await force_channel_check(client, message): - return None - return await get_shortener_status(client, message) - - +from Thunder.utils.decorators import preflight +from Thunder.utils.flag_cache import flags +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_BATCH_LINKS_READY, + MSG_BUTTON_DOWNLOAD, + MSG_BUTTON_START_CHAT, + MSG_BUTTON_STREAM_NOW, + MSG_CRITICAL_ERROR, + MSG_DM_BATCH_PREFIX, + MSG_DM_SINGLE_PREFIX, + MSG_ERROR_BATCH_CHUNK_FAILED, + MSG_ERROR_DM_BATCH_FAILED, + MSG_ERROR_INVALID_NUMBER, + MSG_ERROR_NO_FILE, + MSG_ERROR_NOT_ADMIN, + MSG_ERROR_NUMBER_RANGE, + MSG_ERROR_PROCESSING_MEDIA, + MSG_ERROR_REPLY_FILE, + MSG_ERROR_START_BOT, + MSG_NEW_FILE_REQUEST, + MSG_PROCESSING_BATCH, + MSG_PROCESSING_FILE, + MSG_PROCESSING_REQUEST, + MSG_PROCESSING_RESULT, + MSG_PROCESSING_STATUS, + TELEGRAM_MAX_UNITS, + telegram_len, +) +from Thunder.utils.rate_limiter import handle_rate_limited_request, rate_limiter +from Thunder.utils.safe_call import ( + delete_safe, + edit_safe, + reply_safe, + send_safe, + tg_call, +) +from Thunder.vars import Var + +BATCH_SIZE = 10 +BATCH_UPDATE_INTERVAL = 5 +_BATCH_DEADLINE_BASE = 30 + +# Pause between batch-link messages. +# +# A flat 0.5s was a guess, and length-based chunking made it wrong: 50 files +# whose names are CJK or emoji need 10-13 chunks, and each chunk is a group +# reply PLUS a DM mirror -- 20-26 messages in ~7 seconds. That is what trips +# FloodWait, and the resulting requeues stall a worker each. +# +# Capping the chunk count instead would mean dropping links, which is worse. +# So the pause scales with how many messages the batch will produce and takes +# real pressure into account -- see _chunk_pause, which reads the limiter's +# breaker AND its queue depth, because the breaker alone reports nothing unless +# GLOBAL_RPS_LIMIT / GLOBAL_RATE_LIMIT are configured and both ship disabled. +# MESSAGE_DELAY stays as the floor for a two-message batch. +MESSAGE_DELAY = 0.5 +MAX_CHUNK_PAUSE_SECONDS = 4.0 +_CHUNK_PAUSE_REFERENCE_CHUNKS = 4 + +# Room left for everything that is not a link, so a chunk that measures +# _LINK_CHUNK_UNITS still fits inside one message. +# +# The DM mirror carries the chat title, and html.escape turns ONE character +# into as many as six (' for an apostrophe). A legal 128-character group +# title therefore costs up to 768 units, not 128 -- an earlier flat 600 reserve +# was below that and Telegram rejected the DM at 4252 units for exactly the +# CJK/emoji filenames this budget exists to protect. Derived from the real +# messages so it cannot drift when the wording changes. +_MAX_TITLE_CHARS = 128 +_WORST_ESCAPE_EXPANSION = 6 # "'" is the longest single-character escape +# escaped, because that is what is interpolated at the call site +_DM_PREFIX_RESERVE = ( + telegram_len(MSG_DM_BATCH_PREFIX.format(chat_title=html.escape("'" * _MAX_TITLE_CHARS))) + 1 +) +_HEADER_RESERVE = telegram_len(MSG_BATCH_LINKS_READY.format(count=999)) + 2 +_TAG_RESERVE = len("") +_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 }} - - - - -
-
-

Your download for {{ file_name }} should start automatically.

-

If it doesn't, please click here to download.

-
-
- - \ 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 }}

-
- - - - - Loading... - - - - - - Loading... - -
-
- -
- - -
-
-
- - - - If video not playing - Use External Player -
- - - - -
-
- - - - - - - - -
- -
-
-
- Space - Play / Pause -
-
- ←→ - Seek -
-
- ↑↓ - Volume -
-
- F - Fullscreen -
-
- M - Mute -
-
- <> - Speed -
-
-
- - - -
-
- - -
- - - - - - - - + + + + + + + {{ heading }} + + + + + + + + + + + + + + + + + + + + + {% if kind == 'video' %} + + + {% elif kind == 'audio' %} + + + {% endif %} + + + + + + + + + {% if kind == 'video' %} + + + + {% elif kind == 'audio' %} + + + + {% endif %} + + + + + + + + + + + + + + + + + +
+
+
+

{{ file_name }}

+
+ {% if size_formatted %} + + + + + {{ size_formatted }} + + {% endif %} + + + + + + + +
+
+ +
+ +
+
+ {% if kind == 'audio' %} + + {% endif %} + {% if kind in ('video', 'audio') %} +
+ + + + If video not playing + Use External Player +
+ {% endif %} + {% if kind == 'video' %} + + + + + {% elif kind == 'audio' %} + + + + + {% elif kind == 'image' %} +
+ {{ file_name }} +
+ {% else %} + +
+

This file type ({{ mime_type }}) can't be played in the browser.

+ Download +
+ {% endif %} + +
+
+ + + + + +
+
+
+
+ Space + Play / Pause +
+
+ ←→ + Seek +
+
+ ↑↓ + Volume +
+
+ F + Fullscreen +
+
+ M + Mute +
+
+ <> + Speed +
+
+
+ + +
+ + +
+ +
+ + + + + + + + + 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"]*\bsrc=)(.*?)", 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" }, +]