From 30fefb4faeba93bd9b79c72956072462425be6e6 Mon Sep 17 00:00:00 2001 From: RiX <9930448+rix1337@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:33:16 +0200 Subject: [PATCH] Cap search fan-out and MX feed runs by wall clock Co-Authored-By: Claude Opus 5 --- quasarr/api/AGENTS.md | 1 + quasarr/api/arr/__init__.py | 11 ++ quasarr/constants/__init__.py | 6 + quasarr/providers/AGENTS.md | 4 +- quasarr/providers/imdb_metadata.py | 39 +++- quasarr/providers/radarr_api.py | 56 ++++-- quasarr/providers/sonarr_api.py | 65 +++++-- quasarr/providers/version.py | 2 +- quasarr/search/AGENTS.md | 2 + quasarr/search/__init__.py | 84 +++++++- quasarr/search/sources/AGENTS.md | 2 +- quasarr/search/sources/mx.py | 115 ++++++++++- tests/test_date_numbering_dispatch.py | 3 +- tests/test_imdb_metadata.py | 10 +- tests/test_localized_title_deadline.py | 86 +++++++++ tests/test_mx_source.py | 258 ++++++++++++++++++++++++- tests/test_radarr_wanted.py | 83 +++++++- tests/test_search_fanout_deadline.py | 170 ++++++++++++++++ tests/test_sonarr_wanted.py | 81 +++++++- uv.lock | 98 +++++----- 20 files changed, 1055 insertions(+), 121 deletions(-) create mode 100644 tests/test_localized_title_deadline.py create mode 100644 tests/test_search_fanout_deadline.py diff --git a/quasarr/api/AGENTS.md b/quasarr/api/AGENTS.md index 0c72a4ec..bdabe49b 100644 --- a/quasarr/api/AGENTS.md +++ b/quasarr/api/AGENTS.md @@ -17,6 +17,7 @@ Submodules are packages with all code in their `__init__.py`: `arr/` (emulation - Newznab emulation (`GET /api` with `?t=`): movie/tv searches (`t=movie`, `t=tvsearch`) are imdbid-only (q-only requests are deliberately ignored, except q-as-episode alongside an imdbid) and require configured Radarr/Sonarr clients respectively; `t=book`/`t=music` search by author/title phrase; `t=search` is honored only for magazarr/lidarr User-Agents; RSS titles are prefixed `[]` except for magazarr; enclosures use type `application/x-nzb`; a placeholder "No results found" item is returned when neither imdbid nor q is present and the search produced no items - it keeps *arr connectivity tests passing. - The fake-NZB roundtrip: search results embed `/download/?payload=...`; `GET /download/` renders `` (the free-text `title`/`url`/`password` attributes are XML-escaped via `_xml_attr`, so source-specific characters like `&` in hoster URLs or accents in French titles do not corrupt the NZB); the *arr app posts that file back via `POST /api` (multipart field `name`) or `mode=addurl`, and both paths decode the same payload and call `quasarr.downloads.download()`. The payload format is owned by `providers/utils.py` (`generate_download_link`/`parse_payload`). - Client identity comes exclusively from the User-Agent header (`extract_client_type` and the category resolvers in `providers/utils.py`). +- A search request computes one `SEARCH_FANOUT_DEADLINE_SECONDS` deadline and passes it into every `get_search_results` call it makes. Cache-sharing categories run sequentially, so a per-call deadline would let a multi-category request take a multiple of what the *arr client waits. - Every successful CAPTCHA flow ends in `downloads.submit_final_download_urls(..., remove_protected=True, notification_details=...)` and increments the matching `StatsHelper` counters; manual flows identify their solution method and SponsorsHelper passes solver details. Terminal submission failures increment failed counters and update the same tracked release notification. SponsorsHelper disable persists the advanced notification case/silence state so a later manual solution evaluates the next transition correctly. - Download-category mirror-whitelist order is also the protected-link priority order: the config UI preserves and reorders that ranking, and both the manual CAPTCHA redirect and SponsorsHelper package handoff sort candidate links by it. Categories without a mirror whitelist retain the legacy Rapidgator-first fallback. - SponsorsHelper routes use trailing slashes; most require an active helper (HTTP 402 otherwise), tracked via `helper_last_seen` with a 300s timeout; `POST to_decrypt/` requires the helper's `supported_urls` and only hands out packages matching its URL patterns or optional `supported_mirrors`. diff --git a/quasarr/api/arr/__init__.py b/quasarr/api/arr/__init__.py index 403b9683..2b16856a 100644 --- a/quasarr/api/arr/__init__.py +++ b/quasarr/api/arr/__init__.py @@ -2,6 +2,7 @@ # Quasarr # Project by https://github.com/rix1337 +import time import traceback import xml.sax.saxutils as sax_utils from concurrent.futures import ThreadPoolExecutor, as_completed @@ -11,6 +12,7 @@ from bottle import request +from quasarr.constants import SEARCH_FANOUT_DEADLINE_SECONDS from quasarr.downloads import download from quasarr.downloads.packages import delete_package, get_packages from quasarr.providers import shared_state @@ -365,6 +367,12 @@ def quasarr_api(): elif mode in ["movie", "tvsearch", "book", "music", "search"]: releases = [] + # One deadline for the whole request: cache-sharing categories + # run one after another, so a per-run deadline would let a + # two-category request take twice as long as the *arr client + # is willing to wait. + request_deadline = time.time() + SEARCH_FANOUT_DEADLINE_SECONDS + try: offset = int(getattr(request.query, "offset", 0) or 0) except (AttributeError, ValueError) as e: @@ -475,6 +483,7 @@ def run_cache_group(group_categories): episode=episode, offset=request_offset, limit=request_limit, + deadline=request_deadline, ) ) ) @@ -497,6 +506,7 @@ def run_cache_group(group_categories): search_phrase=search_phrase, offset=request_offset, limit=request_limit, + deadline=request_deadline, ) ) ) @@ -516,6 +526,7 @@ def run_cache_group(group_categories): search_phrase=search_phrase, offset=request_offset, limit=request_limit, + deadline=request_deadline, ) ) ) diff --git a/quasarr/constants/__init__.py b/quasarr/constants/__init__.py index c3aa8a0e..df89d714 100644 --- a/quasarr/constants/__init__.py +++ b/quasarr/constants/__init__.py @@ -119,6 +119,12 @@ def apply_timeout_slow_mode_settings(settings=None): TIMEOUT_SLOW_MODE_DEFINITIONS["session"]["base_seconds"] ) +# Ceiling for one *arr-facing search or feed response. Radarr and Sonarr disable +# an indexer that outlives their own request timeout (100s by default), so the +# fan-out stops waiting for a slow source well before that. Deliberately not part +# of slow mode: the *arr timeout does not grow with it. +SEARCH_FANOUT_DEADLINE_SECONDS = 60 + # Notification providers exposed in config/UI. NOTIFICATION_PROVIDERS = ("discord", "telegram") diff --git a/quasarr/providers/AGENTS.md b/quasarr/providers/AGENTS.md index d69d8579..dba076d1 100644 --- a/quasarr/providers/AGENTS.md +++ b/quasarr/providers/AGENTS.md @@ -12,8 +12,8 @@ The shared-services layer consumed by every other subsystem: cross-process state - `auth.py` - route auth modes (`public_endpoint`/`require_api_key`/browser default), Bottle auth hook, startup audit - `myjd_api.py` - vendored, modified My.JDownloader client (MIT, third-party) - keep modifications minimal - `jd_cache.py` - `JDPackageCache`, valid for exactly one `get_packages()`/`delete_package()` call, never reused across requests -- `imdb_metadata.py` / `xem_metadata.py` - cached metadata chains: IMDb IDs resolve basic title/year/poster and title searches through the local Radarr or Sonarr client; localized titles use locale-specific IMDb release-info HTML and preserve that cache across Arr refreshes; German localized titles are transliterated once at this provider boundary for every source; TheXEM supplies season names -- `radarr_api.py` / `sonarr_api.py` - minimal clients cached in shared_state via `set_client`/`get_client`; IMDb and free-title lookup, IMDb→TMDB/TVDB resolution (`get_tmdb_id`/`get_tvdb_id`), plus library-feed seeds `get_wanted_imdb_ids` / `get_wanted_episodes` (wanted = missing + cutoff-unmet, missing first, capped at the passed `limit`, paging past filtered entries; the movie helper skips not-yet-released titles and the episode helper skips not-yet-aired ones). All return safe empties when the client is unconfigured. +- `imdb_metadata.py` / `xem_metadata.py` - cached metadata chains: IMDb IDs resolve basic title/year/poster and title searches through the local Radarr or Sonarr client; localized titles use locale-specific IMDb release-info HTML and preserve that cache across Arr refreshes; German localized titles are transliterated once at this provider boundary for every source; `get_localized_title` takes an optional caller `deadline`, rechecked at each gate because the Arr refresh between them costs its own request, and then answers from cache only; the 60s FlareSolverr stage is also skipped when the direct IMDb request used the rest of the budget; TheXEM supplies season names +- `radarr_api.py` / `sonarr_api.py` - minimal clients cached in shared_state via `set_client`/`get_client`; IMDb and free-title lookup, IMDb→TMDB/TVDB resolution (`get_tmdb_id`/`get_tvdb_id`), plus library-feed seeds `get_wanted_imdb_ids` / `get_wanted_episodes` (wanted = missing + cutoff-unmet, missing first, capped at the passed `limit`, paging past filtered entries and stopping at an optional caller `deadline` and clamping each page request's timeout to what is left of it, since every page is its own *arr request - a caller timeout only ever tightens the client's own. `client.wanted()` returns `None` on a failed request so paging does not read it as the last page, and an optional `status` dict reports whether the walk completed, which callers persisting progress need; the movie helper skips not-yet-released titles and the episode helper skips not-yet-aired ones). All return safe empties when the client is unconfigured. - `statistics.py` - DB-backed counters, constructed inline at call sites - `version.py` - `__version__`, the single source of version truth - `obfuscated.py` - generated obfuscated userscripts; consumed only by `api/captcha`; regenerated only through the private Quasarr-UserScripts generator repo diff --git a/quasarr/providers/imdb_metadata.py b/quasarr/providers/imdb_metadata.py index 67d295cf..1ec13cfd 100644 --- a/quasarr/providers/imdb_metadata.py +++ b/quasarr/providers/imdb_metadata.py @@ -4,6 +4,7 @@ import html import re +import time from datetime import datetime, timedelta from json import dumps, loads @@ -100,7 +101,7 @@ class IMDbHTML: } @staticmethod - def _request(url, language): + def _request(url, language, deadline=None): headers = { "Accept-Language": IMDbHTML._LANGUAGE_HEADERS.get( language, f"{language},en;q=0.8" @@ -118,6 +119,12 @@ def _request(url, language): # Browser fallback preserves the old AKA parsing path when direct HTML # is unavailable. FlareSolverr cannot reliably set localization headers. + if deadline is not None and time.time() >= deadline: + # It allows 60s on its own and the caller stopped waiting, so this + # is the one stage worth giving up rather than starting. + debug("Skipped IMDb FlareSolverr fallback: caller out of time") + return None + flaresolverr_url = _get_config("FlareSolverr").get("url") flaresolverr_skipped = _get_db("skip_flaresolverr").retrieve("skipped") @@ -358,7 +365,7 @@ def _parse_localized_title(html_content, language): return localized_page_title @staticmethod - def get_localized_title(imdb_id, language): + def get_localized_title(imdb_id, language, deadline=None): # The locale-specific HTML metadata is primary. The parser retains an # AKA-section fallback for older or browser-rendered responses. language = language.lower() @@ -367,7 +374,7 @@ def get_localized_title(imdb_id, language): url = f"{IMDbHTML._WEB_URL}/title/{imdb_id}/releaseinfo/" else: url = f"{IMDbHTML._WEB_URL}/{language}/title/{imdb_id}/releaseinfo/" - html_content = IMDbHTML._request(url, language) + html_content = IMDbHTML._request(url, language, deadline=deadline) if html_content: try: @@ -568,7 +575,23 @@ def get_poster_link(shared_state, imdb_id, search_category=None): return None -def get_localized_title(shared_state, imdb_id, language="de", search_category=None): +def get_localized_title( + shared_state, imdb_id, language="de", search_category=None, deadline=None +): + """Resolve a localized title, optionally within a caller's wall-clock budget. + + ``deadline`` is an absolute time. Cache hits always answer; the Arr refresh + and the IMDb HTML/FlareSolverr fallbacks are skipped once it has passed, + because those cost real requests (the browser fallback alone allows 70s) and + would otherwise run long after the caller stopped waiting for them. + """ + + def out_of_time(): + # Re-read on every gate: the Arr refresh between them costs a request of + # its own, so a budget that was intact on entry can be gone by the time + # the far more expensive IMDb fallbacks would start. + return deadline is not None and time.time() >= deadline + imdb_metadata = _get_cached_metadata(imdb_id) cache_is_fresh = bool( imdb_metadata and imdb_metadata.get("ttl", 0) > datetime.now().timestamp() @@ -577,7 +600,7 @@ def get_localized_title(shared_state, imdb_id, language="de", search_category=No localized = imdb_metadata.get("localized", {}).get(language) if localized: return _normalize_localized_title(localized, language) - else: + elif not out_of_time(): imdb_metadata, arr_localized = _refresh_imdb_metadata( shared_state, imdb_id, search_category, imdb_metadata ) @@ -585,7 +608,11 @@ def get_localized_title(shared_state, imdb_id, language="de", search_category=No if localized: return _normalize_localized_title(localized, language) - title = IMDbHTML.get_localized_title(imdb_id, language) + if out_of_time(): + debug(f"Skipped localized-title lookup for {imdb_id}: caller out of time") + return None + + title = IMDbHTML.get_localized_title(imdb_id, language, deadline=deadline) if title: sanitized_title = TitleCleaner.sanitize(title) _update_cache(imdb_id, "localized", sanitized_title, language) diff --git a/quasarr/providers/radarr_api.py b/quasarr/providers/radarr_api.py index 96740270..15faac97 100644 --- a/quasarr/providers/radarr_api.py +++ b/quasarr/providers/radarr_api.py @@ -2,6 +2,8 @@ # Quasarr # Project by https://github.com/rix1337 +import time + import requests from quasarr.providers.log import error, trace, warn @@ -34,7 +36,10 @@ def __init__(self, base_url, api_key, timeout=10): self._api_key = api_key self._timeout = timeout - def _get(self, path, params=None): + def _get(self, path, params=None, timeout=None): + # A caller timeout only ever tightens the client's own: it says how much + # of its budget is left, not that this request may take longer. + timeout = min(self._timeout, timeout) if timeout else self._timeout url = f"{self._base_url}/api/v3{path}" headers = { "X-Api-Key": self._api_key, @@ -42,7 +47,10 @@ def _get(self, path, params=None): } try: response = requests.get( - url, headers=headers, params=params, timeout=self._timeout + url, + headers=headers, + params=params, + timeout=timeout, ) response.raise_for_status() return response.json() @@ -66,14 +74,16 @@ def movie_lookup(self, term): return [] return self._get("/movie/lookup", params={"term": term}) or [] - def wanted(self, kind, page=1, page_size=50): - """Return a wanted movies page (``kind`` is ``missing`` or ``cutoff``).""" - return ( - self._get( - f"/wanted/{kind}", - params={"page": page, "pageSize": page_size, "monitored": "true"}, - ) - or {} + def wanted(self, kind, page=1, page_size=50, timeout=None): + """Return a wanted movies page (``kind`` is ``missing`` or ``cutoff``). + + ``None`` means the request failed. A caller walking pages must not read + that as "no more pages". + """ + return self._get( + f"/wanted/{kind}", + params={"page": page, "pageSize": page_size, "monitored": "true"}, + timeout=timeout, ) @@ -108,7 +118,7 @@ def get_tmdb_id(shared_state, imdb_id): _WANTED_MAX_PAGES = 5 -def get_wanted_imdb_ids(shared_state, limit=50): +def get_wanted_imdb_ids(shared_state, limit=50, deadline=None, status=None): """Return IMDb IDs of monitored movies Radarr wants as a list. Covers both missing movies (no file) and cutoff-unmet ones (present but @@ -119,6 +129,11 @@ def get_wanted_imdb_ids(shared_state, limit=50): still yields released ones instead of an empty seed. Empty when Radarr is not configured or the request fails. """ + if status is not None: + # Callers that persist progress across runs need to know whether this is + # the whole wanted list or as far as paging got. + status["complete"] = False + client = get_client(shared_state) if client is None: return [] @@ -129,7 +144,20 @@ def get_wanted_imdb_ids(shared_state, limit=50): for page in range(1, _WANTED_MAX_PAGES + 1): if len(imdb_ids) >= limit: return imdb_ids - records = client.wanted(kind, page=page, page_size=limit).get("records", []) + # Every page is its own Radarr request, so a slow instance must not + # spend a caller's whole budget before it gets any seeds - and the + # last page before the deadline must not overrun it either. + page_timeout = None + if deadline is not None: + page_timeout = deadline - time.time() + if page_timeout <= 0: + return imdb_ids + page_data = client.wanted( + kind, page=page, page_size=limit, timeout=page_timeout + ) + if page_data is None: + return imdb_ids # request failed: what we have is partial + records = page_data.get("records", []) if not records: break # no more pages for this kind for movie in records: @@ -141,6 +169,10 @@ def get_wanted_imdb_ids(shared_state, limit=50): seen.add(imdb_id) imdb_ids.append(imdb_id) if len(imdb_ids) >= limit: + if status is not None: + status["complete"] = True return imdb_ids + if status is not None: + status["complete"] = True return imdb_ids diff --git a/quasarr/providers/sonarr_api.py b/quasarr/providers/sonarr_api.py index 53b025bb..378c2519 100644 --- a/quasarr/providers/sonarr_api.py +++ b/quasarr/providers/sonarr_api.py @@ -2,6 +2,7 @@ # Quasarr # Project by https://github.com/rix1337 +import time from datetime import datetime, timezone import requests @@ -36,7 +37,10 @@ def __init__(self, base_url, api_key, timeout=10): self._api_key = api_key self._timeout = timeout - def _get(self, path, params=None): + def _get(self, path, params=None, timeout=None): + # A caller timeout only ever tightens the client's own: it says how much + # of its budget is left, not that this request may take longer. + timeout = min(self._timeout, timeout) if timeout else self._timeout url = f"{self._base_url}/api/v3{path}" headers = { "X-Api-Key": self._api_key, @@ -44,7 +48,10 @@ def _get(self, path, params=None): } try: response = requests.get( - url, headers=headers, params=params, timeout=self._timeout + url, + headers=headers, + params=params, + timeout=timeout, ) response.raise_for_status() return response.json() @@ -76,20 +83,22 @@ def series_lookup(self, term): return [] return self._get("/series/lookup", params={"term": term}) or [] - def wanted(self, kind, page=1, page_size=50): + def wanted(self, kind, page=1, page_size=50, timeout=None): """Return a wanted episodes page (``kind`` is ``missing`` or ``cutoff``); - records include the series.""" - return ( - self._get( - f"/wanted/{kind}", - params={ - "page": page, - "pageSize": page_size, - "includeSeries": "true", - "monitored": "true", - }, - ) - or {} + records include the series. + + ``None`` means the request failed. A caller walking pages must not read + that as "no more pages". + """ + return self._get( + f"/wanted/{kind}", + params={ + "page": page, + "pageSize": page_size, + "includeSeries": "true", + "monitored": "true", + }, + timeout=timeout, ) @@ -157,7 +166,7 @@ def _has_aired(record, now): return False -def get_wanted_episodes(shared_state, limit=50): +def get_wanted_episodes(shared_state, limit=50, deadline=None, status=None): """Return aired monitored episodes Sonarr wants as ``[{imdb_id, season, episode}]``. @@ -168,6 +177,11 @@ def get_wanted_episodes(shared_state, limit=50): ones. Empty when Sonarr is not configured or the request fails. Used to seed a show feed for sources that need a concrete season+episode per request. """ + if status is not None: + # Callers that persist progress across runs need to know whether this is + # the whole wanted list or as far as paging got. + status["complete"] = False + client = get_client(shared_state) if client is None: return [] @@ -179,7 +193,20 @@ def get_wanted_episodes(shared_state, limit=50): for page in range(1, _WANTED_MAX_PAGES + 1): if len(episodes) >= limit: return episodes - records = client.wanted(kind, page=page, page_size=limit).get("records", []) + # Every page is its own Sonarr request, so a slow instance must not + # spend a caller's whole budget before it gets any seeds - and the + # last page before the deadline must not overrun it either. + page_timeout = None + if deadline is not None: + page_timeout = deadline - time.time() + if page_timeout <= 0: + return episodes + page_data = client.wanted( + kind, page=page, page_size=limit, timeout=page_timeout + ) + if page_data is None: + return episodes # request failed: what we have is partial + records = page_data.get("records", []) if not records: break # no more pages for this kind for record in records: @@ -199,6 +226,10 @@ def get_wanted_episodes(shared_state, limit=50): {"imdb_id": imdb_id, "season": season, "episode": episode} ) if len(episodes) >= limit: + if status is not None: + status["complete"] = True return episodes + if status is not None: + status["complete"] = True return episodes diff --git a/quasarr/providers/version.py b/quasarr/providers/version.py index d50a1207..c5067acd 100644 --- a/quasarr/providers/version.py +++ b/quasarr/providers/version.py @@ -5,7 +5,7 @@ import re import sys -__version__ = "4.6.14" +__version__ = "4.6.15" def get_version(): diff --git a/quasarr/search/AGENTS.md b/quasarr/search/AGENTS.md index a50dd4fd..4f8716ee 100644 --- a/quasarr/search/AGENTS.md +++ b/quasarr/search/AGENTS.md @@ -15,6 +15,8 @@ The Newznab-facing search layer: `get_search_results()` fans a single *arr reque - Movie searches and feeds require a cached Radarr client; TV searches and feeds require a cached Sonarr client. Missing clients stop before source dispatch with an error. Book and music phrase searches have no Arr-client gate. IMDb searches warm the metadata cache from the category's required client before fan-out. - Date-numbered requests are parsed once into a validated `datetime.date`; regular `season`/`episode` are cleared before dispatch and proven sources receive only `episode_date`. Invalid calendar dates stay on the normal numbering path. - The method names `search` and `feed` are load-bearing - dispatch is `getattr(source, action)`. +- The fan-out is capped by a deadline: `get_search_results` takes an optional absolute `deadline` and otherwise starts one at `SEARCH_FANOUT_DEADLINE_SECONDS`. Callers running several searches for one *arr request must pass their own so the runs share it. IMDb metadata warming and source dispatch are both skipped once it has passed, and sources still running when it passes are badged and dropped from that response, so an *arr client never waits past its own request timeout. The pool is sized to one worker per dispatched source, otherwise the sources dispatched last would spend the deadline queued behind the first ones. Sources must still bound their own work - a dropped source contributes nothing. +- `start_time` is taken before IMDb metadata warming: sources derive their own budget from it, so a later anchor would let them outlive the deadline by whatever the warming cost. - Cache TTL is 300s for search, 60s for feed; the key nulls `start_time` and uses the cache-owner category. Cached entries skip execution entirely, so source methods must be safe to skip. - Per-source results are merged, date-sorted descending, title-filtered by `release_matches_search_category`, then offset/limit-sliced; feed responses are never paginated. - Search sources normally have a same-key download twin (FX is the search-only exception); the `source_key` embedded in the search payload routes the later `download()` call to the same-key twin first when one exists. diff --git a/quasarr/search/__init__.py b/quasarr/search/__init__.py index a742c07b..21092b44 100644 --- a/quasarr/search/__init__.py +++ b/quasarr/search/__init__.py @@ -4,6 +4,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import TimeoutError as FutureTimeoutError from datetime import timezone from email.utils import parsedate_to_datetime from threading import Lock @@ -13,6 +14,7 @@ SEARCH_CAT_MOVIES, SEARCH_CAT_MUSIC, SEARCH_CAT_SHOWS, + SEARCH_FANOUT_DEADLINE_SECONDS, ) from quasarr.providers.imdb_metadata import get_imdb_metadata from quasarr.providers.log import ( @@ -38,6 +40,7 @@ def get_search_results( episode=None, offset=0, limit=1000, + deadline=None, ): from quasarr.providers.utils import ( determine_search_category, @@ -84,7 +87,15 @@ def get_search_results( error("TV search unavailable: Sonarr is not configured") return [] - if imdb_id: + # Anchored before metadata warming: sources derive their own budget from this, + # so starting the clock after the warming would let a source outlive the + # deadline by however long the warming took. + start_time = time.time() + + if imdb_id and (deadline is None or time.time() < deadline): + # A failed refresh is not cached, so every category of a multi-category + # request would otherwise pay the Arr client timeout again, past the + # ceiling the deadline exists to hold. get_imdb_metadata(shared_state, imdb_id, base_search_category) capability_category = get_search_capability_category(search_category) @@ -111,8 +122,7 @@ def get_search_results( f"Using whitelist for category {search_category}: {', '.join([s.upper() for s in whitelisted_sources])}" ) - start_time = time.time() - search_executor = SearchExecutor() + search_executor = SearchExecutor(deadline=deadline) # Config retrieval config = shared_state.values["config"]("Hostnames") @@ -326,8 +336,16 @@ def get_date(item): class SearchExecutor: - def __init__(self): + def __init__(self, deadline=None): self.searches = [] + # Absolute time this fan-out must be answered by. Callers that run several + # executors for one *arr request pass their own so the runs share a single + # deadline instead of each starting a fresh one. + self.deadline = ( + deadline + if deadline is not None + else time.time() + SEARCH_FANOUT_DEADLINE_SECONDS + ) def add( self, @@ -364,9 +382,17 @@ def run_all(self): min_ttl = float("inf") bar_str = "" # Initialize to prevent UnboundLocalError on full cache - with ThreadPoolExecutor() as executor: + deadline = self.deadline + # One worker per source: the default pool is sized from the CPU count, so + # on a small host the last sources would queue behind the first ones and + # burn the deadline without ever having started. + # Not a context manager on purpose: its __exit__ joins every worker, which + # would re-introduce the very wait the deadline exists to prevent. + executor = ThreadPoolExecutor(max_workers=max(1, len(self.searches))) + try: current_index = 0 pending_futures = [] + skipped_badges = [] for key, func, use_cache, ttl, source_name in self.searches: cached_result = None @@ -388,16 +414,30 @@ def run_all(self): min_ttl = ttl_left else: all_cached = False + if time.time() >= deadline: + # Nothing left to spend. Starting the work anyway would + # only detach a worker whose result this response can no + # longer use, and hit the source a second time for it. + skipped_badges.append( + f"{source_name.upper()}" + ) + get_source_logger(source_name).warn( + "Not started, this request is already out of time" + ) + continue + future = executor.submit(func) cache_meta = (key, ttl) if use_cache else None future_to_meta[future] = (current_index, cache_meta, source_name) pending_futures.append(future) current_index += 1 + results_badges = [""] * len(pending_futures) if pending_futures: - results_badges = [""] * len(pending_futures) + collected = set() - for future in as_completed(pending_futures): + def collect(future): + collected.add(future) index, cache_meta, source_name = future_to_meta[future] try: res = future.result() @@ -420,7 +460,35 @@ def run_all(self): ) get_source_logger(source_name).warn(f"Search error: {e}") - bar_str = f" [{' '.join(results_badges)}]" + try: + for future in as_completed( + pending_futures, timeout=max(0.1, deadline - time.time()) + ): + collect(future) + except FutureTimeoutError: + # Radarr and Sonarr drop an indexer that outlives their own + # request timeout, so answer with whatever is ready instead of + # waiting for the straggler. + for future in pending_futures: + if future in collected: + continue + if future.done(): + collect(future) + continue + + index, _, source_name = future_to_meta[future] + results_badges[index] = ( + f"{source_name.upper()}" + ) + get_source_logger(source_name).warn( + f"Dropped from this response after " + f"{SEARCH_FANOUT_DEADLINE_SECONDS}s" + ) + + if results_badges or skipped_badges: + bar_str = f" [{' '.join(results_badges + skipped_badges)}]" + finally: + executor.shutdown(wait=False, cancel_futures=True) return results, bar_str, all_cached, min_ttl diff --git a/quasarr/search/sources/AGENTS.md b/quasarr/search/sources/AGENTS.md index cafef90b..373c8a5d 100644 --- a/quasarr/search/sources/AGENTS.md +++ b/quasarr/search/sources/AGENTS.md @@ -46,7 +46,7 @@ Capability flags (`supports_*`, `requires_*`) and categories are class attribute - **HE** - no login, IMDb-only; the feed is the empty-query case of search (one shared code path); relative timestamps ("X minutes ago" incl. German variants) normalized to RFC dates. Date searches append the curl-proven `YYYY MM DD` form. - **HS** - no login, IMDb-only RSS feed parsed with BeautifulSoup `html.parser` (lxml deliberately avoided); per-episode size = season-pack bitrate × parsed episode duration, falling back to the pack size; episode metadata extracted with a regex constant inside the module. - **MB** - no login, IMDb-only: the IMDb id is submitted directly as the site search query; German month parsing via a local `MONTHS_MAP`; search drops candidates not matching requested resolution/codec (feed keeps them as posted). -- **MX** - no login, IMDb-only JSON API (French DDL). API base is `api.`; `Referer`/`Origin` set to the configured host. `search` resolves a French title via `get_localized_title(..., "fr")`, queries the title endpoint, and matches the entry whose `imdb_id` equals the request (the first result is not reliably correct; never take `[0]`). The matched entry's internal `id` drives the download endpoint; the API also returns a `tmdb_id` but the download endpoint ignores it. Series require `season`+`episode` per request (an API hard requirement). Each link is decoded via the decode endpoint (real URL under `embed_url.lien` for object responses) during search, so the payload already holds the final hoster URL. `feed` has no native discovery endpoint and instead seeds from the *arr wanted lists (missing + cutoff-unmet, missing first): movies via `radarr_api.get_wanted_imdb_ids` (released-to-cinema only - announced titles skipped), and episodes - carrying the required season+episode - via `sonarr_api.get_wanted_episodes` (aired only - unaired episodes skipped); both capped by `FEED_LIBRARY_LIMIT`. Like every movie/TV source, MX derives `requires_radarr` / `requires_sonarr` from its supported categories, so the matching client is mandatory before either search or feed dispatch. Quality strings are normalized to scene tokens in `_normalize_quality`; titles tagged `.MX..`. +- **MX** - no login, IMDb-only JSON API (French DDL). API base is `api.`; `Referer`/`Origin` set to the configured host. `search` resolves a French title via `get_localized_title(..., "fr")`, queries the title endpoint, and matches the entry whose `imdb_id` equals the request (the first result is not reliably correct; never take `[0]`). The matched entry's internal `id` drives the download endpoint; the API also returns a `tmdb_id` but the download endpoint ignores it. Series require `season`+`episode` per request (an API hard requirement). Each link is decoded via the decode endpoint (real URL under `embed_url.lien` for object responses) during search, so the payload already holds the final hoster URL. `feed` has no native discovery endpoint and instead seeds from the *arr wanted lists (missing + cutoff-unmet, missing first): movies via `radarr_api.get_wanted_imdb_ids` (released-to-cinema only - announced titles skipped), and episodes - carrying the required season+episode - via `sonarr_api.get_wanted_episodes` (aired only - unaired episodes skipped); both capped by `FEED_LIBRARY_LIMIT`. Because every seed costs several round trips, the feed loop is additionally bounded by a wall-clock budget of `min(FEED_REQUEST_TIMEOUT_SECONDS, SEARCH_FANOUT_DEADLINE_SECONDS)` (slow mode must not push a run past the window its response is answered in, since a dropped run still advanced the resume offset), covering the wanted-list paging and the localized-title lookup as well (all three take the same `deadline`): `feed` passes the remaining budget as a callable that `_get` re-reads before every round trip (raising `FeedBudgetSpent`), so a seed with many links cannot outrun the budget it started with; `search` passes a plain number. A seed cut short is left for the next run, including one whose title lookup returned nothing because the budget went rather than because no title exists, and runs resume at the seed the previous one stopped on, tracked per base category under a lock because the registry shares one `Source` instance, normalized on write and left untouched when seed acquisition returned nothing or did not report a completed walk (the wanted helpers' `status` flag; a partial list, whether from a spent budget or a failed page, says nothing about where to resume), so successive pulls still cover the whole wanted list. Like every movie/TV source, MX derives `requires_radarr` / `requires_sonarr` from its supported categories, so the matching client is mandatory before either search or feed dispatch. Quality strings are normalized to scene tokens in `_normalize_quality`; titles tagged `.MX..`. - **NK** - no login. Form-encoded POST search; IMDb input first converted to a localized German title; default passwords read from a labelled element in the mirrors paragraph when present; season-pack size is unknown and reported as 0 so consumers estimate per episode; the local date parser accepts several formats - add new ones there. - **NX** - login required for downloads (`providers/sessions/nx` is used by the download twin only - search and feed run on plain unauthenticated requests). Internal type tokens (ebook/movie/episode/audio) map to Quasarr categories and must stay in lockstep with the API; book titles normalized via the Magazarr helper; an IMDb mismatch on the API response drops the result. - **RM** - no login, but requests only succeed on a bootstrapped session (`_bootstrap_session`, defined in the downloads twin and imported here). Production-centred model: find productions, then fetch each production's releases; search tries multiple match variants (full name, leading words, trailing words, year-stripped); titles normalized by stripping leading tags and replacing spaces with dots; the feed walks a bounded number of production pages. diff --git a/quasarr/search/sources/mx.py b/quasarr/search/sources/mx.py index f9435dba..1876caf0 100644 --- a/quasarr/search/sources/mx.py +++ b/quasarr/search/sources/mx.py @@ -20,6 +20,7 @@ import re import time from datetime import datetime, timezone +from threading import Lock import requests @@ -27,6 +28,7 @@ FEED_REQUEST_TIMEOUT_SECONDS, SEARCH_CAT_MOVIES, SEARCH_CAT_SHOWS, + SEARCH_FANOUT_DEADLINE_SECONDS, SEARCH_REQUEST_TIMEOUT_SECONDS, ) from quasarr.providers import radarr_api, shared_state, sonarr_api @@ -46,6 +48,10 @@ FEED_LIBRARY_LIMIT = 50 +class FeedBudgetSpent(Exception): + """The feed's wall-clock budget ran out while a seed was still being read.""" + + class Source(AbstractSearchSource): initials = "mx" language = "fr" @@ -59,6 +65,11 @@ class Source(AbstractSearchSource): requires_radarr = True requires_sonarr = True + def __init__(self): + # Where the next feed run resumes in the wanted list, per base category. + self._feed_offsets = {} + self._feed_lock = Lock() + # ------------------------------------------------------------------ # # HTTP # # ------------------------------------------------------------------ # @@ -71,6 +82,13 @@ def _api(self, shared_state): return f"https://api.{host}/api", host def _get(self, api_base, host, path, params, shared_state, timeout): + if callable(timeout): + # Feed runs hand in a callable so every round trip of a seed gets the + # budget that is actually left, not the one left when the seed began. + timeout = timeout() + if timeout is None: + raise FeedBudgetSpent() + headers = { "User-Agent": shared_state.values["user_agent"], "Referer": f"https://{host}/", @@ -249,9 +267,18 @@ def _releases_for_imdb( search_category, season=None, episode=None, + title_deadline=None, ): - title = get_localized_title(shared_state, imdb_id, "fr", search_category) + # Title resolution runs before any MX request and can reach IMDb and + # FlareSolverr, so a feed run hands it the same budget the rest obeys. + title = get_localized_title( + shared_state, imdb_id, "fr", search_category, deadline=title_deadline + ) if not title: + if title_deadline is not None and time.time() >= title_deadline: + # Out of time, not out of titles: this seed was never answered, + # so it keeps its place instead of being rotated past. + raise FeedBudgetSpent() return [] match = self._match_imdb( @@ -304,6 +331,10 @@ def feed( # the *arr libraries: monitored movies from Radarr, episodes from Sonarr. # The matching client must be configured; warn (don't fail) when it is # not, since the user may run a movie-only or TV-only setup. + # Seed acquisition pages the *arr API, so it shares the feed's budget + # instead of being free to spend all of it before the first lookup. + deadline = start_time + _feed_budget_seconds() + seed_status = {} base_cat = get_base_search_category_id(search_category) if base_cat == SEARCH_CAT_MOVIES: if radarr_api.get_client(shared_state) is None: @@ -312,7 +343,10 @@ def feed( seeds = [ (imdb_id, None, None) for imdb_id in radarr_api.get_wanted_imdb_ids( - shared_state, limit=FEED_LIBRARY_LIMIT + shared_state, + limit=FEED_LIBRARY_LIMIT, + deadline=deadline, + status=seed_status, ) ] elif base_cat == SEARCH_CAT_SHOWS: @@ -322,15 +356,48 @@ def feed( seeds = [ (ep["imdb_id"], ep["season"], ep["episode"]) for ep in sonarr_api.get_wanted_episodes( - shared_state, limit=FEED_LIBRARY_LIMIT + shared_state, + limit=FEED_LIBRARY_LIMIT, + deadline=deadline, + status=seed_status, ) ] else: return [] + seeds = seeds[:FEED_LIBRARY_LIMIT] + # Paging stops early on both a spent budget and a failed *arr page, so a + # short list here can be a partial view of the wanted list rather than + # all of it. A cursor normalized against that shorter list points + # somewhere near the head, which is not progress worth storing. + seeds_are_complete = seed_status.get("complete", False) + # Every seed costs several API round trips, so a full run can outlast the + # *arr client's own request timeout and get the indexer disabled. Resume + # where the previous run stopped so successive feed pulls still cover the + # whole wanted list instead of replaying the same head until the budget + # runs out. + # The source registry hands out one shared instance, so feed runs for the + # same category can overlap; the lock keeps each offset access atomic and + # the stored value normalized. Two concurrent runs still read the same + # offset and cover the same seeds: reserving a window up front cannot + # prevent that, because a claim of the whole list wraps straight back to + # the offset it started from. The feed cache is what keeps repeat pulls + # off the source. + with self._feed_lock: + offset = self._feed_offsets.get(base_cat, 0) % len(seeds) if seeds else 0 + seeds = seeds[offset:] + seeds[:offset] + releases = [] failures = 0 - for imdb_id, season, episode in seeds[:FEED_LIBRARY_LIMIT]: + processed = 0 + for imdb_id, season, episode in seeds: + if _remaining_feed_timeout(start_time) is None: + debug( + f"[mx] feed budget of {_feed_budget_seconds()}s spent, " + f"skipping {len(seeds) - processed} remaining seeds" + ) + break + processed += 1 try: releases.extend( self._releases_for_imdb( @@ -338,16 +405,33 @@ def feed( host, imdb_id, shared_state, - FEED_REQUEST_TIMEOUT_SECONDS, + lambda: _remaining_feed_timeout(start_time), search_category, season=season, episode=episode, + title_deadline=deadline, ) ) + except FeedBudgetSpent: + # This seed produced nothing, so leave it for the next run. + processed -= 1 + debug( + f"[mx] feed budget of {_feed_budget_seconds()}s spent " + f"while reading {imdb_id}, {len(seeds) - processed} seeds left" + ) + break except Exception as e: failures += 1 debug(f"[mx] feed item {imdb_id} error: {e}") + if seeds and seeds_are_complete: + # An empty seed list means the wanted lookup came back with nothing + # (a transient *arr failure reads as an empty page), which says + # nothing about where the next run should resume. Writing an offset + # for it would send the following healthy pull back to the head. + with self._feed_lock: + self._feed_offsets[base_cat] = (offset + processed) % len(seeds) + if releases: clear_hostname_issue(self.initials) elif failures: @@ -467,3 +551,24 @@ def _to_rfc2822(date_str): return dt.strftime("%a, %d %b %Y %H:%M:%S +0000") except Exception: return datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") + + +def _feed_budget_seconds(): + """The feed budget, capped at the window the response is answered in. + + Slow mode triples the feed timeout while the fan-out deadline stays put, and + a run that outlives the response is discarded work that still advanced the + resume offset - so those seeds would not be retried until the next rotation. + """ + return min(FEED_REQUEST_TIMEOUT_SECONDS, SEARCH_FANOUT_DEADLINE_SECONDS) + + +def _remaining_feed_timeout(start_time): + """Seconds left of the feed's wall-clock budget, or None once it is spent.""" + budget = _feed_budget_seconds() + if start_time is None: + return budget + remaining = budget - (time.time() - start_time) + if remaining <= 0: + return None + return max(0.1, remaining) diff --git a/tests/test_date_numbering_dispatch.py b/tests/test_date_numbering_dispatch.py index 1592477a..76923bc7 100644 --- a/tests/test_date_numbering_dispatch.py +++ b/tests/test_date_numbering_dispatch.py @@ -10,9 +10,10 @@ class FakeSearchExecutor: latest = None - def __init__(self): + def __init__(self, deadline=None): self.searches = [] self.added = [] + self.deadline = deadline FakeSearchExecutor.latest = self def add(self, source, args, kwargs, **options): diff --git a/tests/test_imdb_metadata.py b/tests/test_imdb_metadata.py index f8fa9357..3ee0181c 100644 --- a/tests/test_imdb_metadata.py +++ b/tests/test_imdb_metadata.py @@ -144,7 +144,7 @@ def test_localized_title_always_uses_html_without_proven_arr_locale(self): title = get_localized_title(state, "tt0000004", "de") self.assertEqual("Synthetic German Title", title) - html_lookup.assert_called_once_with("tt0000004", "de") + html_lookup.assert_called_once_with("tt0000004", "de", deadline=None) update_cache.assert_called_once_with( "tt0000004", "localized", "Synthetic German Title", "de" ) @@ -247,7 +247,7 @@ def test_stale_localized_cache_is_refreshed_from_html(self): title = get_localized_title(state, "tt0000007", "de", SEARCH_CAT_MOVIES) self.assertEqual("Fresh Synthetic Title", title) - html_lookup.assert_called_once_with("tt0000007", "de") + html_lookup.assert_called_once_with("tt0000007", "de", deadline=None) stored = loads(self.databases["imdb_metadata"].values["tt0000007"]) self.assertEqual("Fresh Synthetic Title", stored["localized"]["de"]) @@ -345,7 +345,7 @@ def test_cold_arr_non_english_original_falls_back_to_html_for_en(self): title = get_localized_title(state, "tt0000019", "en", SEARCH_CAT_MOVIES) self.assertEqual("Synthetic English Anime Title", title) - html_lookup.assert_called_once_with("tt0000019", "en") + html_lookup.assert_called_once_with("tt0000019", "en", deadline=None) def test_arr_original_language_seeds_matching_code_only(self): record = { @@ -663,7 +663,7 @@ def test_html_lookup_uses_explicit_locale_path(self): self.assertIsNone(IMDbHTML.get_localized_title("tt0000015", "de")) request.assert_called_once_with( - "https://www.imdb.com/de/title/tt0000015/releaseinfo/", "de" + "https://www.imdb.com/de/title/tt0000015/releaseinfo/", "de", deadline=None ) def test_html_lookup_en_uses_unprefixed_default_path(self): @@ -671,7 +671,7 @@ def test_html_lookup_en_uses_unprefixed_default_path(self): self.assertIsNone(IMDbHTML.get_localized_title("tt0000016", "en")) request.assert_called_once_with( - "https://www.imdb.com/title/tt0000016/releaseinfo/", "en" + "https://www.imdb.com/title/tt0000016/releaseinfo/", "en", deadline=None ) def test_html_request_sets_explicit_locale_and_crawler_user_agent(self): diff --git a/tests/test_localized_title_deadline.py b/tests/test_localized_title_deadline.py new file mode 100644 index 00000000..269fc3a8 --- /dev/null +++ b/tests/test_localized_title_deadline.py @@ -0,0 +1,86 @@ +import time +import unittest +from unittest.mock import patch + +from quasarr.providers import imdb_metadata + + +class LocalizedTitleDeadlineTests(unittest.TestCase): + def test_expired_deadline_skips_the_network_fallbacks(self): + # The IMDb HTML request allows 30s and the FlareSolverr fallback 70s, so + # a caller under a wall-clock budget must not enter them once its time + # is up - the work would land long after it stopped waiting. + with ( + patch.object(imdb_metadata, "_get_cached_metadata", return_value=None), + patch.object(imdb_metadata, "_refresh_imdb_metadata") as refresh, + patch.object(imdb_metadata.IMDbHTML, "get_localized_title") as html, + ): + title = imdb_metadata.get_localized_title( + None, "tt0000001", "fr", None, deadline=time.time() - 1 + ) + + self.assertIsNone(title) + refresh.assert_not_called() + html.assert_not_called() + + def test_a_fresh_cache_answers_even_when_out_of_time(self): + cached = { + "ttl": time.time() + 600, + "localized": {"fr": "Un Titre"}, + } + with ( + patch.object(imdb_metadata, "_get_cached_metadata", return_value=cached), + patch.object(imdb_metadata.IMDbHTML, "get_localized_title") as html, + ): + title = imdb_metadata.get_localized_title( + None, "tt0000001", "fr", None, deadline=time.time() - 1 + ) + + self.assertEqual("Un Titre", title) + html.assert_not_called() + + def test_the_deadline_is_rechecked_after_the_arr_refresh(self): + # The Arr refresh costs a request of its own, so a budget that was intact + # on entry can be gone before the far more expensive IMDb fallbacks would + # start. Checking only once on entry would let them run anyway: the clock + # here is inside the budget at the first gate and past it at the second. + with ( + patch.object(imdb_metadata, "_get_cached_metadata", return_value=None), + patch.object( + imdb_metadata, "_refresh_imdb_metadata", return_value=({}, {}) + ) as refresh, + patch.object(imdb_metadata.IMDbHTML, "get_localized_title") as html, + patch.object(imdb_metadata.time, "time", side_effect=[0, 100]), + ): + title = imdb_metadata.get_localized_title( + None, "tt0000001", "fr", None, deadline=10 + ) + + self.assertIsNone(title) + refresh.assert_called_once() + html.assert_not_called() + + def test_the_flaresolverr_fallback_is_skipped_when_time_ran_out(self): + # The direct request can consume what was left. FlareSolverr allows 60s + # of its own on top, which is the stage worth giving up rather than + # starting once the caller has stopped waiting. + def slow_direct(*_args, **_kwargs): + raise RuntimeError("IMDb unreachable") + + with ( + patch("quasarr.providers.imdb_metadata.requests.get", slow_direct), + patch("quasarr.providers.imdb_metadata.requests.post") as post, + patch.object(imdb_metadata.time, "time", return_value=100), + ): + html = imdb_metadata.IMDbHTML._request( + "https://www.imdb.com/fr/title/tt0000001/releaseinfo/", + "fr", + deadline=10, + ) + + self.assertIsNone(html) + post.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mx_source.py b/tests/test_mx_source.py index bea164b6..4945d212 100644 --- a/tests/test_mx_source.py +++ b/tests/test_mx_source.py @@ -1,17 +1,21 @@ +import time import unittest from contextlib import ExitStack +from threading import Thread from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch from quasarr.constants import SEARCH_CAT_MOVIES, SEARCH_CAT_SHOWS from quasarr.downloads.sources.mx import Source as DownloadSource from quasarr.search.sources.mx import ( - Source as SearchSource, -) -from quasarr.search.sources.mx import ( + FeedBudgetSpent, _normalize_quality, + _remaining_feed_timeout, _sanitize, ) +from quasarr.search.sources.mx import ( + Source as SearchSource, +) def make_shared_state(host="source.invalid", radarr=False, sonarr=False): @@ -130,6 +134,22 @@ def fake_get(url, params=None, headers=None, timeout=None): return fake_get +def wanted_returning(items): + """Stand in for a wanted helper whose paging completed. + + The real helpers report completeness through ``status``; a mock that only + returns a list looks like a partial acquisition, which suppresses the resume + offset write. + """ + + def _wanted(_shared_state, limit=50, deadline=None, status=None): + if status is not None: + status["complete"] = True + return list(items) + + return _wanted + + def patch_issue_tracking(stack): """Silence the DB-backed hostname-issue helpers for hermetic tests.""" stack.enter_context(patch("quasarr.search.sources.mx.clear_hostname_issue")) @@ -289,7 +309,7 @@ def test_movie_feed_seeds_from_radarr_wanted(self): return_value=["tt0000001"], ) ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_MOVIES) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_MOVIES) self.assertEqual(len(releases), 1) self.assertEqual(releases[0]["details"]["imdb_id"], "tt0000001") @@ -319,7 +339,7 @@ def test_show_feed_seeds_from_sonarr_wanted(self): return_value=[{"imdb_id": "tt0000002", "season": 1, "episode": 1}], ) ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_SHOWS) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_SHOWS) self.assertEqual(len(releases), 1) self.assertIn("S01E01", releases[0]["details"]["title"]) # Sonarr's season+episode reached the source download endpoint. @@ -337,7 +357,7 @@ def test_show_feed_warns_and_skips_without_sonarr(self): episodes = stack.enter_context( patch("quasarr.search.sources.mx.sonarr_api.get_wanted_episodes") ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_SHOWS) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_SHOWS) self.assertEqual(releases, []) get.assert_not_called() episodes.assert_not_called() @@ -352,7 +372,7 @@ def test_movie_feed_warns_and_skips_without_radarr(self): ids = stack.enter_context( patch("quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids") ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_MOVIES) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_MOVIES) self.assertEqual(releases, []) get.assert_not_called() ids.assert_not_called() @@ -384,7 +404,7 @@ def boom(*a, **k): return_value=["tt0000001"], ) ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_MOVIES) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_MOVIES) self.assertEqual(releases, []) mark.assert_called_once() clear.assert_not_called() @@ -416,11 +436,229 @@ def test_feed_silent_when_nothing_available(self): return_value=["tt0000001"], ) ) - releases = SearchSource().feed(ss, 0.0, SEARCH_CAT_MOVIES) + releases = SearchSource().feed(ss, time.time(), SEARCH_CAT_MOVIES) self.assertEqual(releases, []) mark.assert_not_called() clear.assert_not_called() + def test_feed_stops_at_budget_and_resumes_where_it_left_off(self): + # Each seed costs several round trips, so a full wanted list can outlast + # the *arr client's request timeout. The run must stop at the feed budget + # and the next run must continue with the seeds it did not reach. + ss = make_shared_state(radarr=True) + source = SearchSource() + wanted = ["tt0000001", "tt0000002", "tt0000003", "tt0000004"] + queried = [] + + def fake_lookup(_self, _api_base, _host, imdb_id, *args, **kwargs): + queried.append(imdb_id) + return [] + + def run(): + with ExitStack() as stack: + stack.enter_context( + patch.object(SearchSource, "_releases_for_imdb", fake_lookup) + ) + patch_issue_tracking(stack) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + wanted_returning(wanted), + ) + ) + stack.enter_context( + patch("quasarr.search.sources.mx.FEED_REQUEST_TIMEOUT_SECONDS", 30) + ) + # Two seeds fit in the budget, the third finds it spent. + stack.enter_context( + patch( + "quasarr.search.sources.mx.time.time", + side_effect=[0.0, 1.0, 31.0, 31.0], + ) + ) + source.feed(ss, 0.0, SEARCH_CAT_MOVIES) + + run() + self.assertEqual(["tt0000001", "tt0000002"], queried) + + queried.clear() + run() + self.assertEqual(["tt0000003", "tt0000004"], queried) + + def test_budget_spent_mid_seed_leaves_that_seed_for_the_next_run(self): + # A seed whose own round trips ran into the budget produced nothing, so + # it must be retried rather than skipped - and a spent budget is not a + # source outage. + ss = make_shared_state(radarr=True) + source = SearchSource() + queried = [] + + def fake_lookup(_self, _api_base, _host, imdb_id, *args, **kwargs): + queried.append(imdb_id) + raise FeedBudgetSpent() + + def run(mark): + with ExitStack() as stack: + stack.enter_context( + patch.object(SearchSource, "_releases_for_imdb", fake_lookup) + ) + stack.enter_context( + patch("quasarr.search.sources.mx.clear_hostname_issue") + ) + stack.enter_context( + patch("quasarr.search.sources.mx.mark_hostname_issue", mark) + ) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + return_value=["tt0000001", "tt0000002"], + ) + ) + source.feed(ss, time.time(), SEARCH_CAT_MOVIES) + + mark = MagicMock() + run(mark) + run(mark) + self.assertEqual(["tt0000001", "tt0000001"], queried) + mark.assert_not_called() + + def test_concurrent_feed_runs_leave_a_consistent_resume_offset(self): + # The registry hands out one shared Source, so feed runs for the same + # category can overlap on the offset. Whatever interleaving happens, the + # stored offset has to be a value a run actually reached. + ss = make_shared_state(radarr=True) + source = SearchSource() + wanted = ["tt1", "tt2", "tt3", "tt4"] + + def fake_lookup(*_args, **_kwargs): + return [] + + def run(): + with ExitStack() as stack: + stack.enter_context( + patch.object(SearchSource, "_releases_for_imdb", fake_lookup) + ) + patch_issue_tracking(stack) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + wanted_returning(wanted), + ) + ) + source.feed(ss, time.time(), SEARCH_CAT_MOVIES) + + threads = [Thread(target=run) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(20) + + self.assertIn(source._feed_offsets[SEARCH_CAT_MOVIES], range(len(wanted) + 1)) + + def test_a_title_lookup_that_ran_out_of_time_keeps_its_seed(self): + # No title because the budget went during the lookup, not because none + # exists. The seed was never answered, so it keeps its place instead of + # being rotated past: the clock is inside the budget at the loop check + # and past it by the time the title comes back empty. + ss = make_shared_state(radarr=True) + source = SearchSource() + + with ExitStack() as stack: + patch_issue_tracking(stack) + stack.enter_context( + patch( + "quasarr.search.sources.mx.get_localized_title", + return_value=None, + ) + ) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + wanted_returning(["tt0000001", "tt0000002"]), + ) + ) + stack.enter_context( + patch( + "quasarr.search.sources.mx.time.time", + side_effect=[0, 100, 100], + ) + ) + source.feed(ss, 0.0, SEARCH_CAT_MOVIES) + + self.assertEqual(0, source._feed_offsets[SEARCH_CAT_MOVIES]) + + def test_partial_seed_acquisition_keeps_the_resume_offset(self): + # Wanted paging stops at the same deadline, so a short list can be a + # partial view. Normalizing the stored cursor against it (40 % 10 = 0) + # and writing that back would drag the rotation to the head. + ss = make_shared_state(radarr=True) + source = SearchSource() + source._feed_offsets[SEARCH_CAT_MOVIES] = 40 + + with ExitStack() as stack: + patch_issue_tracking(stack) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + return_value=[f"tt{i}" for i in range(10)], + ) + ) + # The helper reported no completion, the mark of an acquisition that + # was cut short by a spent budget or a failed page. + source.feed(ss, time.time(), SEARCH_CAT_MOVIES) + + self.assertEqual(40, source._feed_offsets[SEARCH_CAT_MOVIES]) + + def test_a_failed_seed_lookup_keeps_the_resume_offset(self): + # A transient Radarr failure reads as an empty page, so the wanted helper + # returns no seeds. That says nothing about where to resume, and writing + # an offset for it would send the next healthy pull back to the head. + ss = make_shared_state(radarr=True) + source = SearchSource() + source._feed_offsets[SEARCH_CAT_MOVIES] = 3 + + with ExitStack() as stack: + patch_issue_tracking(stack) + stack.enter_context( + patch( + "quasarr.search.sources.mx.radarr_api.get_wanted_imdb_ids", + return_value=[], + ) + ) + source.feed(ss, time.time(), SEARCH_CAT_MOVIES) + + self.assertEqual(3, source._feed_offsets[SEARCH_CAT_MOVIES]) + + def test_feed_budget_never_outlives_the_fan_out_deadline(self): + # Slow mode triples the feed timeout, but the window the response is + # answered in does not grow with it. A run past that window is discarded + # while still having advanced the resume offset, so its seeds would not + # come round again until the next full rotation. + with ExitStack() as stack: + stack.enter_context( + patch("quasarr.search.sources.mx.FEED_REQUEST_TIMEOUT_SECONDS", 90) + ) + stack.enter_context( + patch("quasarr.search.sources.mx.SEARCH_FANOUT_DEADLINE_SECONDS", 60) + ) + stack.enter_context( + patch("quasarr.search.sources.mx.time.time", return_value=61.0) + ) + self.assertIsNone(_remaining_feed_timeout(0.0)) + + def test_get_raises_once_the_budget_callable_reports_it_is_spent(self): + # The per-seed budget is re-read before every round trip, so a seed with + # many links cannot outrun it by reusing the value it started with. + with self.assertRaises(FeedBudgetSpent): + SearchSource()._get( + "https://api.source.invalid/api", + "source.invalid", + "/search", + {}, + make_shared_state(), + lambda: None, + ) + class MxSpecialsTests(unittest.TestCase): def test_season_zero_special_is_tagged(self): diff --git a/tests/test_radarr_wanted.py b/tests/test_radarr_wanted.py index 21d6f7fc..799f35ac 100644 --- a/tests/test_radarr_wanted.py +++ b/tests/test_radarr_wanted.py @@ -1,5 +1,7 @@ +import time import unittest from types import SimpleNamespace +from unittest.mock import patch from quasarr.providers import radarr_api @@ -10,7 +12,7 @@ class FakeClient: def __init__(self, missing, cutoff): self._records = {"missing": missing, "cutoff": cutoff} - def wanted(self, kind, page=1, page_size=50): + def wanted(self, kind, page=1, page_size=50, timeout=None): return {"records": self._records.get(kind, []) if page == 1 else []} @@ -20,7 +22,7 @@ class PagedClient: def __init__(self, pages): self._pages = pages - def wanted(self, kind, page=1, page_size=50): + def wanted(self, kind, page=1, page_size=50, timeout=None): return {"records": self._pages.get((kind, page), [])} @@ -67,6 +69,83 @@ def test_pages_past_an_all_unreleased_page(self): ) self.assertEqual(ids, ["tt3"]) + def test_paging_stops_at_the_caller_deadline(self): + # Every page is its own Radarr request, so a caller that is itself under + # a wall-clock budget must not have it spent on paging alone. + pages = { + ("missing", 1): [{"imdbId": "tt1", "status": "announced"}], + ("missing", 2): [{"imdbId": "tt2", "status": "released"}], + } + ids = radarr_api.get_wanted_imdb_ids( + shared_state_with(PagedClient(pages)), + limit=5, + deadline=time.time() - 1, + ) + self.assertEqual(ids, []) + + def test_page_request_timeout_is_clamped_to_the_deadline(self): + # The deadline check passes just before the last page, so that request + # must carry the time that is left rather than the client's full timeout. + class TimeoutRecordingClient: + def __init__(self): + self.timeouts = [] + + def wanted(self, kind, page=1, page_size=50, timeout=None): + self.timeouts.append(timeout) + return {"records": []} + + client = TimeoutRecordingClient() + radarr_api.get_wanted_imdb_ids( + shared_state_with(client), limit=5, deadline=time.time() + 2 + ) + self.assertTrue(client.timeouts) + self.assertLessEqual(client.timeouts[0], 2) + self.assertGreater(client.timeouts[0], 0) + + def test_a_caller_timeout_only_tightens_the_client_timeout(self): + # The deadline says how much of the caller's budget is left, never that + # this request may take longer than the client allows. + client = radarr_api.RadarrAPIClient("http://arr.invalid", "key") + seen = [] + + def fake_get(url, headers=None, params=None, timeout=None): + seen.append(timeout) + raise RuntimeError("no network in tests") + + with patch("quasarr.providers.radarr_api.requests.get", fake_get): + client.wanted("missing", timeout=600) + client.wanted("missing", timeout=2) + client.wanted("missing") + + self.assertEqual([10, 2, 10], seen) + + def test_a_failed_page_reports_partial_paging(self): + # A failed page request is not the end of the list. Callers that persist + # progress must be able to tell "that was everything" from "that was as + # far as I got". + class FailingSecondPage: + def wanted(self, kind, page=1, page_size=50, timeout=None): + if page == 1: + return {"records": [{"imdbId": "tt1", "status": "released"}]} + return None # request failed + + status = {} + radarr_api.get_wanted_imdb_ids( + shared_state_with(FailingSecondPage()), limit=50, status=status + ) + self.assertFalse(status["complete"]) + + def test_a_complete_walk_reports_complete(self): + status = {} + radarr_api.get_wanted_imdb_ids( + shared_state_with( + FakeClient([{"imdbId": "tt1", "status": "released"}], []) + ), + limit=50, + status=status, + ) + self.assertTrue(status["complete"]) + def test_empty_without_client(self): self.assertEqual(radarr_api.get_wanted_imdb_ids(SimpleNamespace(values={})), []) diff --git a/tests/test_search_fanout_deadline.py b/tests/test_search_fanout_deadline.py new file mode 100644 index 00000000..2a6b5d7c --- /dev/null +++ b/tests/test_search_fanout_deadline.py @@ -0,0 +1,170 @@ +import time +import unittest +from threading import Barrier, Event +from types import SimpleNamespace +from unittest.mock import patch + +from quasarr.constants import SEARCH_CAT_MOVIES +from quasarr.search import SearchExecutor, get_search_results + + +class FakeSource: + def __init__(self, initials, func): + self.initials = initials + self._func = func + + def search(self, *args, **kwargs): + return self._func() + + +class SearchFanoutDeadlineTests(unittest.TestCase): + def test_slow_source_is_dropped_instead_of_stalling_the_response(self): + # A source that outlives the fan-out deadline must not hold up the + # response: *arr clients disable an indexer that answers too late. + release = Event() + self.addCleanup(release.set) + + def fast(): + return [{"details": {"title": "Fast.Release"}}] + + def slow(): + release.wait(30) + return [{"details": {"title": "Slow.Release"}}] + + executor = SearchExecutor(deadline=time.time() + 0.5) + executor.add(FakeSource("fa", fast), (None, 0.0, 2000), {}) + executor.add(FakeSource("sl", slow), (None, 0.0, 2000), {}) + + started = time.time() + results, bar, _, _ = executor.run_all() + elapsed = time.time() - started + + self.assertLess(elapsed, 10) + self.assertEqual(["Fast.Release"], [r["details"]["title"] for r in results]) + self.assertIn("FA", bar) + self.assertIn("SL", bar) + + def test_every_source_within_the_deadline_is_collected(self): + def make(title): + return lambda: [{"details": {"title": title}}] + + executor = SearchExecutor() + executor.add(FakeSource("aa", make("A")), (None, 0.0, 2000), {}) + executor.add(FakeSource("bb", make("B")), (None, 0.0, 2000), {}) + + results, bar, _, _ = executor.run_all() + + self.assertEqual({"A", "B"}, {r["details"]["title"] for r in results}) + self.assertIn("AA", bar) + self.assertIn("BB", bar) + + def test_a_shared_deadline_is_not_restarted_by_the_next_run(self): + # A multi-category request runs cache-sharing categories one after + # another. Each run must inherit the one deadline the request started + # with, or two categories add up to twice the wait the *arr client + # allows. + release = Event() + self.addCleanup(release.set) + + def slow(): + release.wait(30) + return [{"details": {"title": "Slow.Release"}}] + + deadline = time.time() + 0.5 + started = time.time() + for _ in range(2): + executor = SearchExecutor(deadline=deadline) + executor.add(FakeSource("sl", slow), (None, 0.0, 2000), {}) + results, _, _, _ = executor.run_all() + self.assertEqual([], results) + elapsed = time.time() - started + + # Two runs off one 0.5s deadline, not 0.5s each. + self.assertLess(elapsed, 1.0) + + def test_an_expired_deadline_starts_no_work_at_all(self): + # The first category can use up a shared deadline. Submitting anyway + # would hit the source for a result this response can no longer use. + started = [] + + def never_wanted(): + started.append(True) + return [{"details": {"title": "Too.Late"}}] + + executor = SearchExecutor(deadline=time.time() - 1) + executor.add(FakeSource("sl", never_wanted), (None, 0.0, 2000), {}) + + results, bar, _, _ = executor.run_all() + + self.assertEqual([], started) + self.assertEqual([], results) + self.assertIn("SL", bar) + + def test_every_source_gets_a_worker_regardless_of_cpu_count(self): + # The default pool is sized from the CPU count, so on a small host the + # sources dispatched last would wait in the queue and be dropped by the + # deadline without ever having run. The barrier only clears if all of + # them are running at the same time. + count = 40 + barrier = Barrier(count, timeout=10) + + def blocked(): + barrier.wait() + return [{"details": {"title": "ok"}}] + + executor = SearchExecutor() + for index in range(count): + executor.add(FakeSource(f"s{index}", blocked), (None, 0.0, 2000), {}) + + results, _, _, _ = executor.run_all() + + self.assertEqual(count, len(results)) + + +class MetadataWarmingDeadlineTests(unittest.TestCase): + def test_an_expired_deadline_skips_imdb_metadata_warming(self): + # A failed refresh is not cached, so every category of a multi-category + # request would pay the Arr client timeout again, past the ceiling the + # deadline is there to hold. + state = SimpleNamespace( + values={"config": lambda _section: {}, "radarr_client": object()} + ) + + with ( + patch("quasarr.search.get_imdb_metadata") as warm, + patch("quasarr.search.get_search_category_sources", return_value=[]), + patch("quasarr.search.get_sources", return_value={}), + ): + get_search_results( + state, + "radarr", + SEARCH_CAT_MOVIES, + imdb_id="tt0000010", + deadline=time.time() - 1, + ) + + warm.assert_not_called() + + def test_metadata_warming_still_runs_inside_the_deadline(self): + state = SimpleNamespace( + values={"config": lambda _section: {}, "radarr_client": object()} + ) + + with ( + patch("quasarr.search.get_imdb_metadata") as warm, + patch("quasarr.search.get_search_category_sources", return_value=[]), + patch("quasarr.search.get_sources", return_value={}), + ): + get_search_results( + state, + "radarr", + SEARCH_CAT_MOVIES, + imdb_id="tt0000010", + deadline=time.time() + 60, + ) + + warm.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sonarr_wanted.py b/tests/test_sonarr_wanted.py index 9725a85f..95bd113b 100644 --- a/tests/test_sonarr_wanted.py +++ b/tests/test_sonarr_wanted.py @@ -1,5 +1,7 @@ +import time import unittest from types import SimpleNamespace +from unittest.mock import patch from quasarr.providers import sonarr_api @@ -22,7 +24,7 @@ class FakeClient: def __init__(self, missing, cutoff): self._records = {"missing": missing, "cutoff": cutoff} - def wanted(self, kind, page=1, page_size=50): + def wanted(self, kind, page=1, page_size=50, timeout=None): return {"records": self._records.get(kind, []) if page == 1 else []} @@ -30,7 +32,7 @@ class PagedClient: def __init__(self, pages): self._pages = pages - def wanted(self, kind, page=1, page_size=50): + def wanted(self, kind, page=1, page_size=50, timeout=None): return {"records": self._pages.get((kind, page), [])} @@ -65,6 +67,81 @@ def test_pages_past_unaired(self): ) self.assertEqual(got, [{"imdb_id": "tt9", "season": 3, "episode": 3}]) + def test_paging_stops_at_the_caller_deadline(self): + # Every page is its own Sonarr request, so a caller that is itself under + # a wall-clock budget must not have it spent on paging alone. + pages = { + ("missing", 1): [ep("tt1", 1, 1, FUTURE)], + ("missing", 2): [ep("tt9", 3, 3, PAST)], + } + got = sonarr_api.get_wanted_episodes( + shared_state_with(PagedClient(pages)), + limit=5, + deadline=time.time() - 1, + ) + self.assertEqual(got, []) + + def test_page_request_timeout_is_clamped_to_the_deadline(self): + # The deadline check passes just before the last page, so that request + # must carry the time that is left rather than the client's full timeout. + class TimeoutRecordingClient: + def __init__(self): + self.timeouts = [] + + def wanted(self, kind, page=1, page_size=50, timeout=None): + self.timeouts.append(timeout) + return {"records": []} + + client = TimeoutRecordingClient() + sonarr_api.get_wanted_episodes( + shared_state_with(client), limit=5, deadline=time.time() + 2 + ) + self.assertTrue(client.timeouts) + self.assertLessEqual(client.timeouts[0], 2) + self.assertGreater(client.timeouts[0], 0) + + def test_a_caller_timeout_only_tightens_the_client_timeout(self): + # The deadline says how much of the caller's budget is left, never that + # this request may take longer than the client allows. + client = sonarr_api.SonarrAPIClient("http://arr.invalid", "key") + seen = [] + + def fake_get(url, headers=None, params=None, timeout=None): + seen.append(timeout) + raise RuntimeError("no network in tests") + + with patch("quasarr.providers.sonarr_api.requests.get", fake_get): + client.wanted("missing", timeout=600) + client.wanted("missing", timeout=2) + client.wanted("missing") + + self.assertEqual([10, 2, 10], seen) + + def test_a_failed_page_reports_partial_paging(self): + # A failed page request is not the end of the list. Callers that persist + # progress must be able to tell "that was everything" from "that was as + # far as I got". + class FailingSecondPage: + def wanted(self, kind, page=1, page_size=50, timeout=None): + if page == 1: + return {"records": [ep("tt1", 1, 1, PAST)]} + return None # request failed + + status = {} + sonarr_api.get_wanted_episodes( + shared_state_with(FailingSecondPage()), limit=50, status=status + ) + self.assertFalse(status["complete"]) + + def test_a_complete_walk_reports_complete(self): + status = {} + sonarr_api.get_wanted_episodes( + shared_state_with(FakeClient([ep("tt1", 1, 1, PAST)], [])), + limit=50, + status=status, + ) + self.assertTrue(status["complete"]) + def test_empty_without_client(self): self.assertEqual(sonarr_api.get_wanted_episodes(SimpleNamespace(values={})), []) diff --git a/uv.lock b/uv.lock index 859cfddf..d8dd37b5 100644 --- a/uv.lock +++ b/uv.lock @@ -223,11 +223,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { 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]] @@ -312,11 +312,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, ] [[package]] @@ -388,7 +388,7 @@ wheels = [ [[package]] name = "pyinstaller" -version = "6.21.0" +version = "6.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "altgraph" }, @@ -399,19 +399,19 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/03/669d06735cf57d7e2e5dfc3c2e1643554b34c559eff21449c58172ca0335/pyinstaller-6.22.0.tar.gz", hash = "sha256:8b0166fff4583b374bbe7fa044bf03ddc33cab0f792a665807d96048e63f060e", size = 4072247, upload-time = "2026-08-08T13:34:52.418Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, - { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, - { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, + { url = "https://files.pythonhosted.org/packages/da/c7/abe1ca65ab55e615961df1b978b997129a8488ab9dc0931dc7664bdc4c99/pyinstaller-6.22.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d7c7c5b149f7f9b37585b37e2376f774038b6a5633ba34db0de2698f75391e13", size = 1058495, upload-time = "2026-08-08T13:33:46.963Z" }, + { url = "https://files.pythonhosted.org/packages/17/d0/b7874153bd034fcfe0f47abe1140b49d436e262a3ad18cd52ba86be6358c/pyinstaller-6.22.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f92f78a9c463b884d57f90c7459c0cb263ccd26a499356bbfac7a5f41fd5b93f", size = 752753, upload-time = "2026-08-08T13:33:51.247Z" }, + { url = "https://files.pythonhosted.org/packages/b9/45/810aee0ad8c137667f2685783cde14c8517357ba08e1c54599ba77547578/pyinstaller-6.22.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f6c750c8ae999cc5520ddbce4263ac3ece96db04ba359ef20acacd81bccc201b", size = 765543, upload-time = "2026-08-08T13:33:55.44Z" }, + { url = "https://files.pythonhosted.org/packages/96/36/24af3e59ca090ae1c0ba9daaeaa10125e99b767df5e3ab06384b516ff1e7/pyinstaller-6.22.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0a20c37761851100f422ec261f513f46d8e57100ea22886b9ff7a6c346104bd8", size = 764130, upload-time = "2026-08-08T13:33:59.623Z" }, + { url = "https://files.pythonhosted.org/packages/45/17/92cb544e29ad867bcf5a06efd63dcf93af7c662f2121333b99409615b50b/pyinstaller-6.22.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:e6859e80b9c7dccb8a1687c5ed4644a722b370c2aa13e8a9ca5ebf5f9bb842ce", size = 759221, upload-time = "2026-08-08T13:34:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/3733a6f590075c8a6678072480ee22f7b0237f7952b2c1521d5aafa15514/pyinstaller-6.22.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b07ac9fd1d661ae4d0af6752c3941e4e5576a16cfe7e2d6504a32d57e17d0acc", size = 759087, upload-time = "2026-08-08T13:34:08.012Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b0/300a00798920d5e8e52f71fb0ceb2955a71c24814eaf6c086aeb95944049/pyinstaller-6.22.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:7885de3c830356b0990b27fe164f2b008fb4a18560bac884cf33985f61469f44", size = 758597, upload-time = "2026-08-08T13:34:12.164Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ba/3c5f8cbe3686f8080eadadee10a0da4c7af3eaff72998165d5f5174f5a40/pyinstaller-6.22.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:81dc305c16510c1f9ead6d115b66f381e3d3453ecb96653099c51aa3050420d1", size = 758149, upload-time = "2026-08-08T13:34:16.381Z" }, + { url = "https://files.pythonhosted.org/packages/b6/66/f7de05fa65b141cbd83bf61f56730b3f5249820a4de026e11feadffca3c8/pyinstaller-6.22.0-py3-none-win32.whl", hash = "sha256:6f527c1345d5010682587aebc2f0c1cc34a9b1f200424969c7bfda723cddc074", size = 1341311, upload-time = "2026-08-08T13:34:23.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a7/0ea60e966a0da82961ecba80177627fae28fdda5127993281db479ee46fc/pyinstaller-6.22.0-py3-none-win_amd64.whl", hash = "sha256:6e5f3656de100954bf5db25536c43e097e46d482843a96d03a0852bf266e4853", size = 1403046, upload-time = "2026-08-08T13:34:30.352Z" }, + { url = "https://files.pythonhosted.org/packages/47/9a/71fbfdeffc60107c8256436ea62eb39dd35266164f721091242924363747/pyinstaller-6.22.0-py3-none-win_arm64.whl", hash = "sha256:b1233da63926be213cdfbfbb314735bc7762136ed772b1082e1b0a2d7ed896b5", size = 1350592, upload-time = "2026-08-08T13:34:36.532Z" }, ] [[package]] @@ -600,45 +600,45 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] name = "soupsieve" -version = "2.9.1" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.7.1" +version = "21.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -669,9 +669,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/10/8b7a5454efc032be50c1c5641467dbb5d31314500205212b2aa66d3c8af4/virtualenv-21.7.3.tar.gz", hash = "sha256:5e9e287f5c808070eea3b40403d3368248e64b24f1b60bd9a36e85ac841d2c3e", size = 5525482, upload-time = "2026-08-08T14:43:20.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl", hash = "sha256:26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773", size = 5504590, upload-time = "2026-08-08T14:43:18.57Z" }, ] [[package]]