Fix CI: refresh uv.lock, resolve ruff findings, pin ruff 0.16.1
Also point the README CI badge at the public GitHub mirror workflow.
This commit is contained in:
@@ -19,3 +19,6 @@ jobs:
|
||||
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
|
||||
with:
|
||||
args: "check"
|
||||
# Pinned: an unpinned ruff means any upstream release can turn main red
|
||||
# with no code change (exactly what happened 2026-07 → 2026-08).
|
||||
version: "0.16.1"
|
||||
|
||||
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- CI: refreshed the stale `uv.lock` (unblocks the `uv lock --check` gate), fixed all
|
||||
outstanding ruff findings, and pinned ruff to 0.16.1 in the lint workflow so an
|
||||
upstream ruff release can no longer turn `main` red without a code change.
|
||||
- README CI badge now points at the public GitHub mirror's workflow instead of the
|
||||
forge outsiders can't browse.
|
||||
|
||||
## [0.35.0] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# hark
|
||||
|
||||
[](https://git.onetick.ninja/flan/hark/actions) [](https://www.python.org/) [](LICENSE) [](https://github.com/sponsors/sudolulo) [](https://ko-fi.com/sudolulo)
|
||||
[](https://github.com/sudolulo/hark/actions) [](https://www.python.org/) [](LICENSE) [](https://github.com/sponsors/sudolulo) [](https://ko-fi.com/sudolulo)
|
||||
|
||||
Cross-podcast topic index and discovery service for subject-per-episode genres
|
||||
(true crime, history, disasters, and the like). The goal: resolve episodes to the
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ import contextlib
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
PW_ITERS = 120_000
|
||||
@@ -75,7 +75,7 @@ def constant_eq(a: str, b: str) -> bool:
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def iso(dt: datetime) -> str:
|
||||
@@ -83,7 +83,7 @@ def iso(dt: datetime) -> str:
|
||||
|
||||
|
||||
def parse_iso(value: str) -> datetime:
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
||||
|
||||
|
||||
class Auth:
|
||||
|
||||
+2
-1
@@ -30,9 +30,10 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Protocol
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
+24
-9
@@ -22,11 +22,11 @@ import argparse
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from adscrub import chapters as ad_chapters
|
||||
from adscrub import cut as ad_cut
|
||||
from adscrub import dai as ad_dai
|
||||
@@ -36,9 +36,24 @@ from adscrub import repeats as ad_repeats
|
||||
from adscrub import transcribe as ad_transcribe
|
||||
|
||||
from . import (
|
||||
__version__, claims, dai_probe, db, discover, extract, gpodder_server,
|
||||
hosting, ingest, llm_budget, nextcloud, opml, orchestrator, pipeline,
|
||||
ratings, resolve, transcript_search, wikidata,
|
||||
__version__,
|
||||
claims,
|
||||
dai_probe,
|
||||
db,
|
||||
discover,
|
||||
extract,
|
||||
gpodder_server,
|
||||
hosting,
|
||||
ingest,
|
||||
llm_budget,
|
||||
nextcloud,
|
||||
opml,
|
||||
orchestrator,
|
||||
pipeline,
|
||||
ratings,
|
||||
resolve,
|
||||
transcript_search,
|
||||
wikidata,
|
||||
)
|
||||
|
||||
DEFAULT_DB = os.environ.get("HARK_DB", "hark.db")
|
||||
@@ -724,8 +739,8 @@ def cmd_seeds(args: argparse.Namespace) -> int:
|
||||
# /pipeline can show EXACTLY which episodes detect-ads would read — and that it is these
|
||||
# few, campaign-deduplicated, not the thousands merely "pending". select_seed_episodes
|
||||
# writes via ensure_schema, so the web can't recompute this itself.
|
||||
from datetime import datetime, timezone
|
||||
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
from datetime import datetime
|
||||
now_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
by_id = {e["id"]: e for e in all_pending}
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS llm_ad_seeds (episode_id INTEGER PRIMARY KEY, title TEXT, "
|
||||
@@ -756,7 +771,7 @@ def cmd_seeds(args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
|
||||
by_id = {e["id"]: e for e in all_pending}
|
||||
out = open(args.out, "w", encoding="utf-8") if args.out else sys.stdout
|
||||
out = open(args.out, "w", encoding="utf-8") if args.out else sys.stdout # noqa: SIM115 — the sys.stdout branch must not be closed by a context manager
|
||||
try:
|
||||
print(f"# {len(seeds)} episode(s) to read — chosen to cover every unread ad campaign.",
|
||||
file=out)
|
||||
@@ -1223,7 +1238,7 @@ def cmd_dai_probe(args: argparse.Namespace) -> int:
|
||||
# adscrub.dai's own docstring for why a shared client's cookie jar
|
||||
# silently defeats the comparison. User-Agent is set per-fetch by
|
||||
# probe_variance() itself, so no default is needed here.
|
||||
client_factory = lambda: httpx.Client(timeout=60.0) # noqa: E731
|
||||
client_factory = lambda: httpx.Client(timeout=60.0)
|
||||
for ep in sample:
|
||||
r = dai_probe.run_probe(client_factory, conn, ep, ep["hosting_platform"])
|
||||
if r.error or r.result is None: # run_probe sets exactly one of the two
|
||||
|
||||
@@ -7,8 +7,8 @@ which platforms actually support this technique, not just running it once.
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
from adscrub import dai
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# topics / topic_genres / episode_topics are created now but only populated by
|
||||
@@ -372,4 +372,4 @@ def connect(path: str | Path) -> sqlite3.Connection:
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from . import resolve
|
||||
|
||||
@@ -36,7 +36,7 @@ MAX_SHOWS_PER_USER = 10
|
||||
|
||||
|
||||
def _format_ts(epoch: float) -> str:
|
||||
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime(_ACTION_TS_FORMAT)
|
||||
return datetime.fromtimestamp(epoch, tz=UTC).strftime(_ACTION_TS_FORMAT)
|
||||
|
||||
|
||||
def record_subscription_changes(
|
||||
|
||||
@@ -22,7 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
# Opus 4.8 input price, $/1M tokens — the model hark's ad/topic extraction defaults to. Output is
|
||||
# tiny for these tasks (span-index lists, short topic labels) so only input is metered.
|
||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS llm_spend (
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
|
||||
@@ -20,13 +20,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from . import alert, llm_budget
|
||||
|
||||
@@ -165,13 +165,15 @@ def stage_meta() -> list[dict]:
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ}] pipeline: {msg}", flush=True)
|
||||
print(f"[{datetime.now(UTC):%Y-%m-%dT%H:%M:%SZ}] pipeline: {msg}", flush=True)
|
||||
|
||||
|
||||
def _default_run(db_path: str, argv: list[str]) -> int:
|
||||
"""Run one `hark` subcommand as its own process, so a crash is isolated."""
|
||||
try:
|
||||
return subprocess.run([sys.executable, "-m", "hark", "--db", db_path, *argv]).returncode
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "hark", "--db", db_path, *argv], check=False
|
||||
).returncode
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log(f"stage {' '.join(argv)} raised {exc}")
|
||||
return 1
|
||||
@@ -231,7 +233,7 @@ def run_cycle(
|
||||
path = os.path.join(data_dir, fname)
|
||||
if os.path.exists(path):
|
||||
if run(argv + [path]) == 0:
|
||||
stamp = datetime.now(timezone.utc).strftime("%s")
|
||||
stamp = datetime.now(UTC).strftime("%s")
|
||||
loaded = os.path.join(data_dir, fname.replace("pending-", f"loaded-{stamp}-"))
|
||||
try:
|
||||
os.replace(path, loaded)
|
||||
|
||||
@@ -15,8 +15,8 @@ store path.
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from .db import utcnow
|
||||
from .extract import GENRES, ExtractedTopic, TopicExtractor
|
||||
|
||||
@@ -19,12 +19,11 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from feedgen.feed import FeedGenerator
|
||||
|
||||
from adscrub.cut import CUT_SOURCES
|
||||
from feedgen.feed import FeedGenerator
|
||||
|
||||
# Podcasting 2.0 namespace — for <podcast:chapters> in "mark, don't cut" mode.
|
||||
_PODCAST_NS = "https://podcastindex.org/namespace/1.0"
|
||||
@@ -64,7 +63,7 @@ def _add_chapters_links(rss: bytes, chapters_url_by_guid: dict[str, str]) -> byt
|
||||
def _parse_pubdate(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def feed_url(show: sqlite3.Row, base_url: str) -> str:
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
@@ -173,7 +173,7 @@ def pipeline_status(conn: sqlite3.Connection) -> dict:
|
||||
out: dict = {"stages": {}, "spans": [], "library": 0, "quarantined": 0, "held": 0,
|
||||
"spend": {"ads": 0.0, "comparisons": 0.0}}
|
||||
try:
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
for r in conn.execute("SELECT category, dollars FROM llm_spend WHERE day = ?", (today,)):
|
||||
out["spend"][r["category"]] = r["dollars"] # read-only: never touches llm_budget's ensure_schema
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ import math
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
@@ -236,7 +236,7 @@ class TaddyRatingsSource:
|
||||
|
||||
|
||||
def _cutoff(days: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return (datetime.now(UTC) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _chunked(items: list, size: int):
|
||||
|
||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from . import queries
|
||||
|
||||
|
||||
+18
-8
@@ -10,14 +10,24 @@ import secrets
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from . import (claims, gpodder_server, llm_budget, orchestrator, podcast_feed, ratings, resolve,
|
||||
scoring, series, transcript_search)
|
||||
from .auth import BASE_URL_SETTING, Auth, parse_iso, utcnow
|
||||
from . import (
|
||||
claims,
|
||||
gpodder_server,
|
||||
llm_budget,
|
||||
orchestrator,
|
||||
podcast_feed,
|
||||
ratings,
|
||||
resolve,
|
||||
scoring,
|
||||
series,
|
||||
transcript_search,
|
||||
)
|
||||
from .auth import BASE_URL_SETTING, Auth, parse_iso
|
||||
from .extract import GENRES as GENRES_FILTER
|
||||
from .queries import (
|
||||
PAGE_SIZE,
|
||||
@@ -46,8 +56,8 @@ from .templates import (
|
||||
plural,
|
||||
relative_time,
|
||||
stage_status_badge,
|
||||
topic_table,
|
||||
topic_pills,
|
||||
topic_table,
|
||||
)
|
||||
|
||||
# MAX_SHOWS_PER_USER lives in gpodder_server.py — shared with the
|
||||
@@ -568,7 +578,7 @@ class App:
|
||||
status = row["last_status"] if row else None
|
||||
exit_code = row["last_exit"] if row else None
|
||||
last_run = (row["last_run"] if row else None) or 0.0
|
||||
when = relative_time(datetime.fromtimestamp(last_run, timezone.utc)) if last_run > 0 else "—"
|
||||
when = relative_time(datetime.fromtimestamp(last_run, UTC)) if last_run > 0 else "—"
|
||||
stage_rows.append(
|
||||
f"<tr class='stagerow'><td>{esc(meta['name'])}</td>"
|
||||
f"<td class='dim'>{esc(meta['cadence'])}</td>"
|
||||
@@ -913,7 +923,7 @@ class App:
|
||||
),
|
||||
"rare": (
|
||||
"<h2>Rare coverage</h2>" +
|
||||
(f'<p class="dim">Top 15 episodes covering hark\'s least-common genres — '
|
||||
('<p class="dim">Top 15 episodes covering hark\'s least-common genres — '
|
||||
+ " and ".join(
|
||||
f'<a href="/topics?genre={esc(g)}">{esc(g)}</a>' for g in rare_genres
|
||||
) + ".</p>"
|
||||
@@ -1006,7 +1016,7 @@ class App:
|
||||
)
|
||||
related_html = f"<h2>Related topics</h2><p>{related_pills}</p>"
|
||||
comparison_html = (
|
||||
f'<h2 id="comparison">what each show said</h2>'
|
||||
'<h2 id="comparison">what each show said</h2>'
|
||||
+ claims_html(comparison, shows_transcribed)
|
||||
) if comparison is not None or shows_transcribed >= 2 else ""
|
||||
span_html = ""
|
||||
|
||||
+2
-1
@@ -84,7 +84,8 @@ from .templates import ( # noqa: F401 — re-exported for callers (cli.py, test
|
||||
topic_pills,
|
||||
topic_table,
|
||||
)
|
||||
from .views import COOKIE, App # noqa: F401 — re-exported for callers (cli.py, tests)
|
||||
from .views import COOKIE, App
|
||||
|
||||
|
||||
def _safe_next(value: str, default: str) -> str:
|
||||
"""Validate a same-origin-relative redirect target from a form's `next`
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import email.utils
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -32,7 +32,7 @@ def _retry_after_seconds(value: str | None, default: float = 5.0) -> float:
|
||||
when = email.utils.parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
seconds = (when - datetime.now(UTC)).total_seconds()
|
||||
return max(0.0, min(seconds, MAX_BACKOFF))
|
||||
|
||||
|
||||
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from adscrub import cut as ad_cut
|
||||
from adscrub import detect as ad_detect
|
||||
from adscrub import transcribe as ad_transcribe
|
||||
@@ -983,7 +982,7 @@ def test_user_add_admin_flag(tmp_path, capsys):
|
||||
|
||||
from hark import web
|
||||
auth = web.Auth(auth_db, admin_token=None)
|
||||
row = [u for u in auth.list_users() if u["username"] == "bob"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "bob")
|
||||
assert row["is_admin"] == 1
|
||||
|
||||
|
||||
@@ -1054,7 +1053,7 @@ def test_user_invite_creates_account_and_prints_link(tmp_path, capsys):
|
||||
|
||||
from hark import web
|
||||
auth = web.Auth(auth_db, admin_token=None)
|
||||
row = [u for u in auth.list_users() if u["username"] == "alice"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "alice")
|
||||
assert row["invite_pending"] == 1
|
||||
|
||||
|
||||
@@ -1065,7 +1064,7 @@ def test_user_invite_admin_flag(tmp_path, capsys):
|
||||
|
||||
from hark import web
|
||||
auth = web.Auth(auth_db, admin_token=None)
|
||||
row = [u for u in auth.list_users() if u["username"] == "bob"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "bob")
|
||||
assert row["is_admin"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from adscrub import dai
|
||||
|
||||
from hark import dai_probe, db
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_run_probe_records_an_attempt_on_fetch_failure(conn):
|
||||
def handler(request):
|
||||
return httpx.Response(404)
|
||||
|
||||
factory = lambda: httpx.Client(transport=httpx.MockTransport(handler)) # noqa: E731
|
||||
factory = lambda: httpx.Client(transport=httpx.MockTransport(handler))
|
||||
result = dai_probe.run_probe(factory, conn, ep, "acast.com")
|
||||
|
||||
assert result.error is not None
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_subscription_changes_since_only_returns_later_events(tmp_path):
|
||||
("https://b.example/feed", cursor_after_first + 10),
|
||||
)
|
||||
conn.commit()
|
||||
add, remove, _ = gpodder_server.subscription_changes_since(conn, 1, cursor_after_first)
|
||||
add, _remove, _ = gpodder_server.subscription_changes_since(conn, 1, cursor_after_first)
|
||||
assert add == ["https://b.example/feed"]
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from hark import orchestrator, llm_budget
|
||||
|
||||
from hark import llm_budget, orchestrator
|
||||
|
||||
|
||||
def _db(tmp_path):
|
||||
|
||||
@@ -274,7 +274,6 @@ def test_recanonicalize_limit_caps_batch_size(tmp_path):
|
||||
|
||||
def canonicalize(label):
|
||||
calls.append(label)
|
||||
return None
|
||||
|
||||
results = pipeline.recanonicalize(conn, canonicalize, limit=2)
|
||||
assert results == [] # canonicalize returns None for all, no upgrades
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_chapters_mode_serves_original_audio_with_chapter_links(server, tmp_path
|
||||
assert doc["version"] == "1.2.0"
|
||||
assert {"startTime": 30.0, "title": "Advertisement"} in doc["chapters"]
|
||||
assert {"startTime": 60.0, "title": "Content"} in doc["chapters"]
|
||||
assert request(server, f"/chapters/2/wrong.json")[0].status == 404 # wrong token
|
||||
assert request(server, "/chapters/2/wrong.json")[0].status == 404 # wrong token
|
||||
|
||||
|
||||
def test_feed_route_uses_admin_base_url_override(server, tmp_path):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -114,18 +114,19 @@ def test_fetch_raises_taddy_error_on_graphql_error_body():
|
||||
def handler(variables):
|
||||
return httpx.Response(200, json={"errors": [{"message": "bad argument"}]})
|
||||
|
||||
with graphql_client(handler) as client:
|
||||
with pytest.raises(ratings.TaddyError):
|
||||
make_source(client).fetch("https://feeds.example.com/x", None)
|
||||
with graphql_client(handler) as client, pytest.raises(ratings.TaddyError):
|
||||
make_source(client).fetch("https://feeds.example.com/x", None)
|
||||
|
||||
|
||||
def test_fetch_raises_on_http_error_status():
|
||||
def handler(request):
|
||||
return httpx.Response(401, json={"message": "unauthorized"})
|
||||
|
||||
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
make_source(client).fetch("https://feeds.example.com/x", None)
|
||||
with (
|
||||
httpx.Client(transport=httpx.MockTransport(handler)) as client,
|
||||
pytest.raises(httpx.HTTPStatusError),
|
||||
):
|
||||
make_source(client).fetch("https://feeds.example.com/x", None)
|
||||
|
||||
|
||||
# --- refresh_ratings ---
|
||||
@@ -290,7 +291,7 @@ def test_refresh_ratings_known_match_uses_shorter_stale_window_than_a_miss(tmp_p
|
||||
conn = db.connect(tmp_path / "t.db")
|
||||
# 100 days old: stale for a known match (90-day window) but not yet due
|
||||
# for a confirmed miss (180-day window).
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=100)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
cutoff = (datetime.now(UTC) - timedelta(days=100)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
known_id = seed_existing_rating(conn, "https://feeds.example.com/known", "uuid-known", cutoff, 4.0)
|
||||
seed_existing_rating(conn, "https://feeds.example.com/miss", None, cutoff)
|
||||
|
||||
|
||||
+13
-13
@@ -280,7 +280,7 @@ def test_episode_ad_transparency_and_admin_marking(tmp_path):
|
||||
marked = [r for r in rows if r["source"] == "manual"]
|
||||
assert marked and marked[0]["start_second"] == 120.0 and marked[0]["end_second"] == 150.0
|
||||
assert conn.execute("SELECT cut_path FROM episodes WHERE id = 1").fetchone()[0] is None
|
||||
fp_id = [r["id"] for r in rows if r["source"] == "fpmatch"][0]
|
||||
fp_id = next(r["id"] for r in rows if r["source"] == "fpmatch")
|
||||
conn.close()
|
||||
|
||||
# (#4) remove a false positive
|
||||
@@ -1169,7 +1169,7 @@ def test_adblock_toggle_is_atomic_under_concurrent_requests(server):
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
resp, body = request(server, "GET", "/show/1", cookie=cookie)
|
||||
_resp, body = request(server, "GET", "/show/1", cookie=cookie)
|
||||
assert "<strong>enabled</strong>" in body # 20 (even) toggles from enabled -> enabled
|
||||
|
||||
|
||||
@@ -1704,7 +1704,7 @@ def test_bootstrap_admin_is_admin(tmp_path):
|
||||
def test_create_user_defaults_to_non_admin(tmp_path):
|
||||
auth = web.Auth(tmp_path / "auth.db", admin_token="t")
|
||||
auth.create_user("alice")
|
||||
row = [u for u in auth.list_users() if u["username"] == "alice"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "alice")
|
||||
assert row["is_admin"] == 0
|
||||
assert row["has_password"] == 0
|
||||
|
||||
@@ -1712,14 +1712,14 @@ def test_create_user_defaults_to_non_admin(tmp_path):
|
||||
def test_create_user_can_grant_admin(tmp_path):
|
||||
auth = web.Auth(tmp_path / "auth.db", admin_token="t")
|
||||
auth.create_user("bob", is_admin=True)
|
||||
row = [u for u in auth.list_users() if u["username"] == "bob"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "bob")
|
||||
assert row["is_admin"] == 1
|
||||
|
||||
|
||||
def test_create_user_rejects_duplicate_username(tmp_path):
|
||||
auth = web.Auth(tmp_path / "auth.db", admin_token="t")
|
||||
auth.create_user("alice")
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
auth.create_user("alice")
|
||||
|
||||
|
||||
@@ -1854,7 +1854,7 @@ def test_create_invite_returns_working_token(tmp_path):
|
||||
def test_create_invite_defaults_to_non_admin(tmp_path):
|
||||
auth = web.Auth(tmp_path / "auth.db", admin_token="t")
|
||||
auth.create_invite("alice")
|
||||
row = [u for u in auth.list_users() if u["username"] == "alice"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "alice")
|
||||
assert row["is_admin"] == 0
|
||||
assert row["invite_pending"] == 1
|
||||
|
||||
@@ -1862,7 +1862,7 @@ def test_create_invite_defaults_to_non_admin(tmp_path):
|
||||
def test_create_invite_can_grant_admin(tmp_path):
|
||||
auth = web.Auth(tmp_path / "auth.db", admin_token="t")
|
||||
auth.create_invite("alice", is_admin=True)
|
||||
row = [u for u in auth.list_users() if u["username"] == "alice"][0]
|
||||
row = next(u for u in auth.list_users() if u["username"] == "alice")
|
||||
assert row["is_admin"] == 1
|
||||
|
||||
|
||||
@@ -2008,7 +2008,7 @@ def test_subscribe_unknown_show_404s(server):
|
||||
|
||||
def test_show_page_offers_subscribe_toggle(server):
|
||||
cookie = login(server)
|
||||
resp, body = request(server, "GET", "/show/1", cookie=cookie)
|
||||
_resp, body = request(server, "GET", "/show/1", cookie=cookie)
|
||||
assert "In your list." in body
|
||||
assert 'action="/show/1/unsubscribe"' in body
|
||||
|
||||
@@ -2059,10 +2059,10 @@ def test_gpodder_sync_isolated_between_two_accounts(server, tmp_path):
|
||||
request(server, "POST", "/index.php/apps/gpoddersync/subscription_change/create",
|
||||
auth=alice_auth, json_body={"add": ["https://alice-only.example/feed"], "remove": []})
|
||||
|
||||
resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
auth=admin_auth, json_body=None)
|
||||
_resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
auth=admin_auth, json_body=None)
|
||||
admin_add = json.loads(body)["add"]
|
||||
resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
_resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
auth=alice_auth, json_body=None)
|
||||
alice_add = json.loads(body)["add"]
|
||||
|
||||
@@ -2514,7 +2514,7 @@ def test_gpodder_sync_quota_enforced_for_non_admin(server, tmp_path):
|
||||
urls = [f"https://quota-test-{i}.example/feed" for i in range(15)]
|
||||
request(server, "POST", "/index.php/apps/gpoddersync/subscription_change/create",
|
||||
auth=auth, json_body={"add": urls, "remove": []})
|
||||
resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
_resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
auth=auth, json_body=None)
|
||||
data = json.loads(body)
|
||||
assert len(data["add"]) == 10
|
||||
@@ -2525,7 +2525,7 @@ def test_gpodder_sync_quota_not_enforced_for_admin(server, tmp_path):
|
||||
urls = [f"https://admin-quota-test-{i}.example/feed" for i in range(15)]
|
||||
request(server, "POST", "/index.php/apps/gpoddersync/subscription_change/create",
|
||||
auth=admin_auth, json_body={"add": urls, "remove": []})
|
||||
resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
_resp, body = request(server, "GET", "/index.php/apps/gpoddersync/subscriptions",
|
||||
auth=admin_auth, json_body=None)
|
||||
data = json.loads(body)
|
||||
assert len([u for u in urls if u in data["add"]]) == 15
|
||||
|
||||
Reference in New Issue
Block a user