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:
2026-10-03 16:04:06 +02:00
parent cdfe7a8986
commit 3c16012524
37 changed files with 168 additions and 99 deletions
-18
View File
@@ -1650,24 +1650,6 @@
"endColumn": 36, "endColumn": 36,
"lineCount": 1 "lineCount": 1
} }
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 46,
"endColumn": 52,
"lineCount": 1
}
}
],
"./app/youtube/rss.py": [
{
"code": "reportGeneralTypeIssues",
"range": {
"startColumn": 10,
"endColumn": 15,
"lineCount": 1
}
} }
] ]
} }
+1 -1
View File
@@ -219,7 +219,7 @@ class BodyLimitMiddleware:
try: try:
await self.app(scope, counting_receive, guarded_send) 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 # 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 # 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 # than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
+2 -2
View File
@@ -400,7 +400,7 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
try: try:
proc.wait(timeout=grace_s) proc.wait(timeout=grace_s)
except subprocess.TimeoutExpired: 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. # `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 log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
@@ -456,7 +456,7 @@ def run_ffmpeg(
try: try:
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S) ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
except subprocess.TimeoutExpired: # pipes closed but the process lingers 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: except BaseException:
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this # 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` # function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
+2 -2
View File
@@ -57,7 +57,7 @@ def main() -> None:
logs.configure_logging("runner") logs.configure_logging("runner")
_deprioritize() _deprioritize()
if len(sys.argv) != 2: 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 # 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. # line wants the bare sentence, not a record with a request id they do not have.
print( # noqa: T201 print( # noqa: T201
@@ -104,7 +104,7 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
else: else:
_download(job_id, asset_id, source_kind, source_ref, spec, user_id) _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 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 log.error( # noqa: TRY400
"the download child for job %s failed", "the download child for job %s failed",
job_id, job_id,
+3 -3
View File
@@ -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 # 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). # 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 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): if not passes(ch_tags, exclude):
continue continue
for tid in ch_tags: for tid in ch_tags:
@@ -671,8 +671,8 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime |
raw = payload["k"] raw = payload["k"]
if len(raw) != len(keys): if len(raw) != len(keys):
raise ValueError("key arity mismatch") raise ValueError("key arity mismatch")
vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)] 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): for k, v in zip(keys, vals, strict=True):
if v is None and not k["nullable"]: if v is None and not k["nullable"]:
raise ValueError("null value for a non-nullable key") raise ValueError("null value for a non-nullable key")
window = payload.get("w") window = payload.get("w")
+1 -1
View File
@@ -239,7 +239,7 @@ def deliver(
# already open on the tracker and this is what would have recorded its link. We # 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 — # 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. # 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. # nothing else; the issue number below is the whole news.
log.error( # noqa: TRY400 log.error( # noqa: TRY400
"an issue was created by a send that had already lost its claim", "an issue was created by a send that had already lost its claim",
+1 -1
View File
@@ -166,7 +166,7 @@ def bind(**fields: Any):
try: try:
yield yield
finally: finally:
for key, token in zip(keys, tokens): for key, token in zip(keys, tokens, strict=True):
_CONTEXT_VARS[key].reset(token) _CONTEXT_VARS[key].reset(token)
+1 -1
View File
@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI):
# booting — an unreachable or not-yet-migrated database has to start into the env level. # booting — an unreachable or not-yet-migrated database has to start into the env level.
try: try:
set_level(sysconfig.get_str(db, "log_level")) 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, # 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. # 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()) log.exception("could not read the stored log level; staying at %s", current_level())
+9
View File
@@ -245,6 +245,9 @@ def _probe_video_kind(src: Path) -> str:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, 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: 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 # 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, capture_output=True,
text=True, text=True,
timeout=10, 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()] return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError) as exc: except (subprocess.SubprocessError, OSError) as exc:
@@ -615,6 +621,9 @@ def _probe_first_pts(seg: Path) -> float | None:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, 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(): for line in out.stdout.splitlines():
tok = line.strip().strip(",") tok = line.strip().strip(",")
+2 -1
View File
@@ -162,7 +162,8 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
"webvtt", "webvtt",
"pipe:1", "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: if r.returncode != 0 or not r.stdout:
raise PlexError( raise PlexError(
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed" (r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
+3 -3
View File
@@ -273,7 +273,7 @@ def sync(db: Session) -> dict:
# becomes filterable over successive runs (the cheap listing above only stored the top ~3). # becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try: try:
stats["people_enriched"] = _enrich_people_batch(db, plex) 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 # 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. # 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") 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. # successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try: try:
stats["media_probed"] = _probe_media_batch(db, plex) 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") log.exception("the Plex media-probe batch failed")
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which # 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. # 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. # appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try: try:
stats["people_pruned"] = people.prune_orphan_people(db) 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.exception("the Plex people prune failed")
log.info("Plex sync done: %s", stats) log.info("Plex sync done: %s", stats)
return stats return stats
+4 -2
View File
@@ -8,7 +8,7 @@ its own thread/context) never cross wires. When no sink is bound (e.g. a manual
""" """
import contextvars import contextvars
from typing import Callable, Optional from typing import Callable, Literal, Optional
Sink = Callable[[int, Optional[int], Optional[str]], None] Sink = Callable[[int, Optional[int], Optional[str]], None]
@@ -48,7 +48,9 @@ class bind:
self._token = _sink.set(self._sink) self._token = _sink.set(self._sink)
return self 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: if self._token is not None:
_sink.reset(self._token) _sink.reset(self._token)
return False return False
+3 -3
View File
@@ -436,7 +436,7 @@ def explore_channel(
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token) result = explore_ingest_page(db, yt, channel, page_token)
except YouTubeError as exc: except YouTubeError as exc:
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
return { return {
"ok": True, "ok": True,
"channel_id": channel_id, "channel_id": channel_id,
@@ -597,7 +597,7 @@ def subscribe(
if "subscriptionDuplicate" in exc.reason.split(","): if "subscriptionDuplicate" in exc.reason.split(","):
yt_sub_id = "" yt_sub_id = ""
else: 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 # 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 # 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 # 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: except YouTubeError as exc:
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as # 422 (a real, explainable error → speaking modal), not 502 which the client treats as
# a transient "connection lost" gateway blip. # a transient "connection lost" gateway blip.
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
db.delete(sub) db.delete(sub)
db.commit() db.commit()
return {"ok": True, "unsubscribed": channel_id} return {"ok": True, "unsubscribed": channel_id}
+1 -1
View File
@@ -170,7 +170,7 @@ def add_pin(
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed # 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 # 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. # 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": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True} return {"ok": True, "id": pin.id, "created": True}
+5 -5
View File
@@ -322,7 +322,7 @@ def set_credential(
try: try:
cleaned = dlcreds.validate_cookies(body.cookies) cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e: 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) dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record( audit.record(
db, db,
@@ -407,7 +407,7 @@ def enqueue_download(
try: try:
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name) job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
except quota.QuotaExceeded as e: 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) return _serialize_job(db, job)
@@ -520,7 +520,7 @@ def upload_download(
display_name=upload.display_name, display_name=upload.display_name,
) )
except quota.QuotaExceeded as e: 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) return _serialize_job(db, job)
@@ -547,9 +547,9 @@ def enqueue_edit(
try: try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name) job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e: 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: 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) return _serialize_job(db, job)
+2 -2
View File
@@ -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. # 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)]) query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
if vals is not None: if vals is not None:
for k, v in zip(keys, vals): for k, v in zip(keys, vals, strict=True):
k["value"] = v k["value"] = v
if ranked and window is not None: if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1) 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]) items = yt.get_videos([video_id])
except YouTubeError as exc: except YouTubeError as exc:
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
if not items: if not items:
raise not_found("video") raise not_found("video")
+7 -7
View File
@@ -342,7 +342,7 @@ def sync_youtube(
return {"ok": True, **pull_user_playlists(db, user)} return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc: except YouTubeError as exc:
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
@router.post("/{playlist_id}/revert-youtube") @router.post("/{playlist_id}/revert-youtube")
@@ -369,10 +369,10 @@ def revert_youtube(
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)} return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone: except PlaylistGone:
raise not_found("playlist") raise not_found("playlist") from None
except YouTubeError as exc: except YouTubeError as exc:
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: 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): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_merge(db, user, pl) plan = plan_merge(db, user, pl)
except YouTubeError as exc: except YouTubeError as exc:
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
plan["remaining_today"] = quota.remaining_today(db) plan["remaining_today"] = quota.remaining_today(db)
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
return plan return plan
@@ -436,10 +436,10 @@ def push(
raise _refused(429, "quota_exceeded") raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)} return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone: except PlaylistGone:
raise not_found("playlist") raise not_found("playlist") from None
except YouTubeError as exc: except YouTubeError as exc:
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
@router.get("/{playlist_id}") @router.get("/{playlist_id}")
@@ -539,7 +539,7 @@ def delete_playlist(
with YouTubeClient(db, user) as yt: with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id) yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc: 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. # The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id) lock_playlists(db, pl.id)
db.delete(pl) # items cascade db.delete(pl) # items cascade
+4 -4
View File
@@ -428,7 +428,7 @@ def create_collection(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return _collection_card(col)
@@ -450,7 +450,7 @@ def collection_add_item(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return _collection_card(col)
@@ -472,7 +472,7 @@ def collection_remove_item(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return _collection_card(col)
@@ -502,7 +502,7 @@ def collection_rename(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return _collection_card(col)
+12 -4
View File
@@ -124,8 +124,14 @@ def _recently_added(
# share one), so the merge doesn't depend on object identity staying alive. # 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_movies = [o for _, kind, o in top if kind == "movie"]
top_shows = [o for _, kind, o in top if kind == "show"] 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))} mcards = {
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))} 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] 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] 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()). # rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
mcards = { 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 = { 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] = [] items: list[dict] = []
for rk in rks: for rk in rks:
+2 -2
View File
@@ -87,7 +87,7 @@ def create_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "view_name_taken", name=name) raise refused(409, "view_name_taken", name=name) from None
return _serialize(view) return _serialize(view)
@@ -135,7 +135,7 @@ def update_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() 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) return _serialize(view)
+1 -1
View File
@@ -74,7 +74,7 @@ def run_job_now(
try: try:
result = trigger_job(job_id, actor_id=user.id) result = trigger_job(job_id, actor_id=user.id)
except KeyError: except KeyError:
raise not_found("job") raise not_found("job") from None
if result == "already_running": if result == "already_running":
raise refused(409, "job_running") raise refused(409, "job_running")
return {"ok": True, "id": job_id, "started": True} return {"ok": True, "id": job_id, "started": True}
+1 -1
View File
@@ -331,7 +331,7 @@ def search_youtube(
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1). # bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
try: try:
collected.extend(_ingest_candidates(db, yt, user, candidates)) 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() db.rollback()
# The term is NOT in this line. A search string is exactly what the privacy # 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 # policy keeps out of the log unless the user turned Teljes/TRACE on for
+1 -1
View File
@@ -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 # 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). # blocking "Server error" dialog, with nothing the user could act on (E9 F1).
db.rollback() db.rollback()
raise youtube_refusal(exc) raise youtube_refusal(exc) from exc
result["quota_used_estimate"] = m.used result["quota_used_estimate"] = m.used
result["quota_remaining_today"] = m.remaining result["quota_remaining_today"] = m.remaining
return {"ok": True, **result} return {"ok": True, **result}
+2 -2
View File
@@ -75,7 +75,7 @@ def create_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "tag_name_taken", name=name) raise refused(409, "tag_name_taken", name=name) from None
return { return {
"ok": True, "ok": True,
"id": tag.id, "id": tag.id,
@@ -113,7 +113,7 @@ def update_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "tag_name_taken", name=tag.name) raise refused(409, "tag_name_taken", name=tag.name) from None
return { return {
"ok": True, "ok": True,
"id": tag.id, "id": tag.id,
+1 -1
View File
@@ -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) channel.feed_fallback_at = datetime.now(timezone.utc)
db.add(channel) db.add(channel)
db.commit() db.commit()
except Exception: # noqa: BLE001 — one channel must not stop the rest except Exception: # one channel must not stop the rest
db.rollback() db.rollback()
log.exception( log.exception(
"Feed fallback could not store channel %s", "Feed fallback could not store channel %s",
-2
View File
@@ -60,7 +60,6 @@ _SHORT_LOWER = {
"for", "for",
"or", "or",
"vs", "vs",
"the",
"és", "és",
"az", "az",
"egy", "egy",
@@ -120,7 +119,6 @@ _ACRONYMS = {
"nfl", "nfl",
"asmr", "asmr",
"pov", "pov",
"diy",
"wtf", "wtf",
"lol", "lol",
"rtx", "rtx",
+9 -7
View File
@@ -25,6 +25,7 @@ import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import partial
from sqlalchemy import text from sqlalchemy import text
@@ -72,7 +73,7 @@ def _wait_for_schema(timeout: float = 300.0) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
db.execute(text("SELECT 1 FROM download_jobs LIMIT 1")) db.execute(text("SELECT 1 FROM download_jobs LIMIT 1"))
return True 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: if time.monotonic() > deadline:
# The traceback, not `str(exc)[:120]`: this is the last thing the worker says # 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. # 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: try:
proc = _spawn_download_child(job_id) 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) log.exception("failed to spawn download child for job %s", job_id)
_reset_asset_pending(asset_id) _reset_asset_pending(asset_id)
_requeue_if_running(job_id, None) _requeue_if_running(job_id, None)
@@ -438,7 +439,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
cause="child_exit", cause="child_exit",
exit_code=proc.returncode, 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) log.exception("post-exit reconciliation failed for job %s", job_id)
return 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 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 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.""" 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: with SessionLocal() as db:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None: if job is None:
@@ -528,7 +530,7 @@ def _process_job(job_id: int) -> None:
if asset_status == "ready": if asset_status == "ready":
_finish_from_cache(job_id, asset_id) _finish_from_cache(job_id, asset_id)
return return
if asset_status == "error": if diagnosis is not None:
_fail_running( _fail_running(
job_id, job_id,
error=asset_error or "Download failed", error=asset_error or "Download failed",
@@ -577,7 +579,7 @@ def _worker_loop(idx: int) -> None:
while not _stop.is_set(): while not _stop.is_set():
try: try:
job_id = _claim_job() job_id = _claim_job()
except Exception: # noqa: BLE001 except Exception:
log.exception("claim failed") log.exception("claim failed")
_stop.wait(settings.download_poll_seconds) _stop.wait(settings.download_poll_seconds)
continue continue
@@ -597,12 +599,12 @@ def _worker_loop(idx: int) -> None:
job_id, job_id,
exc, exc,
"crash", "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 # 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 # 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`. # worker thread for good; boot recovery settles a job left `running`.
except Exception: # noqa: BLE001 except Exception:
log.exception( log.exception(
"could not fail job %s after its crash", "could not fail job %s after its crash",
job_id, job_id,
+1 -1
View File
@@ -189,7 +189,7 @@ class YouTubeClient:
return cached return cached
try: try:
refresh = decrypt(tok.refresh_token_enc) 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), # 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) # 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 raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc
+3
View File
@@ -40,6 +40,7 @@ class RssError(Exception):
def _get_feed(channel_id: str) -> bytes: def _get_feed(channel_id: str) -> bytes:
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised.""" """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) url = RSS_URL.format(channel_id=channel_id)
error: RssError | None = None
for attempt in range(len(RETRY_DELAYS) + 1): for attempt in range(len(RETRY_DELAYS) + 1):
if attempt: if attempt:
time.sleep(RETRY_DELAYS[attempt - 1]) 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: if resp.status_code not in _RETRYABLE_STATUS:
break 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 raise error
+1 -1
View File
@@ -25,7 +25,7 @@
{ {
"include": ["app", "alembic"], "include": ["app", "alembic"],
"extraPaths": ["."], "extraPaths": ["."],
"typeCheckingMode": "basic", "typeCheckingMode": "standard",
"pythonVersion": "3.12", "pythonVersion": "3.12",
"pythonPlatform": "Linux" "pythonPlatform": "Linux"
} }
+4 -2
View File
@@ -2,7 +2,9 @@
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests # 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 # 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 # 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 testpaths = tests
pythonpath = . pythonpath = .
addopts = -q -rs addopts = -q -rs --strict-markers
+19 -1
View File
@@ -29,5 +29,23 @@ target-version = "py312"
# nobody to raise to, a best-effort backfill inside a sync) and always worth a sentence. # 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 # 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. # 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"] ignore = ["E402"]
[lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]
+12 -9
View File
@@ -25,7 +25,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event, text from sqlalchemy import create_engine, event, text
from starlette.requests import HTTPConnection 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 import app.state as app_state
from app import security from app import security
from app.auth import current_user 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). # Sentinel for "this dependency had no override before the fixture set one" (see override_dep).
_MISSING = object() _MISSING = object()
# The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error # A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the
# — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run. # suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1" # 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: def _db_reachable() -> bool:
@@ -86,19 +88,20 @@ def _truncate_all():
def pytest_configure(config): def pytest_configure(config):
"""Fail the whole run — loudly, once — when SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't """Fail the whole run — loudly, once — when the full suite is required (the default) but the
actually run the full suite. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns 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.""" ~60 DB tests + the poster tests into silent skips and the gate still exits green."""
if not _REQUIRE_FULL: if not _REQUIRE_FULL:
return return
if not _DB_REACHABLE: if not _DB_REACHABLE:
raise pytest.UsageError( raise pytest.UsageError(
"SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the " "no Postgres is reachable at the test DATABASE_URL — the DB-backed lane would silently "
"DB-backed lane would silently skip. Fix the DB wiring or unset the flag." "skip. Fix the DB wiring, or run the pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
) )
if shutil.which("ffmpeg") is None: if shutil.which("ffmpeg") is None:
raise pytest.UsageError( 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."
) )
+1 -1
View File
@@ -321,7 +321,7 @@ def _body_reading_functions() -> dict[tuple[str, str], dict]:
continue continue
names = [name for _, name in calls] names = [name for _, name in calls]
limits = [] 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."): if name.startswith("request."):
limits.append("<no ceiling declared>") limits.append("<no ceiling declared>")
continue continue
+1 -1
View File
@@ -259,7 +259,7 @@ def test_the_writer_drains_what_was_queued_when_it_is_stopped(db):
log.addHandler(sink) log.addHandler(sink)
try: try:
for i in range(logsink.BATCH_MAX + 5): 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. # what keeps the flood limiter (which groups by template) out of this case.
log.warning(f"line {i}") # noqa: G004 log.warning(f"line {i}") # noqa: G004
finally: finally:
+41
View File
@@ -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.""" second half of this file pins what keeps it off a LIVE session's segments."""
import os import os
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -830,6 +831,46 @@ class TestProbeVideoKind:
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE 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: class TestOpenclPresent:
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives """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 the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
+5 -5
View File
@@ -21,10 +21,10 @@ from datetime import datetime, timezone
sys.path.insert(0, "/app") 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.db import SessionLocal
from app.models import ( # noqa: E402 from app.models import (
AppState, AppState,
Channel, Channel,
Playlist, Playlist,
@@ -34,7 +34,7 @@ from app.models import ( # noqa: E402
Video, Video,
VideoState, VideoState,
) )
from app.security import hash_password # noqa: E402 from app.security import hash_password
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local") EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
PASSWORD = os.environ.get("E2E_PASSWORD", "") PASSWORD = os.environ.get("E2E_PASSWORD", "")
@@ -171,7 +171,7 @@ def main() -> None:
saved = svid.id saved = svid.id
db.commit() 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 print( # noqa: T201
json.dumps( json.dumps(
{ {