diff --git a/backend/app/main.py b/backend/app/main.py index a9068e9d..1376e980 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,38 +1,36 @@ import asyncio import math +import mimetypes from contextlib import asynccontextmanager, suppress from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError - -# Logging is configured HERE, before the route modules are imported, so every logger they create at -# import time is already covered. ONE format for the api, the worker, the download child and alembic -# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that -# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below. -from app import logs -from app.logs import configure_logging, current_level, log_requests, set_level - -configure_logging("api") - -log = logs.get_logger(__name__) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import DataError from starlette.middleware.sessions import SessionMiddleware -from app import bodylimit -from app import collation -from app import logsink -from app import state -from app import sysconfig -from app.config import settings -from app.db import SessionLocal, engine -from app.plex import stream as plex_stream -from app.plex import subtitles as plex_subtitles -from app.routes import ( +# Logging is configured HERE, before the app's own modules are imported, so a line one of them logs +# WHILE importing already has the format (a logger is configured whenever it is created — +# app/logs.py — but a line emitted before `configure_logging` goes out raw). ONE format for the api, +# the worker, the download child and alembic alike — this replaces the old uvicorn --log-config + +# the hand-rolled fallback that used to live here, and uvicorn's access log is replaced by the +# `http.request` middleware below. The imports after it are E402 on purpose, each one marked. +from app import logs +from app.logs import configure_logging, current_level, log_requests, set_level + +configure_logging("api") + +log = logs.get_logger(__name__) +from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first +from app.config import settings # noqa: E402 — logging is configured first +from app.db import SessionLocal, engine # noqa: E402 — logging is configured first +from app.plex import stream as plex_stream # noqa: E402 — logging is configured first +from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first +from app.routes import ( # noqa: E402 — logging is configured first admin, admin_logs as admin_logs_routes, audit as audit_routes, @@ -60,7 +58,7 @@ from app.routes import ( tags, version, ) -from app.scheduler import shutdown_scheduler, start_scheduler +from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first @asynccontextmanager @@ -332,8 +330,6 @@ app.include_router(setup_routes.router) # Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the # filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream). -import mimetypes - mimetypes.add_type("image/webp", ".webp") mimetypes.add_type("image/avif", ".avif") diff --git a/backend/app/models.py b/backend/app/models.py index 2c958085..2e406a39 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -23,6 +23,7 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from app.db import Base +from app.utils import canonical_email # Every user-supplied display string in this schema shares one width, and it lives HERE so the # columns below are declared with it rather than with a literal: a name a person typed goes into @@ -51,9 +52,6 @@ def display_text(raw: str | None) -> str | None: return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None -from app.utils import canonical_email - - # The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint # routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500. POSITION_MAX_S = 2**31 - 1 diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 273fb607..19292ad1 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -15,8 +15,6 @@ from sqlalchemy import func, or_, select, update from sqlalchemy.orm import Session from app import logs, progress, quota, sysconfig - -log = logs.get_logger(__name__) from app.db import SessionLocal from app.models import Channel, OAuthToken, Subscription, User from app.sync.explore import followed_channel @@ -35,6 +33,8 @@ from app.sync.videos import ( from app.youtube.client import YouTubeClient, YouTubeError, is_quota_refusal from app.youtube.rss import RssError, fetch_channel_feed +log = logs.get_logger(__name__) + # RSS feeds are free and network-bound, so fetch many at once; DB writes stay single-threaded. RSS_POLL_WORKERS = 16 # Failed channels named in a poll's summary warning; the rest are counted by reason. diff --git a/backend/ruff.toml b/backend/ruff.toml index 7778e4ba..57145a40 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -1,7 +1,8 @@ # Ruff config for the Siftlode backend. Keeps ruff's default rule set (E4/E7/E9 + F = the -# high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), but ignores E402: -# a handful of modules (e.g. main.py) deliberately run setup — logging config, sys.path — before -# importing route modules, so "module import not at top of file" is intentional there, not cruft. +# high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), all of it on. E402 was +# ignored for main.py's logging setup; the ignore had let 11 incidental late imports in elsewhere +# (E32 review), so it is on now and main.py marks each deliberate one (`# noqa: E402 — `). +# `scripts/e2e_seed.py` edits `sys.path` before its imports, which E402 itself allows. # # line-length 100 (ruff's default is 88): matches the frontend prettier printWidth so both halves of # the repo wrap at the same width, and it's the width `ruff format` enforces (gated via @@ -17,9 +18,10 @@ target-version = "py312" # LOG — stdlib logging misuse (`.warn()`, a logger built by hand, `exc_info` on `.exception`) # G — the call's shape: %-style args, never an f-string the level filter cannot skip, and # never `str(exc)` where `exc_info` would carry the traceback -# T20 — `print` instead of a log line. Two deliberate ones remain, each with its `# noqa` -# and a why: `downloads/runner.py`'s usage line is for whoever typed it, and -# `logsink.py` reports the SINK's own failure, which cannot be logged into the sink. +# T20 — `print` instead of a log line. Three deliberate ones remain, each with its `# noqa` +# and a why: `downloads/runner.py`'s usage line is for whoever typed it, `logsink.py` +# reports the SINK's own failure, which cannot be logged into the sink, and +# `scripts/e2e_seed.py` prints the seeded ids for the e2e run that reads them. # TRY400 — `.error()` in an `except` where `.exception()` would keep the traceback # TRY401 — the exception spelled into the message AND passed as `exc_info` (it is in both) # S112 — `except: continue`, the silent swallow inside a loop @@ -45,7 +47,6 @@ target-version = "py312" 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/test_email_canonical.py b/backend/tests/test_email_canonical.py index 71d49f9c..4b3fed24 100644 --- a/backend/tests/test_email_canonical.py +++ b/backend/tests/test_email_canonical.py @@ -13,12 +13,12 @@ hold for Google Workspace custom domains unless their admin enabled it. import pytest from sqlalchemy import select, text -_UAT_PW = "correct horse battery" - from app.auth import is_allowed from app.models import Invite, User from app.utils import canonical_email +_UAT_PW = "correct horse battery" + # (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold. CASES = [ # Gmail: dots and +tags are noise… diff --git a/docs/gates.md b/docs/gates.md index e0ca534f..877749d1 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -74,7 +74,6 @@ project checks needs a row. | Scope | Narrowed | Why | Measured | | --- | --- | --- | --- | -| backend + scripts | `E402` — ignored | A few modules (`main.py`, `scripts/e2e_seed.py`) run setup — logging, `sys.path` — before their imports on purpose. | — | | backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI | | backend/app | `C901` — not in ruff; measured by `tests/test_source_size.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. The test holds each offender, by name, at its current value. Tests and scripts are outside it: see the size / complexity rows under Lanes. | 32 functions over 10 | diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 84ea2a11..c76edbf2 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -281,7 +281,7 @@ describe("the gate register (docs/gates.md)", () => { return m ? [...m[1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!) : []; }; expect(tomlKeys(toml).sort()).toEqual( - ["extend-immutable-calls", "extend-select", "ignore", "line-length", "target-version"].sort(), + ["extend-immutable-calls", "extend-select", "line-length", "target-version"].sort(), ); // the selection on top of ruff's defaults (E, F) — dropping one narrows the lane expect(list("extend-select")).toEqual([