diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e700d64..3789837 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: # An extremely fast Python linter and formatter. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.15 + rev: v0.15.10 hooks: - id: ruff-format types_or: [ python, pyi, jupyter, pyproject ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 5e7f664..658befd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,5 @@ { "cSpell.words": [ - "appauthor", - "appname", "argnames", "argvalues", "autoexport", @@ -22,7 +20,6 @@ "overwritable", "pipx", "pyproject", - "retcode", "Skulbladi", "thead", "thelovinator", diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index 87cd162..68ce295 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -133,7 +133,7 @@ def extract_domain(url: str) -> str: # noqa: PLR0911 if not url: return "Other" - try: # noqa: PLW0717 + try: # Special handling for YouTube feeds if "youtube.com/feeds/videos.xml" in url: return "YouTube" @@ -1219,13 +1219,13 @@ def _capture_full_page_screenshot_sync( Returns: bytes | None: PNG bytes on success, otherwise None. """ - try: # noqa: PLW0717 + try: with sync_playwright() as playwright: browser: Browser = playwright.chromium.launch( headless=True, args=["--disable-dev-shm-usage", "--no-sandbox"], ) - try: # noqa: PLW0717 + try: if screenshot_layout == "mobile": page = browser.new_page( viewport={"width": 390, "height": 844}, @@ -1432,7 +1432,7 @@ def get_ttvdrops_reward_description(drop: JsonObject, reward: JsonObject) -> str return reward_name -def extract_ttvdrops_media_gallery_items(value: JsonValue, *, hide_paid: bool = False) -> list[JsonObject]: # noqa: C901 +def extract_ttvdrops_media_gallery_items(value: JsonValue) -> list[JsonObject]: # noqa: C901 """Extract benefit/reward media gallery items from a ttvdrops API response. Returns: @@ -1441,9 +1441,6 @@ def extract_ttvdrops_media_gallery_items(value: JsonValue, *, hide_paid: bool = media_items: list[JsonObject] = [] def add_reward_image(drop: JsonObject, reward: JsonObject) -> None: - if hide_paid and json_value_to_int(drop.get("required_minutes_watched")) <= 0: - return - image_url = reward.get("image_url") if isinstance(image_url, str): add_unique_media_gallery_item( @@ -1494,8 +1491,7 @@ def fetch_ttvdrops_campaign_media_items(entry: Entry) -> list[JsonObject]: logger.exception("Failed to fetch ttvdrops campaign data from %s", api_url) return [] - hide_paid: bool = "1" in parse_qs(urlparse(entry.feed.url).query).get("hide_paid", []) - return extract_ttvdrops_media_gallery_items(response_json, hide_paid=hide_paid) + return extract_ttvdrops_media_gallery_items(response_json) def get_entry_media_gallery_items( diff --git a/discord_rss_bot/git_backup.py b/discord_rss_bot/git_backup.py index 2784e6f..490807d 100644 --- a/discord_rss_bot/git_backup.py +++ b/discord_rss_bot/git_backup.py @@ -91,7 +91,7 @@ def setup_backup_repo(backup_path: Path) -> bool: """Ensure the backup directory exists and contains a git repository. If the directory does not yet contain a ``.git`` folder a new repository is - initialized. A basic git identity is configured locally so that commits + initialised. A basic git identity is configured locally so that commits succeed even in environments where a global ``~/.gitconfig`` is absent. Args: @@ -100,12 +100,12 @@ def setup_backup_repo(backup_path: Path) -> bool: Returns: ``True`` if the repository is ready, ``False`` on any error. """ - try: # noqa: PLW0717 + try: backup_path.mkdir(parents=True, exist_ok=True) git_dir: Path = backup_path / ".git" if not git_dir.exists(): subprocess.run([GIT_EXECUTABLE, "init", str(backup_path)], check=True, capture_output=True) # noqa: S603 - logger.info("Initialized git backup repository at %s", backup_path) + logger.info("Initialised git backup repository at %s", backup_path) # Ensure a local identity exists so that `git commit` always works. for key, value in (("user.email", "discord-rss-bot@localhost"), ("user.name", "discord-rss-bot")): @@ -155,7 +155,7 @@ def setup_backup_repo(backup_path: Path) -> bool: def export_state(reader: Reader, backup_path: Path) -> None: - """Serialize the current bot state to ``state.json`` inside *backup_path*. + """Serialise the current bot state to ``state.json`` inside *backup_path*. Args: reader: The :class:`reader.Reader` instance to read state from. @@ -190,7 +190,7 @@ def export_state(reader: Reader, backup_path: Path) -> None: if clean_layout in {"desktop", "mobile"}: global_screenshot_layout = clean_layout - state: JsonObject = {"feeds": feeds_state, "webhooks": webhooks} # pyright: ignore[reportAssignmentType] + state: JsonObject = {"feeds": feeds_state, "webhooks": webhooks} if global_update_interval is not None: state["global_update_interval"] = global_update_interval if global_screenshot_layout is not None: @@ -217,7 +217,7 @@ def commit_state_change(reader: Reader, message: str) -> None: if not setup_backup_repo(backup_path): return - try: # noqa: PLW0717 + try: export_state(reader, backup_path) subprocess.run([GIT_EXECUTABLE, "-C", str(backup_path), "add", "-A"], check=True, capture_output=True) # noqa: S603 diff --git a/discord_rss_bot/hoyolab_api.py b/discord_rss_bot/hoyolab_api.py index c1aa2c1..9877aea 100644 --- a/discord_rss_bot/hoyolab_api.py +++ b/discord_rss_bot/hoyolab_api.py @@ -76,7 +76,7 @@ def fetch_hoyolab_post(post_id: str) -> JsonObject | None: return None http_ok = 200 - try: # noqa: PLW0717 + try: url: str = f"https://bbs-api-os.hoyolab.com/community/post/wapi/getPostFull?post_id={post_id}" response: requests.Response = requests.get(url, timeout=10) @@ -143,8 +143,8 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject ) if image_list: image_url: str = str(image_list[0].get("url", "")) - image_height: int = int(image_list[0].get("height", "1080")) # pyright: ignore[reportArgumentType] - image_width: int = int(image_list[0].get("width", "1920")) # pyright: ignore[reportArgumentType] + image_height: int = int(image_list[0].get("height", 1080)) + image_width: int = int(image_list[0].get("width", 1920)) logger.debug("Image URL: %s, Height: %s, Width: %s", image_url, image_height, image_width) discord_embed.set_image(url=image_url, height=image_height, width=image_width) @@ -185,7 +185,7 @@ def create_hoyolab_webhook(webhook_url: str, entry: Entry, post_data: JsonObject # Only show Youtube URL if available structured_content: str = str(post.get("structured_content", "")) if structured_content: # noqa: PLR1702 - try: # noqa: PLW0717 + try: loaded_structured_content = cast("JsonValue", json.loads(structured_content)) structured_content_data: list[JsonObject] = ( [cast("JsonObject", item) for item in loaded_structured_content if isinstance(item, dict)] diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index d3ee932..35d0ac0 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -239,12 +239,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ) scheduler.start() logger.info("Scheduler started.") - - try: - yield - finally: - reader.close() - scheduler.shutdown(wait=True) + yield + reader.close() + scheduler.shutdown(wait=True) app: FastAPI = FastAPI(lifespan=lifespan) diff --git a/tests/conftest.py b/tests/conftest.py index 68061e1..8bd820e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,7 @@ def pytest_sessionstart(session: pytest.Session) -> None: # the worker-specific location. settings_module: ModuleType | None = sys.modules.get("discord_rss_bot.settings") if settings_module is not None: - settings_module.data_dir = str(worker_data_dir) # pyright: ignore[reportAttributeAccessIssue] + settings_module.data_dir = str(worker_data_dir) get_reader_attr = getattr(settings_module, "get_reader", None) if get_reader_attr is not None and hasattr(get_reader_attr, "cache_clear"): get_reader = cast("CachedReaderFactory", get_reader_attr) diff --git a/tests/test_conftest_hooks.py b/tests/test_conftest_hooks.py index 65303d2..716edba 100644 --- a/tests/test_conftest_hooks.py +++ b/tests/test_conftest_hooks.py @@ -102,6 +102,6 @@ def test_pytest_collection_modifyitems_noops_when_real_git_backup_tests_enabled( config.getoption.return_value = True items: list[MagicMock] = [MagicMock()] - hooks.pytest_collection_modifyitems(config=config, items=items) # pyright: ignore[reportArgumentType] + hooks.pytest_collection_modifyitems(config=config, items=items) config.getoption.assert_called_once_with("--run-real-git-backup-tests") diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 08a7e4f..e852cba 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -781,24 +781,8 @@ def test_get_ttvdrops_campaign_api_url_from_campaign_page() -> None: assert api_url == "https://ttvdrops.lovinator.space/twitch/api/v1/campaigns/93ba35ae-5bfc-43fe-88ac-49a0aabb2fe2/" -@pytest.mark.parametrize( - ("feed_url", "include_paid_reward"), - [ - ("https://ttvdrops.lovinator.space/twitch/feed.xml", True), - ("https://ttvdrops.lovinator.space/twitch/feed.xml?hide_paid=0", True), - ("https://ttvdrops.lovinator.space/twitch/feed.xml?hide_paid=true", True), - ("https://ttvdrops.lovinator.space/twitch/feed.xml?hide_paid=1", False), - ("https://ttvdrops.lovinator.space/twitch/feed.xml?lang=en&hide_paid=1", False), - ("https://ttvdrops.lovinator.space/twitch/feed.xml?hide_paid=0&hide_paid=1", False), - ], -) @patch("discord_rss_bot.feeds.httpx.get") -def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text( - mock_get: MagicMock, - feed_url: str, - *, - include_paid_reward: bool, -) -> None: +def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text(mock_get: MagicMock) -> None: response = MagicMock() response.status_code = 200 response.json.return_value = { @@ -812,38 +796,22 @@ def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text( {"image_url": "javascript:alert(1)"}, ], }, - { - "name": "Paid drop", - "required_minutes_watched": 0, - "required_subs": 2, - "benefits": [ - {"name": "Pay2win reward", "image_url": "/media/benefits/images/paid-reward.png"}, - ], - }, ], } mock_get.return_value = response entry = MagicMock() entry.link = "https://ttvdrops.lovinator.space/twitch/campaigns/93ba35ae-5bfc-43fe-88ac-49a0aabb2fe2/" entry.id = "entry-4" - entry.feed.url = feed_url + entry.feed.url = "https://example.com/feed.xml" media_items = feeds.fetch_ttvdrops_campaign_media_items(entry) - expected_media_items: list[JsonObject] = [ + assert media_items == [ { "url": "https://ttvdrops.lovinator.space/media/benefits/images/reward.png", "description": "120 minutes watched: Skulbladi", }, ] - if include_paid_reward: - expected_media_items.append( - { - "url": "https://ttvdrops.lovinator.space/media/benefits/images/paid-reward.png", - "description": "2 subscriptions: Pay2win reward", - }, - ) - assert media_items == expected_media_items mock_get.assert_called_once_with( "https://ttvdrops.lovinator.space/twitch/api/v1/campaigns/93ba35ae-5bfc-43fe-88ac-49a0aabb2fe2/", follow_redirects=True, @@ -851,78 +819,6 @@ def test_fetch_ttvdrops_campaign_media_items_extracts_reward_alt_text( ) -def test_extract_ttvdrops_media_gallery_items_includes_paid_rewards_by_default() -> None: - media_items = feeds.extract_ttvdrops_media_gallery_items( - { - "drops": [ - { - "required_subs": 1, - "benefits": [{"name": "Paid reward", "image_url": "/media/paid.png"}], - }, - ], - }, - ) - - assert media_items == [ - { - "url": "https://ttvdrops.lovinator.space/media/paid.png", - "description": "1 subscriptions: Paid reward", - }, - ] - - -def test_extract_ttvdrops_media_gallery_items_hide_paid_omits_non_watch_rewards() -> None: - media_items = feeds.extract_ttvdrops_media_gallery_items( - { - "drops": [ - { - "required_minutes_watched": 30, - "benefits": [{"name": "Watch reward", "image_url": "/media/watch.png"}], - }, - { - "required_minutes_watched": 0, - "required_subs": 1, - "benefits": [{"name": "Paid reward", "image_url": "/media/paid.png"}], - }, - { - "benefits": [{"name": "Unknown reward", "image_url": "/media/unknown.png"}], - }, - ], - }, - hide_paid=True, - ) - - assert media_items == [ - { - "url": "https://ttvdrops.lovinator.space/media/watch.png", - "description": "30 minutes watched: Watch reward", - }, - ] - - -def test_extract_ttvdrops_media_gallery_items_extracts_nested_watch_rewards() -> None: - media_items = feeds.extract_ttvdrops_media_gallery_items( - { - "campaign": { - "drops": [ - { - "required_minutes_watched": 45, - "rewards": [{"name": "Nested reward", "image_url": "/media/nested.png"}], - }, - ], - }, - }, - hide_paid=True, - ) - - assert media_items == [ - { - "url": "https://ttvdrops.lovinator.space/media/nested.png", - "description": "45 minutes watched: Nested reward", - }, - ] - - def test_capture_full_page_screenshot_uses_thread_when_loop_running() -> None: """Capture should offload sync Playwright work when called from an active event loop.""" with patch("discord_rss_bot.feeds._capture_full_page_screenshot_sync", return_value=b"png") as mock_capture_sync: diff --git a/tests/test_git_backup.py b/tests/test_git_backup.py index e7303f4..ec6b2a0 100644 --- a/tests/test_git_backup.py +++ b/tests/test_git_backup.py @@ -4,6 +4,7 @@ import contextlib import json import shutil import subprocess # noqa: S404 +from pathlib import Path from typing import TYPE_CHECKING from typing import cast from unittest.mock import MagicMock @@ -195,8 +196,8 @@ def test_export_state_creates_state_json(tmp_path: Path) -> None: data = cast("JsonObject", json.loads(state_file.read_text(encoding="utf-8"))) assert "feeds" in data assert "webhooks" in data - assert data["feeds"][0]["url"] == "https://example.com/feed.rss" # type: ignore - assert data["feeds"][0]["webhook"] == "https://discord.com/api/webhooks/123/abc" # type: ignore + assert data["feeds"][0]["url"] == "https://example.com/feed.rss" + assert data["feeds"][0]["webhook"] == "https://discord.com/api/webhooks/123/abc" def test_export_state_omits_empty_tags(tmp_path: Path) -> None: @@ -226,7 +227,7 @@ def test_export_state_omits_empty_tags(tmp_path: Path) -> None: data = cast("JsonObject", json.loads((backup_path / "state.json").read_text())) # Only "url" key should be present (no empty-value tags) - assert list(data["feeds"][0].keys()) == ["url"] # type: ignore + assert list(data["feeds"][0].keys()) == ["url"] def test_commit_state_change_noop_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: @@ -574,7 +575,7 @@ def test_embed_backup_end_to_end(monkeypatch: pytest.MonkeyPatch, tmp_path: Path state_data = cast("JsonObject", json.loads(state_file.read_text(encoding="utf-8"))) # Find our test feed in the state - test_feed_data = next((feed for feed in state_data["feeds"] if feed["url"] == test_feed_url), None) # type: ignore + test_feed_data = next((feed for feed in state_data["feeds"] if feed["url"] == test_feed_url), None) assert test_feed_data is not None, f"Test feed not found in state.json: {state_data}" # The embed settings are stored as a nested dict under custom_embed tag diff --git a/tests/test_hoyolab_api.py b/tests/test_hoyolab_api.py index 0649578..8a131df 100644 --- a/tests/test_hoyolab_api.py +++ b/tests/test_hoyolab_api.py @@ -174,7 +174,7 @@ class TestCreateHoyolabWebhook: entry = make_entry(link=None) entry = typing.cast("Entry", entry) - webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) # type: ignore + webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) assert webhook is webhook_instance mock_webhook_cls.assert_called_once_with(url="https://discord.test/webhook", rate_limit_retry=True) @@ -222,7 +222,7 @@ class TestCreateHoyolabWebhook: entry = make_entry() entry = typing.cast("Entry", entry) - webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) # type: ignore + webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data) assert webhook is webhook_instance webhook_instance.remove_embeds.assert_not_called() diff --git a/tests/test_main.py b/tests/test_main.py index 8cd59ff..b98416c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -438,7 +438,7 @@ def test_blacklist_preview_shows_labeled_field_values_for_substring_match() -> N stub_reader = StubReader() app.dependency_overrides[get_reader_dependency] = lambda: stub_reader - try: # noqa: PLW0717 + try: with patch("discord_rss_bot.main.create_html_for_feed", return_value="