diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index a438d546..5ea56f47 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -1650,24 +1650,6 @@ "endColumn": 36, "lineCount": 1 } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 46, - "endColumn": 52, - "lineCount": 1 - } - } - ], - "./app/youtube/rss.py": [ - { - "code": "reportGeneralTypeIssues", - "range": { - "startColumn": 10, - "endColumn": 15, - "lineCount": 1 - } } ] } diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py index d2847387..5b4dc3a5 100644 --- a/backend/app/bodylimit.py +++ b/backend/app/bodylimit.py @@ -219,7 +219,7 @@ class BodyLimitMiddleware: try: await self.app(scope, counting_receive, guarded_send) - except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below + except Exception: # only swallowed when WE caused it, see below # ClientDisconnect (or whatever a handler raises on a truncated read) is the expected # consequence of the disconnect we just faked, and the caller must get the 413 rather # than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index d4967169..24498872 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -400,7 +400,7 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label try: proc.wait(timeout=grace_s) except subprocess.TimeoutExpired: - # noqa TRY400: the exception IS the message here. A TimeoutExpired's traceback is this + # TRY400: the exception IS the message here. A TimeoutExpired's traceback is this # `wait` call and nothing else — it would add three frames and no fact the line lacks. log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400 @@ -456,7 +456,7 @@ def run_ffmpeg( try: ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S) except subprocess.TimeoutExpired: # pipes closed but the process lingers - raise StatedFailure("ffmpeg did not exit after closing its output") + raise StatedFailure("ffmpeg did not exit after closing its output") from None except BaseException: # ONE exit path stops the process, so the invariant is unconditional: nothing leaves this # function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress` diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py index ac87b6c1..b609e32e 100644 --- a/backend/app/downloads/runner.py +++ b/backend/app/downloads/runner.py @@ -57,7 +57,7 @@ def main() -> None: logs.configure_logging("runner") _deprioritize() if len(sys.argv) != 2: - # noqa T201: a usage line is for the person who typed the command. Logging is already + # T201: a usage line is for the person who typed the command. Logging is already # configured here, and in a deployment it writes JSON records — the one reader of this # line wants the bare sentence, not a record with a request id they do not have. print( # noqa: T201 @@ -104,7 +104,7 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) -> else: _download(job_id, asset_id, source_kind, source_ref, spec, user_id) except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw - # noqa TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw. + # TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw. log.error( # noqa: TRY400 "the download child for job %s failed", job_id, diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index f612b8ca..84cfe50f 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -438,7 +438,7 @@ def feed_summary(db: Session, user: User, **params) -> dict: # Drop this category's own selections so its chips keep independent counts; a conjunctive # topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected). exclude = None if tag_modes.get(category, "or") == "and" else category - for cid, ch_tags in channel_tags.items(): + for ch_tags in channel_tags.values(): if not passes(ch_tags, exclude): continue for tid in ch_tags: @@ -671,8 +671,8 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | raw = payload["k"] if len(raw) != len(keys): raise ValueError("key arity mismatch") - vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)] - for k, v in zip(keys, vals): + vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw, strict=True)] + for k, v in zip(keys, vals, strict=True): if v is None and not k["nullable"]: raise ValueError("null value for a non-nullable key") window = payload.get("w") diff --git a/backend/app/issues/gitea.py b/backend/app/issues/gitea.py index 6d462296..b492de71 100644 --- a/backend/app/issues/gitea.py +++ b/backend/app/issues/gitea.py @@ -239,7 +239,7 @@ def deliver( # already open on the tracker and this is what would have recorded its link. We # still must not write — another send owns the row and is opening its own issue — # so say which issue was left behind, because nothing else will. - # noqa TRY400: the traceback is one SendClaimLost carrying the session id and + # TRY400: the traceback is one SendClaimLost carrying the session id and # nothing else; the issue number below is the whole news. log.error( # noqa: TRY400 "an issue was created by a send that had already lost its claim", diff --git a/backend/app/logs.py b/backend/app/logs.py index 64746cf5..d25d5380 100644 --- a/backend/app/logs.py +++ b/backend/app/logs.py @@ -166,7 +166,7 @@ def bind(**fields: Any): try: yield finally: - for key, token in zip(keys, tokens): + for key, token in zip(keys, tokens, strict=True): _CONTEXT_VARS[key].reset(token) diff --git a/backend/app/main.py b/backend/app/main.py index 8f52f23f..a9068e9d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -77,7 +77,7 @@ async def lifespan(app: FastAPI): # booting — an unreachable or not-yet-migrated database has to start into the env level. try: set_level(sysconfig.get_str(db, "log_level")) - except Exception: # noqa: BLE001 — any DB/config failure keeps the bootstrap level + except Exception: # any DB/config failure keeps the bootstrap level # With the traceback: this catch spans a connection, a query and a coercion at boot, # and `%s` of the exception does not say which of them raised. log.exception("could not read the stored log level; staying at %s", current_level()) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 656dabd9..b25d1e24 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -245,6 +245,9 @@ def _probe_video_kind(src: Path) -> str: capture_output=True, text=True, timeout=10, + # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch + # below), not an answer: whatever it printed before refusing is not the file's truth. + check=True, ) except (subprocess.SubprocessError, OSError) as exc: # The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind @@ -502,6 +505,9 @@ def _probe_audio_langs(src: Path) -> list[str]: capture_output=True, text=True, timeout=10, + # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch + # below), not an answer: whatever it printed before refusing is not the file's truth. + check=True, ) return [ln.strip() for ln in out.stdout.splitlines()] except (subprocess.SubprocessError, OSError) as exc: @@ -615,6 +621,9 @@ def _probe_first_pts(seg: Path) -> float | None: capture_output=True, text=True, timeout=10, + # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch + # below), not an answer: whatever it printed before refusing is not the file's truth. + check=True, ) for line in out.stdout.splitlines(): tok = line.strip().strip(",") diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py index 22f2a3ce..29059f94 100644 --- a/backend/app/plex/subtitles.py +++ b/backend/app/plex/subtitles.py @@ -162,7 +162,8 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str: "webvtt", "pipe:1", ] - r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S) + # check=False: the exit status is read on the next line, with ffmpeg's own stderr as the reason. + r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S, check=False) if r.returncode != 0 or not r.stdout: raise PlexError( (r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed" diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 90f358b9..1dbd038e 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -273,7 +273,7 @@ def sync(db: Session) -> dict: # becomes filterable over successive runs (the cheap listing above only stored the top ~3). try: stats["people_enriched"] = _enrich_people_batch(db, plex) - except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill + except Exception: # best-effort; never fail the sync over a backfill # With the traceback: a blind catch over a batch that fetches AND writes can be # a Plex shape change, a DB error or our own bug, and `%s` told them apart badly. log.exception("the Plex people-enrich batch failed") @@ -282,7 +282,7 @@ def sync(db: Session) -> dict: # successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec. try: stats["media_probed"] = _probe_media_batch(db, plex) - except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill + except Exception: # best-effort; never fail the sync over a backfill log.exception("the Plex media-probe batch failed") # silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which # logs the job result, and to the admin route, which answers it to the caller. @@ -296,7 +296,7 @@ def sync(db: Session) -> dict: # appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata. try: stats["people_pruned"] = people.prune_orphan_people(db) - except Exception: # noqa: BLE001 — best-effort housekeeping; never fail the sync over it + except Exception: # best-effort housekeeping; never fail the sync over it log.exception("the Plex people prune failed") log.info("Plex sync done: %s", stats) return stats diff --git a/backend/app/progress.py b/backend/app/progress.py index b6e3f900..98126e7b 100644 --- a/backend/app/progress.py +++ b/backend/app/progress.py @@ -8,7 +8,7 @@ its own thread/context) never cross wires. When no sink is bound (e.g. a manual """ import contextvars -from typing import Callable, Optional +from typing import Callable, Literal, Optional Sink = Callable[[int, Optional[int], Optional[str]], None] @@ -48,7 +48,9 @@ class bind: self._token = _sink.set(self._sink) return self - def __exit__(self, *exc) -> bool: + # Literal[False], not bool: this never swallows the job's exception, and saying so is what lets + # a type checker know a name assigned inside `with bind(...)` is bound after it. + def __exit__(self, *exc) -> Literal[False]: if self._token is not None: _sink.reset(self._token) return False diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 3f63dfa9..31147bf5 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -436,7 +436,7 @@ def explore_channel( channel = db.get(Channel, channel_id) result = explore_ingest_page(db, yt, channel, page_token) except YouTubeError as exc: - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc return { "ok": True, "channel_id": channel_id, @@ -597,7 +597,7 @@ def subscribe( if "subscriptionDuplicate" in exc.reason.split(","): yt_sub_id = "" else: - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right # away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to # render. Keep the client open (we're still inside the `with`); a metadata failure here @@ -658,7 +658,7 @@ def unsubscribe( except YouTubeError as exc: # 422 (a real, explainable error → speaking modal), not 502 which the client treats as # a transient "connection lost" gateway blip. - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc db.delete(sub) db.commit() return {"ok": True, "unsubscribed": channel_id} diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 2de01fc8..0d61f99e 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -170,7 +170,7 @@ def add_pin( # between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed # item), which trips the FK. Semantically that is the same "no such thing" the pre-check # returns above — re-raising handed the user a 500 they could do nothing with. - raise not_found("item") + raise not_found("item") from None return {"ok": True, "id": winner.id, "created": False} return {"ok": True, "id": pin.id, "created": True} diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index af75086e..d9a11554 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -322,7 +322,7 @@ def set_credential( try: cleaned = dlcreds.validate_cookies(body.cookies) except dlcreds.InvalidCookies as e: - raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) + raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e dlcreds.upsert_credential(db, user.id, host_key, cleaned) audit.record( db, @@ -407,7 +407,7 @@ def enqueue_download( try: job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name) except quota.QuotaExceeded as e: - raise HTTPException(status_code=422, detail=_quota_detail(e)) + raise HTTPException(status_code=422, detail=_quota_detail(e)) from e return _serialize_job(db, job) @@ -520,7 +520,7 @@ def upload_download( display_name=upload.display_name, ) except quota.QuotaExceeded as e: - raise HTTPException(status_code=422, detail=_quota_detail(e)) + raise HTTPException(status_code=422, detail=_quota_detail(e)) from e return _serialize_job(db, job) @@ -547,9 +547,9 @@ def enqueue_edit( try: job = service.enqueue_edit(db, user.id, source_job, spec, display_name) except quota.QuotaExceeded as e: - raise HTTPException(status_code=422, detail=_quota_detail(e)) + raise HTTPException(status_code=422, detail=_quota_detail(e)) from e except service.EditError as e: - raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) + raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e return _serialize_job(db, job) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 4e905e0c..46c62601 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -133,7 +133,7 @@ def get_feed( # Expose each key column on the row so we can build the next cursor from the last item. query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)]) if vals is not None: - for k, v in zip(keys, vals): + for k, v in zip(keys, vals, strict=True): k["value"] = v if ranked and window is not None: rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1) @@ -431,7 +431,7 @@ def get_video_detail( ): items = yt.get_videos([video_id]) except YouTubeError as exc: - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc if not items: raise not_found("video") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index b2e33b51..123ab40b 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -342,7 +342,7 @@ def sync_youtube( return {"ok": True, **pull_user_playlists(db, user)} except YouTubeError as exc: db.rollback() - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc @router.post("/{playlist_id}/revert-youtube") @@ -369,10 +369,10 @@ def revert_youtube( with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): return {"ok": True, **repull_playlist(db, user, pl)} except PlaylistGone: - raise not_found("playlist") + raise not_found("playlist") from None except YouTubeError as exc: db.rollback() - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: @@ -398,7 +398,7 @@ def push_plan( with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_merge(db, user, pl) except YouTubeError as exc: - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc plan["remaining_today"] = quota.remaining_today(db) plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] return plan @@ -436,10 +436,10 @@ def push( raise _refused(429, "quota_exceeded") return {"ok": True, **merge_playlist(db, user, pl, live=live)} except PlaylistGone: - raise not_found("playlist") + raise not_found("playlist") from None except YouTubeError as exc: db.rollback() - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc @router.get("/{playlist_id}") @@ -539,7 +539,7 @@ def delete_playlist( with YouTubeClient(db, user) as yt: yt.delete_playlist(pl.yt_playlist_id) except YouTubeError as exc: - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc # The playlist first, as every item write: the cascade deletes the items before the list row. lock_playlists(db, pl.id) db.delete(pl) # items cascade diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 239c2ed3..0dfdde83 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -428,7 +428,7 @@ def create_collection( db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() - raise _write_or_pending_error(wrote, e) + raise _write_or_pending_error(wrote, e) from e return _collection_card(col) @@ -450,7 +450,7 @@ def collection_add_item( db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() - raise _write_or_pending_error(wrote, e) + raise _write_or_pending_error(wrote, e) from e return _collection_card(col) @@ -472,7 +472,7 @@ def collection_remove_item( db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() - raise _write_or_pending_error(wrote, e) + raise _write_or_pending_error(wrote, e) from e return _collection_card(col) @@ -502,7 +502,7 @@ def collection_rename( db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() - raise _write_or_pending_error(wrote, e) + raise _write_or_pending_error(wrote, e) from e return _collection_card(col) diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 05d6936b..84c79c96 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -124,8 +124,14 @@ def _recently_added( # share one), so the merge doesn't depend on object identity staying alive. top_movies = [o for _, kind, o in top if kind == "movie"] top_shows = [o for _, kind, o in top if kind == "show"] - mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang))} - scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))} + mcards = { + m.rating_key: c + for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True) + } + scards = { + s.rating_key: c + for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True) + } return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top] @@ -351,10 +357,12 @@ def _because_you_watched( ordered_shows = [shows[rk] for rk in rks if rk in shows] # rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()). mcards = { - m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang)) + m.rating_key: c + for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True) } scards = { - s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang)) + s.rating_key: c + for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True) } items: list[dict] = [] for rk in rks: diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py index 6add2507..c400cd0a 100644 --- a/backend/app/routes/saved_views.py +++ b/backend/app/routes/saved_views.py @@ -87,7 +87,7 @@ def create_view( db.commit() except IntegrityError: db.rollback() - raise refused(409, "view_name_taken", name=name) + raise refused(409, "view_name_taken", name=name) from None return _serialize(view) @@ -135,7 +135,7 @@ def update_view( db.commit() except IntegrityError: db.rollback() - raise refused(409, "view_name_taken", name=view.name) + raise refused(409, "view_name_taken", name=view.name) from None return _serialize(view) diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index 2a84dddc..579a691f 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -74,7 +74,7 @@ def run_job_now( try: result = trigger_job(job_id, actor_id=user.id) except KeyError: - raise not_found("job") + raise not_found("job") from None if result == "already_running": raise refused(409, "job_running") return {"ok": True, "id": job_id, "started": True} diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 31bc2fce..50d284f0 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -331,7 +331,7 @@ def search_youtube( # bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1). try: collected.extend(_ingest_candidates(db, yt, user, candidates)) - except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError + except Exception as exc: # any ingest failure, not just YouTubeError db.rollback() # The term is NOT in this line. A search string is exactly what the privacy # policy keeps out of the log unless the user turned Teljes/TRACE on for diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 998d658b..e413c484 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -39,7 +39,7 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend # The onboarding wizard's first call: an unguarded failure here was a raw 500 and the # blocking "Server error" dialog, with nothing the user could act on (E9 F1). db.rollback() - raise youtube_refusal(exc) + raise youtube_refusal(exc) from exc result["quota_used_estimate"] = m.used result["quota_remaining_today"] = m.remaining return {"ok": True, **result} diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 7e2c23c9..8228e912 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -75,7 +75,7 @@ def create_tag( db.commit() except IntegrityError: db.rollback() - raise refused(409, "tag_name_taken", name=name) + raise refused(409, "tag_name_taken", name=name) from None return { "ok": True, "id": tag.id, @@ -113,7 +113,7 @@ def update_tag( db.commit() except IntegrityError: db.rollback() - raise refused(409, "tag_name_taken", name=tag.name) + raise refused(409, "tag_name_taken", name=tag.name) from None return { "ok": True, "id": tag.id, diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index b495fbdf..273fb607 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -267,7 +267,7 @@ def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]: channel.feed_fallback_at = datetime.now(timezone.utc) db.add(channel) db.commit() - except Exception: # noqa: BLE001 — one channel must not stop the rest + except Exception: # one channel must not stop the rest db.rollback() log.exception( "Feed fallback could not store channel %s", diff --git a/backend/app/titles.py b/backend/app/titles.py index b1e8553f..3e3330cc 100644 --- a/backend/app/titles.py +++ b/backend/app/titles.py @@ -60,7 +60,6 @@ _SHORT_LOWER = { "for", "or", "vs", - "the", "és", "az", "egy", @@ -120,7 +119,6 @@ _ACRONYMS = { "nfl", "asmr", "pov", - "diy", "wtf", "lol", "rtx", diff --git a/backend/app/worker.py b/backend/app/worker.py index c5bd2ced..e2006bc9 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -25,6 +25,7 @@ import threading import time from collections.abc import Callable from datetime import datetime, timezone +from functools import partial from sqlalchemy import text @@ -72,7 +73,7 @@ def _wait_for_schema(timeout: float = 300.0) -> bool: with SessionLocal() as db: db.execute(text("SELECT 1 FROM download_jobs LIMIT 1")) return True - except Exception: # noqa: BLE001 — table not migrated yet / DB not up yet + except Exception: # table not migrated yet / DB not up yet if time.monotonic() > deadline: # The traceback, not `str(exc)[:120]`: this is the last thing the worker says # before it exits, and which driver call failed is the whole question. @@ -366,7 +367,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None: try: proc = _spawn_download_child(job_id) - except Exception: # noqa: BLE001 — spawn failed; don't leave the asset stuck 'downloading' + except Exception: # spawn failed; don't leave the asset stuck 'downloading' log.exception("failed to spawn download child for job %s", job_id) _reset_asset_pending(asset_id) _requeue_if_running(job_id, None) @@ -438,7 +439,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None: cause="child_exit", exit_code=proc.returncode, ) - except Exception: # noqa: BLE001 — boot recovery is the backstop for a stuck 'running' job + except Exception: # boot recovery is the backstop for a stuck 'running' job log.exception("post-exit reconciliation failed for job %s", job_id) return @@ -501,6 +502,7 @@ def _process_job(job_id: int) -> None: then run in a supervised CHILD PROCESS (`_run_supervised`) so a wedged yt-dlp/ffmpeg can be hard-killed — a worker THREAD cannot be killed, nor (when it spins in a C call) even cooperatively cancelled, and one such thread starves the others via the GIL.""" + diagnosis = None # set exactly when the asset has already failed (asset_status == "error") with SessionLocal() as db: job = db.get(DownloadJob, job_id) if job is None: @@ -528,7 +530,7 @@ def _process_job(job_id: int) -> None: if asset_status == "ready": _finish_from_cache(job_id, asset_id) return - if asset_status == "error": + if diagnosis is not None: _fail_running( job_id, error=asset_error or "Download failed", @@ -577,7 +579,7 @@ def _worker_loop(idx: int) -> None: while not _stop.is_set(): try: job_id = _claim_job() - except Exception: # noqa: BLE001 + except Exception: log.exception("claim failed") _stop.wait(settings.download_poll_seconds) continue @@ -597,12 +599,12 @@ def _worker_loop(idx: int) -> None: job_id, exc, "crash", - lambda: _fail_running(job_id, error="Worker error", error_code=None), + partial(_fail_running, job_id, error="Worker error", error_code=None), ) # silent-ok: not silent — `_report_worker_error` logged the crash before this # re-raise. Failing the job hit the database, and letting that out would end this # worker thread for good; boot recovery settles a job left `running`. - except Exception: # noqa: BLE001 + except Exception: log.exception( "could not fail job %s after its crash", job_id, diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 3439ca5b..8ecacb94 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -189,7 +189,7 @@ class YouTubeClient: return cached try: refresh = decrypt(tok.refresh_token_enc) - except Exception as exc: # noqa: BLE001 — Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed) + except Exception as exc: # Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed) # A key mismatch must degrade like any other token problem (caller handles YouTubeError), # not surface as a raw 500 on every scheduler job / connect check. (B-core-2) raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc diff --git a/backend/app/youtube/rss.py b/backend/app/youtube/rss.py index 510bc13d..c8502f89 100644 --- a/backend/app/youtube/rss.py +++ b/backend/app/youtube/rss.py @@ -40,6 +40,7 @@ class RssError(Exception): def _get_feed(channel_id: str) -> bytes: """The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised.""" url = RSS_URL.format(channel_id=channel_id) + error: RssError | None = None for attempt in range(len(RETRY_DELAYS) + 1): if attempt: time.sleep(RETRY_DELAYS[attempt - 1]) @@ -60,6 +61,8 @@ def _get_feed(channel_id: str) -> bytes: ) if resp.status_code not in _RETRYABLE_STATUS: break + if error is None: # cannot happen: the range above is never empty, and every try records one + raise RssError(f"RSS fetch for {channel_id} made no attempt", "transport") raise error diff --git a/backend/pyrightconfig.json b/backend/pyrightconfig.json index 6e32135f..e034ee46 100644 --- a/backend/pyrightconfig.json +++ b/backend/pyrightconfig.json @@ -25,7 +25,7 @@ { "include": ["app", "alembic"], "extraPaths": ["."], - "typeCheckingMode": "basic", + "typeCheckingMode": "standard", "pythonVersion": "3.12", "pythonPlatform": "Linux" } diff --git a/backend/pytest.ini b/backend/pytest.ini index 6692f5b6..f92aa790 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -2,7 +2,9 @@ # Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests # import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in # via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently -# skipped lane is visible (the compose lane sets SIFTLODE_TEST_REQUIRE_DB=1 to make skips a hard error). +# skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless +# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers` (E32): a misspelt `@pytest.mark.skipiff` is an error, +# not an unknown marker that quietly does nothing. testpaths = tests pythonpath = . -addopts = -q -rs +addopts = -q -rs --strict-markers diff --git a/backend/ruff.toml b/backend/ruff.toml index f69ce16c..7778e4ba 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -29,5 +29,23 @@ target-version = "py312" # nobody to raise to, a best-effort backfill inside a sync) and always worth a sentence. # The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that # neither logs nor re-raises says `# silent-ok: `. Ruff sees only the two narrow shapes. -extend-select = ["LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001"] +# +# E32 (gate integrity) added three more, each measured before it went on and with every hit fixed: +# RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and +# it stays armed for the day the line changes (24 were removed). A comment that merely +# explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`. +# PLW1510 — `subprocess.run` without an explicit `check=`. Found the real E32 bug: three ffprobe +# probes read the output of a probe that had FAILED and took it for the file's truth. +# B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a +# `zip` that silently truncates (each one is `strict=True` now — the lengths are an +# invariant, so a mismatch should be loud). B008 (a call in a default argument) stays ON: +# FastAPI's `Depends(...)` / `Query(...)` ARE that shape by design (474 hits, all of +# them), so those two are declared immutable below instead of switching the rule off — +# any other call in a default still fails. +extend-select = [ + "LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001", "RUF100", "PLW1510", "B", +] ignore = ["E402"] + +[lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c25be444..3c25394f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -25,7 +25,7 @@ from fastapi.testclient import TestClient from sqlalchemy import create_engine, event, text from starlette.requests import HTTPConnection -import app.models # noqa: F401 — register every model on Base.metadata +import app.models # register every model on Base.metadata import app.state as app_state from app import security from app.auth import current_user @@ -37,9 +37,11 @@ from app.models import User # Sentinel for "this dependency had no override before the fixture set one" (see override_dep). _MISSING = object() -# The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error -# — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run. -_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1" +# A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the +# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default +# since E32: as an opt-in only the compose lane and CI set, a hand-run `pytest` skipped ~60 DB tests +# and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run. +_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0" def _db_reachable() -> bool: @@ -86,19 +88,20 @@ def _truncate_all(): def pytest_configure(config): - """Fail the whole run — loudly, once — when SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't - actually run the full suite. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns + """Fail the whole run — loudly, once — when the full suite is required (the default) but the + environment can't actually run it. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns ~60 DB tests + the poster tests into silent skips and the gate still exits green.""" if not _REQUIRE_FULL: return if not _DB_REACHABLE: raise pytest.UsageError( - "SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the " - "DB-backed lane would silently skip. Fix the DB wiring or unset the flag." + "no Postgres is reachable at the test DATABASE_URL — the DB-backed lane would silently " + "skip. Fix the DB wiring, or run the pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0." ) if shutil.which("ffmpeg") is None: raise pytest.UsageError( - "SIFTLODE_TEST_REQUIRE_DB=1 but ffmpeg is missing — the thumbnail/poster tests would skip." + "ffmpeg is missing — the thumbnail/poster tests would skip. Install it, or run the " + "pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0." ) diff --git a/backend/tests/test_body_limit.py b/backend/tests/test_body_limit.py index d967643d..905537e7 100644 --- a/backend/tests/test_body_limit.py +++ b/backend/tests/test_body_limit.py @@ -321,7 +321,7 @@ def _body_reading_functions() -> dict[tuple[str, str], dict]: continue names = [name for _, name in calls] limits = [] - for node, name in zip([n for n, _ in calls], names): + for node, name in zip([n for n, _ in calls], names, strict=True): if name.startswith("request."): limits.append("") continue diff --git a/backend/tests/test_logsink.py b/backend/tests/test_logsink.py index 7b9eb7cf..f0965c9f 100644 --- a/backend/tests/test_logsink.py +++ b/backend/tests/test_logsink.py @@ -259,7 +259,7 @@ def test_the_writer_drains_what_was_queued_when_it_is_stopped(db): log.addHandler(sink) try: for i in range(logsink.BATCH_MAX + 5): - # noqa G004: the f-string is the POINT — a distinct rendered message per line is + # G004: the f-string is the POINT — a distinct rendered message per line is # what keeps the flood limiter (which groups by template) out of this case. log.warning(f"line {i}") # noqa: G004 finally: diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 143f3eb5..22859570 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with second half of this file pins what keeps it off a LIVE session's segments.""" import os +import sys from pathlib import Path import pytest @@ -830,6 +831,46 @@ class TestProbeVideoKind: assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE +class TestProbeExitStatus: + """A probe whose ffprobe EXITS NON-ZERO is a failed probe, not an answer (E32). Each probe used to + read stdout without looking at the exit status, so a refusing ffprobe silently picked the fallback + — HW-safe, single audio, the requested offset — and skipped the warning its except branch promises. + Drives the REAL subprocess.run with the probe's own kwargs against a stand-in command that prints a + plausible answer and then fails, so the test fails whenever the exit status is ignored.""" + + @staticmethod + def _failing_ffprobe(monkeypatch, stdout: str): + real_run = stream.subprocess.run + script = f"import sys; sys.stdout.write({stdout!r}); sys.exit(1)" + monkeypatch.setattr( + stream.subprocess, + "run", + lambda _cmd, **kw: real_run([sys.executable, "-c", script], **kw), + ) + + @staticmethod + def _events(caplog) -> list[str]: + return [getattr(r, "event", "") for r in caplog.records] + + def test_video_kind(self, monkeypatch, caplog): + self._failing_ffprobe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=smpte2084\n") + with caplog.at_level("INFO"): + assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE + assert "plex.stream.probe_kind_failed" in self._events(caplog) + + def test_audio_langs(self, monkeypatch, caplog): + self._failing_ffprobe(monkeypatch, "hun\neng\n") + with caplog.at_level("INFO"): + assert stream._probe_audio_langs(Path("/x")) == [] + assert "plex.stream.probe_audio_failed" in self._events(caplog) + + def test_first_pts(self, monkeypatch, caplog): + self._failing_ffprobe(monkeypatch, "12.5\n") + with caplog.at_level("INFO"): + assert stream._probe_first_pts(Path("/x")) is None + assert "plex.stream.first_pts_probe_failed" in self._events(caplog) + + class TestOpenclPresent: """The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py index 975011e4..e1dd85d0 100644 --- a/scripts/e2e_seed.py +++ b/scripts/e2e_seed.py @@ -21,10 +21,10 @@ from datetime import datetime, timezone sys.path.insert(0, "/app") -from sqlalchemy import delete, func, select # noqa: E402 +from sqlalchemy import delete, func, select -from app.db import SessionLocal # noqa: E402 -from app.models import ( # noqa: E402 +from app.db import SessionLocal +from app.models import ( AppState, Channel, Playlist, @@ -34,7 +34,7 @@ from app.models import ( # noqa: E402 Video, VideoState, ) -from app.security import hash_password # noqa: E402 +from app.security import hash_password EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local") PASSWORD = os.environ.get("E2E_PASSWORD", "") @@ -171,7 +171,7 @@ def main() -> None: saved = svid.id db.commit() - # noqa T201: this script's stdout IS its interface — the e2e runner parses this JSON. + # T201: this script's stdout IS its interface — the e2e runner parses this JSON. print( # noqa: T201 json.dumps( {