build(backend): close the lint gaps the E32 audit measured
ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than ignored. B008 stays on with FastAPI's Depends/Query declared immutable. basedpyright moves to standard mode, pytest to --strict-markers, and a hand run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0. PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never looked at the exit status, so a probe that failed was read as the file's truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed to one track, a subtitle offset taken from garbage) and the warning each except branch promises never fired. They now run with check=True.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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())
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{
|
||||
"include": ["app", "alembic"],
|
||||
"extraPaths": ["."],
|
||||
"typeCheckingMode": "basic",
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.12",
|
||||
"pythonPlatform": "Linux"
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+19
-1
@@ -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: <why>`. 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"]
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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("<no ceiling declared>")
|
||||
continue
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-5
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user