diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53995d..00baaa9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,13 +21,13 @@ repos: - id: trailing-whitespace - repo: https://github.com/adamchainz/django-upgrade - rev: 1.30.0 + rev: 1.31.1 hooks: - id: django-upgrade args: [--target-version, "6.0"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.17 + rev: v0.15.22 hooks: - id: ruff-check args: ["--fix", "--exit-non-zero-on-fix"] diff --git a/chzzk/management/commands/import_chzzk_campaign.py b/chzzk/management/commands/import_chzzk_campaign.py index c8944ca..d39449a 100644 --- a/chzzk/management/commands/import_chzzk_campaign.py +++ b/chzzk/management/commands/import_chzzk_campaign.py @@ -144,7 +144,7 @@ class Command(BaseCommand): Args: campaign_no (int): The campaign number to import. """ - api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # noqa: TD003 + api_version: str = "v2" # TODO(TheLovinator): Add support for v1 API # ruff:ignore[missing-todo-link] url: str = f"https://api.chzzk.naver.com/service/{api_version}/drops/campaigns/{campaign_no}" resp: requests.Response = requests.get( url, diff --git a/chzzk/tasks.py b/chzzk/tasks.py index 55f929c..a2ef415 100644 --- a/chzzk/tasks.py +++ b/chzzk/tasks.py @@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="imports", max_retries=3, default_retry_delay=60) -def import_chzzk_campaign_task(self, campaign_no: int) -> None: # noqa: ANN001 +def import_chzzk_campaign_task(self, campaign_no: int) -> None: # ruff:ignore[missing-type-function-argument] """Import a single Chzzk campaign by its campaign number.""" try: call_command("import_chzzk_campaign", str(campaign_no)) @@ -18,7 +18,7 @@ def import_chzzk_campaign_task(self, campaign_no: int) -> None: # noqa: ANN001 @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) -def discover_chzzk_campaigns(self) -> None: # noqa: ANN001 +def discover_chzzk_campaigns(self) -> None: # ruff:ignore[missing-type-function-argument] """Discover and import the latest Chzzk campaigns (equivalent to --latest flag).""" try: call_command("import_chzzk_campaign", latest=True) diff --git a/config/celery.py b/config/celery.py index 129ab98..503b47d 100644 --- a/config/celery.py +++ b/config/celery.py @@ -23,10 +23,10 @@ classes = [ ] for cls in classes: - setattr( # noqa: B010 + setattr( # ruff:ignore[set-attr-with-constant] cls, "__class_getitem__", - classmethod(lambda cls, *args, **kwargs: cls), # noqa: ARG005 + classmethod(lambda cls, *args, **kwargs: cls), # ruff:ignore[unused-lambda-argument] ) diff --git a/config/tests/test_celery.py b/config/tests/test_celery.py index edb0a3d..332f086 100644 --- a/config/tests/test_celery.py +++ b/config/tests/test_celery.py @@ -16,7 +16,7 @@ def celery_app() -> Generator[Celery, Any]: Yields: Celery: A Celery app instance configured for testing. """ - with patch("os.environ.setdefault") as mock_setenv: # noqa: F841 + with patch("os.environ.setdefault") as mock_setenv: # ruff:ignore[unused-variable] app = Celery("config") app.config_from_object("django.conf:settings", namespace="CELERY") yield app diff --git a/conftest.py b/conftest.py index e3b4683..883c39d 100644 --- a/conftest.py +++ b/conftest.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from collections.abc import Generator -@pytest.fixture(autouse=True) # noqa: RUF076 — intentional: project-wide N+1 detection with @pytest.mark.no_zeal escape hatch +@pytest.fixture(autouse=True) def use_zeal(request: pytest.FixtureRequest) -> Generator[None]: """Enable Zeal N+1 detection context for each pytest test. diff --git a/core/tasks.py b/core/tasks.py index cf7f6f0..c26f5ae 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -9,7 +9,7 @@ logger: logging.Logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="default", max_retries=3, default_retry_delay=300) -def submit_indexnow_task(self) -> None: # noqa: ANN001 +def submit_indexnow_task(self) -> None: # ruff:ignore[missing-type-function-argument] """Submit all site URLs to the IndexNow search index.""" try: call_command("submit_indexnow") diff --git a/core/tests/test_sitemaps.py b/core/tests/test_sitemaps.py index 7821279..e6093fa 100644 --- a/core/tests/test_sitemaps.py +++ b/core/tests/test_sitemaps.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET # noqa: S405 +import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import] from typing import TYPE_CHECKING from django.urls import reverse @@ -9,7 +9,7 @@ if TYPE_CHECKING: def _extract_locs(xml_bytes: bytes) -> list[str]: - root = ET.fromstring(xml_bytes) # noqa: S314 + root = ET.fromstring(xml_bytes) # ruff:ignore[suspicious-xml-element-tree-usage] ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"} return [el.text for el in root.findall(".//s:loc", ns) if el.text] diff --git a/core/utils.py b/core/utils.py index 2adc70f..863dfca 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,7 +1,7 @@ from typing import Any -from xml.etree.ElementTree import Element # noqa: S405 -from xml.etree.ElementTree import SubElement # noqa: S405 -from xml.etree.ElementTree import tostring # noqa: S405 +from xml.etree.ElementTree import Element # ruff:ignore[suspicious-xml-etree-import] +from xml.etree.ElementTree import SubElement # ruff:ignore[suspicious-xml-etree-import] +from xml.etree.ElementTree import tostring # ruff:ignore[suspicious-xml-etree-import] from django.conf import settings diff --git a/core/views.py b/core/views.py index 963ebe3..e80920e 100644 --- a/core/views.py +++ b/core/views.py @@ -53,7 +53,7 @@ MIN_SEARCH_RANK = 0.05 DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and more." -def _build_seo_context( # noqa: PLR0913, PLR0917 +def _build_seo_context( # ruff:ignore[too-many-arguments, too-many-positional-arguments] page_title: str = "ttvdrops", page_description: str | None = None, page_url: str | None = None, @@ -91,7 +91,7 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 if page_url and not page_url.startswith("http"): page_url = f"{settings.BASE_URL}{page_url}" - # TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003 + # TODO(TheLovinator): Instead of having so many parameters, # ruff:ignore[missing-todo-link] # consider having a single "seo_info" parameter that # can contain all of these optional fields. This would make # it easier to extend in the future without changing the @@ -533,7 +533,7 @@ def docs_rss_view(request: HttpRequest) -> HttpResponse: # MARK: /debug/ -def debug_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914 +def debug_view(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-locals] """Debug view showing potentially broken or inconsistent data. Returns: @@ -772,8 +772,8 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse: Returns: HttpResponse: The rendered dataset backups page. """ - # TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # noqa: TD003 - # TODO(TheLovinator): Upload to s3 instead. # noqa: TD003 + # TODO(TheLovinator): Instead of only using sql we should also support other formats like parquet, csv, or json. # ruff:ignore[missing-todo-link] + # TODO(TheLovinator): Upload to s3 instead. # ruff:ignore[missing-todo-link] # TODO(TheLovinator): https://developers.google.com/search/docs/appearance/structured-data/dataset#json-ld datasets_root: Path = settings.DATA_DIR / "datasets" search_dirs: list[Path] = [datasets_root] @@ -889,7 +889,7 @@ def dataset_backup_download_view( Raises: Http404: When the file is not found or is outside the data directory. """ - # TODO(TheLovinator): Use s3 instead of local disk. # noqa: TD003 + # TODO(TheLovinator): Use s3 instead of local disk. # ruff:ignore[missing-todo-link] datasets_root: Path = settings.DATA_DIR / "datasets" requested_path: Path = (datasets_root / relative_path).resolve() @@ -994,7 +994,7 @@ def search_view(request: HttpRequest) -> HttpResponse: total_results_count: int = sum(len(qs) for qs in results.values()) - # TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # noqa: TD003 + # TODO(TheLovinator): Make the description more informative by including counts of each result type, e.g. "Found 5 games, 3 campaigns, and 10 drops for 'rust'." # ruff:ignore[missing-todo-link] if query: page_title: str = f"Search Results for '{query}'"[:60] page_description: str = f"Found {total_results_count} results for '{query}'." diff --git a/kick/feeds.py b/kick/feeds.py index 36f55b9..d2d65fb 100644 --- a/kick/feeds.py +++ b/kick/feeds.py @@ -496,7 +496,7 @@ class KickCategoryCampaignFeed(TTVDropsBaseFeed): self._limit = None return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # noqa: ARG002 + def get_object(self, request: HttpRequest, kick_id: int) -> KickCategory: # ruff:ignore[unused-method-argument] """Return game object for this feed URL.""" return KickCategory.objects.get(kick_id=kick_id) diff --git a/kick/management/commands/import_kick_drops.py b/kick/management/commands/import_kick_drops.py index 4027227..308e202 100644 --- a/kick/management/commands/import_kick_drops.py +++ b/kick/management/commands/import_kick_drops.py @@ -130,7 +130,7 @@ class Command(BaseCommand): try: payload: dict = response.json() - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] self.stderr.write(self.style.ERROR(f"Failed to parse JSON response: {exc}")) return @@ -166,7 +166,7 @@ class Command(BaseCommand): self.style.SUCCESS(f"Imported {imported}/{len(campaigns)} campaign(s)."), ) - def _import_campaign(self, data: KickDropCampaignSchema) -> None: # noqa: PLR0914, PLR0915 + def _import_campaign(self, data: KickDropCampaignSchema) -> None: # ruff:ignore[too-many-locals, too-many-statements] """Import a single campaign and all its related objects.""" # Organization org_data: KickOrganizationSchema = data.organization diff --git a/kick/models.py b/kick/models.py index 450434f..40a01c1 100644 --- a/kick/models.py +++ b/kick/models.py @@ -93,12 +93,12 @@ class KickCategory(auto_prefetch.Model): @property def get_absolute_url(self) -> str: - """Return the URL to the game detail page.""" + """The URL to the game detail page.""" return reverse("kick:game_detail", args=[self.kick_id]) @property def kick_url(self) -> str: - """Return the URL to the game page on Kick.""" + """The URL to the game page on Kick.""" return f"https://kick.com/category/{self.slug}" if self.slug else "" @@ -136,7 +136,7 @@ class KickUser(auto_prefetch.Model): @property def kick_profile_url(self) -> str: - """Return the Kick profile URL for this user.""" + """The Kick profile URL for this user.""" return f"https://kick.com/{self.username}" if self.username else "" @@ -183,7 +183,7 @@ class KickChannel(auto_prefetch.Model): @property def channel_url(self) -> str: - """Return the Kick channel URL.""" + """The Kick channel URL.""" return f"https://kick.com/{self.slug}" if self.slug else "" @@ -290,7 +290,7 @@ class KickDropCampaign(auto_prefetch.Model): @property def image_url(self) -> str: - """Return the image URL for the campaign.""" + """The image URL for the campaign.""" # Image from first drop rewards_prefetched: list[KickReward] | None = getattr( self, @@ -356,7 +356,7 @@ class KickDropCampaign(auto_prefetch.Model): @property def merged_rewards(self) -> list[KickReward]: - """Return rewards de-duplicated by normalized name. + """Rewards de-duplicated by normalized name. If both a base reward and a "(Con)" variant exist, prefer the base reward name. """ @@ -455,7 +455,7 @@ class KickReward(auto_prefetch.Model): @property def full_image_url(self) -> str: - """Return the absolute image URL for this reward. + """The absolute image URL for this reward. If the image_url is a relative path, prepend the Kick image base URL. """ diff --git a/kick/schemas.py b/kick/schemas.py index 2b26114..396dbb1 100644 --- a/kick/schemas.py +++ b/kick/schemas.py @@ -1,4 +1,4 @@ -from datetime import datetime # noqa: TC003 +from datetime import datetime # ruff:ignore[typing-only-standard-library-import] from pydantic import BaseModel from pydantic import Field diff --git a/kick/tasks.py b/kick/tasks.py index 9c50a9e..2eebd3b 100644 --- a/kick/tasks.py +++ b/kick/tasks.py @@ -9,7 +9,7 @@ logger = logging.getLogger("ttvdrops.tasks") @shared_task(bind=True, queue="api-fetches", max_retries=3, default_retry_delay=120) -def import_kick_drops(self) -> None: # noqa: ANN001 +def import_kick_drops(self) -> None: # ruff:ignore[missing-type-function-argument] """Fetch and upsert Kick drop campaigns from the public API.""" try: call_command("import_kick_drops") diff --git a/kick/tests/test_kick.py b/kick/tests/test_kick.py index 3f2925e..6d4153b 100644 --- a/kick/tests/test_kick.py +++ b/kick/tests/test_kick.py @@ -463,7 +463,7 @@ class KickDropCampaignMergedRewardsTest(TestCase): class ImportKickDropsCommandTest(TestCase): """Tests for the import_kick_drops management command.""" - def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # noqa: ANN401 + def _run_command(self, json_payload: dict, **options: Any) -> tuple[str, str]: # ruff:ignore[any-type] mock_response = MagicMock() mock_response.json.return_value = json_payload diff --git a/manage.py b/manage.py index 1077d0a..43ab60a 100755 --- a/manage.py +++ b/manage.py @@ -13,7 +13,7 @@ def main() -> None: """ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") try: - from django.core.management import execute_from_command_line # noqa: PLC0415 + from django.core.management import execute_from_command_line # ruff:ignore[import-outside-top-level, unsorted-imports] except ImportError as exc: msg = ( "Couldn't import Django. Are you sure it's installed and " diff --git a/tools/extract_historical_drops.py b/tools/extract_historical_drops.py index 65db357..9af65a0 100755 --- a/tools/extract_historical_drops.py +++ b/tools/extract_historical_drops.py @@ -16,7 +16,7 @@ import argparse import hashlib import json import shutil -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] import tempfile from pathlib import Path from typing import Any @@ -60,7 +60,7 @@ def run_git(git_dir: str, *args: str, check: bool = True) -> str: if not git: msg = "Git executable not found in PATH." raise FileNotFoundError(msg) - result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603 + result: subprocess.CompletedProcess[str] = subprocess.run( # ruff:ignore[subprocess-without-shell-equals-true] [git, "--git-dir", git_dir, *args], capture_output=True, text=True, diff --git a/twitch/api.py b/twitch/api.py index 4e94366..26af923 100644 --- a/twitch/api.py +++ b/twitch/api.py @@ -1,6 +1,6 @@ from __future__ import annotations -import datetime # noqa: TC003 +import datetime # ruff:ignore[typing-only-standard-library-import] from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Literal diff --git a/twitch/apps.py b/twitch/apps.py index d15fdbd..67d0d6c 100644 --- a/twitch/apps.py +++ b/twitch/apps.py @@ -15,7 +15,7 @@ class TwitchConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "twitch" - def ready(self) -> None: # noqa: D102 + def ready(self) -> None: # ruff:ignore[undocumented-public-method] logger: logging.Logger = logging.getLogger("ttvdrops.apps") # Patch FieldFile.open to swallow FileNotFoundError and provide @@ -39,18 +39,18 @@ class TwitchConfig(AppConfig): # Register post_save signal handlers that dispatch image download tasks # when new Twitch records are created. - from django.db.models.signals import m2m_changed # noqa: I001, PLC0415 - from django.db.models.signals import post_save # noqa: PLC0415 + from django.db.models.signals import m2m_changed # ruff:ignore[unsorted-imports, import-outside-top-level] + from django.db.models.signals import post_save # ruff:ignore[import-outside-top-level] - from twitch.models import DropBenefit # noqa: PLC0415 - from twitch.models import DropCampaign # noqa: PLC0415 - from twitch.models import Game # noqa: PLC0415 - from twitch.models import RewardCampaign # noqa: PLC0415 - from twitch.signals import on_drop_benefit_saved # noqa: PLC0415 - from twitch.signals import on_drop_campaign_allow_channels_changed # noqa: PLC0415 - from twitch.signals import on_drop_campaign_saved # noqa: PLC0415 - from twitch.signals import on_game_saved # noqa: PLC0415 - from twitch.signals import on_reward_campaign_saved # noqa: PLC0415 + from twitch.models import DropBenefit # ruff:ignore[import-outside-top-level] + from twitch.models import DropCampaign # ruff:ignore[import-outside-top-level] + from twitch.models import Game # ruff:ignore[import-outside-top-level] + from twitch.models import RewardCampaign # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_benefit_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_campaign_allow_channels_changed # ruff:ignore[import-outside-top-level] + from twitch.signals import on_drop_campaign_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_game_saved # ruff:ignore[import-outside-top-level] + from twitch.signals import on_reward_campaign_saved # ruff:ignore[import-outside-top-level] post_save.connect(on_game_saved, sender=Game) post_save.connect(on_drop_campaign_saved, sender=DropCampaign) diff --git a/twitch/feeds.py b/twitch/feeds.py index b5c503c..3c9e904 100644 --- a/twitch/feeds.py +++ b/twitch/feeds.py @@ -22,7 +22,7 @@ from django.utils.html import format_html_join from django.utils.safestring import SafeText from core.base_url import build_absolute_uri -from core.base_url import get_current_site # noqa: F811 +from core.base_url import get_current_site # ruff:ignore[redefined-while-unused] from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import DropCampaign @@ -185,9 +185,9 @@ class TTVDropsBaseFeed(Feed): Returns: SyndicationFeed: The feed generator instance with the correct site and URL context for absolute URL generation. """ - # TODO(TheLovinator): Refactor to avoid this mess. # noqa: TD003 + # TODO(TheLovinator): Refactor to avoid this mess. # ruff:ignore[missing-todo-link] try: - from django.contrib.sites import shortcuts as sites_shortcuts # noqa: I001, PLC0415 + from django.contrib.sites import shortcuts as sites_shortcuts # ruff:ignore[unsorted-imports, import-outside-top-level] except ImportError: sites_shortcuts = None @@ -575,7 +575,7 @@ def generate_channels_html(item: Model, *, hide_paid: bool = False) -> list[Safe ) if "twitch-chat-badges-guide" in getattr(game, "details_url", ""): - # TODO(TheLovinator): Improve detection of global emotes # noqa: TD003 + # TODO(TheLovinator): Improve detection of global emotes # ruff:ignore[missing-todo-link] parts.append( format_html( "{}", @@ -1177,7 +1177,7 @@ class GameCampaignFeed(TTVDropsBaseFeed): self._hide_paid = _query_bool(request, "hide_paid") return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 + def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] """Retrieve the Game instance for the given Twitch ID. Returns: @@ -1727,7 +1727,7 @@ class GameRewardCampaignFeed(TTVDropsBaseFeed): self._limit = None return super().__call__(request, *args, **kwargs) - def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # noqa: ARG002 + def get_object(self, request: HttpRequest, twitch_id: str) -> Game: # ruff:ignore[unused-method-argument] """Retrieve the Game instance for the given Twitch ID. Returns: diff --git a/twitch/management/commands/backfill_image_dimensions.py b/twitch/management/commands/backfill_image_dimensions.py index 9d876b0..3a0b468 100644 --- a/twitch/management/commands/backfill_image_dimensions.py +++ b/twitch/management/commands/backfill_image_dimensions.py @@ -14,7 +14,7 @@ class Command(BaseCommand): help = "Backfill image dimensions for existing cached images" - def handle(self, *args, **options) -> None: # noqa: ARG002, PLR0915 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument, too-many-statements] """Execute the command.""" total_updated = 0 diff --git a/twitch/management/commands/backup_db.py b/twitch/management/commands/backup_db.py index 753e844..bcdbbd5 100644 --- a/twitch/management/commands/backup_db.py +++ b/twitch/management/commands/backup_db.py @@ -2,7 +2,7 @@ import io import json import os import shutil -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] from compression import zstd from datetime import datetime from pathlib import Path @@ -191,11 +191,11 @@ def _write_table_rows( connection: SQLite connection. table: Table name. """ - cursor = connection.execute(f'SELECT * FROM "{table}"') # noqa: S608 + cursor = connection.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] columns = [description[0] for description in cursor.description] for row in cursor.fetchall(): values = ", ".join(_sql_literal(row[idx]) for idx in range(len(columns))) - handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # noqa: S608 + handle.write(f'INSERT INTO "{table}" VALUES ({values});\n') # ruff:ignore[hardcoded-sql-expression] def _write_indexes( @@ -268,7 +268,7 @@ def _write_postgres_dump(output_path: Path, tables: list[str]) -> None: for table in tables: cmd.extend(["-t", f"public.{table}"]) - process = subprocess.Popen( # noqa: S603 + process = subprocess.Popen( # ruff:ignore[subprocess-without-shell-equals-true] cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -339,7 +339,7 @@ def _write_json_dump(output_path: Path, tables: list[str]) -> None: data: dict[str, list[dict]] = {} with django_connection.cursor() as cursor: for table in tables: - cursor.execute(f'SELECT * FROM "{table}"') # noqa: S608 + cursor.execute(f'SELECT * FROM "{table}"') # ruff:ignore[hardcoded-sql-expression] columns: list[str] = [col[0] for col in cursor.description] rows = cursor.fetchall() data[table] = [dict(zip(columns, row, strict=False)) for row in rows] diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 81acda0..03ed611 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -403,7 +403,7 @@ def repair_partially_broken_json(raw_text: str) -> str: wrapped: str = f"[{raw_text}]" wrapped_data = json.loads(wrapped) # Validate that all items look like GraphQL responses - if isinstance(wrapped_data, list) and wrapped_data: # noqa: SIM102 + if isinstance(wrapped_data, list) and wrapped_data: # ruff:ignore[collapsible-if] # Check if all items have "data" or "extensions" (GraphQL response structure) if all( isinstance(item, dict) and ("data" in item or "extensions" in item) @@ -452,7 +452,7 @@ def repair_partially_broken_json(raw_text: str) -> str: valid_lines: list[dict[str, Any]] = [] for line in lines: - line: str = line.strip() # noqa: PLW2901 + line: str = line.strip() # ruff:ignore[redefined-loop-name] if line and line.startswith("{"): try: fixed_line: str = json_repair.repair_json(line, logging=False) @@ -749,7 +749,7 @@ class Command(BaseCommand): return channel_obj - def process_responses( # noqa: PLR0915 + def process_responses( # ruff:ignore[too-many-statements] self, responses: list[dict[str, Any]], file_path: Path, @@ -1180,7 +1180,7 @@ class Command(BaseCommand): msg: str = f"Path does not exist: {input_path}" raise CommandError(msg) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the command.""" colorama_init(autoreset=True) diff --git a/twitch/management/commands/cleanup_unknown_organizations.py b/twitch/management/commands/cleanup_unknown_organizations.py index 3f07263..e6ec0a9 100644 --- a/twitch/management/commands/cleanup_unknown_organizations.py +++ b/twitch/management/commands/cleanup_unknown_organizations.py @@ -49,7 +49,7 @@ class Command(BaseCommand): ), ) - def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 + def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Execute the command to detach the organization and optionally re-import data. Args: diff --git a/twitch/management/commands/download_box_art.py b/twitch/management/commands/download_box_art.py index 3d1b513..2a32cb2 100644 --- a/twitch/management/commands/download_box_art.py +++ b/twitch/management/commands/download_box_art.py @@ -39,7 +39,7 @@ class Command(BaseCommand): help="Re-download even if a local box art file already exists.", ) - def handle( # noqa: PLR0914, PLR0915 + def handle( # ruff:ignore[too-many-locals, too-many-statements] self, *_args: str, **options: str | bool | int | None, diff --git a/twitch/management/commands/import_chat_badges.py b/twitch/management/commands/import_chat_badges.py index 43906be..2e0d463 100644 --- a/twitch/management/commands/import_chat_badges.py +++ b/twitch/management/commands/import_chat_badges.py @@ -50,7 +50,7 @@ class Command(BaseCommand): help="Twitch Access Token (optional - will be obtained automatically if not provided)", ) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the command. Raises: diff --git a/twitch/management/commands/import_twitch_drops_api.py b/twitch/management/commands/import_twitch_drops_api.py index f8119ec..8791b18 100644 --- a/twitch/management/commands/import_twitch_drops_api.py +++ b/twitch/management/commands/import_twitch_drops_api.py @@ -27,7 +27,7 @@ import hashlib import json import os import pathlib -import subprocess # noqa: S404 +import subprocess # ruff:ignore[suspicious-subprocess-import] import tempfile from typing import TYPE_CHECKING from typing import Any @@ -194,7 +194,7 @@ class Command(BaseCommand): ), ) - def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002, PLR0914, PLR0915 + def handle(self, *args: Any, **options: Any) -> None: # ruff:ignore[any-type, unused-method-argument, too-many-locals, too-many-statements] """Main entry point for the command. Raises: @@ -301,7 +301,7 @@ class Command(BaseCommand): crash_on_error=crash_on_error, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] msg = f"Failed to import drops.json: {exc}" self.stderr.write(self.style.ERROR(msg)) errors.append(msg) @@ -315,7 +315,7 @@ class Command(BaseCommand): crash_on_error=crash_on_error, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] msg = f"Failed to import rewards.json: {exc}" self.stderr.write(self.style.ERROR(msg)) errors.append(msg) @@ -353,7 +353,7 @@ class Command(BaseCommand): # Historical import via git clone # ------------------------------------------------------------------ - def _process_historical( # noqa: PLR0913 + def _process_historical( # ruff:ignore[too-many-arguments] self, source: str, *, @@ -423,7 +423,7 @@ class Command(BaseCommand): max_commits=max_commits, skip_existing=skip_existing, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] errors.append(str(exc)) finally: if own_clone: @@ -431,7 +431,7 @@ class Command(BaseCommand): return drops_count, rewards_count, errors - def _import_historical_file( # noqa: PLR0913 + def _import_historical_file( # ruff:ignore[too-many-arguments] self, git_dir: str, file_path: str, @@ -581,7 +581,7 @@ class Command(BaseCommand): Stdout of the git command. """ cmd = ["git", "--git-dir", git_dir, *args] - result = subprocess.run(cmd, capture_output=True, text=True, check=check) # noqa: S603 + result = subprocess.run(cmd, capture_output=True, text=True, check=check) # ruff:ignore[subprocess-without-shell-equals-true] return result.stdout def _rmtree(self, path: str) -> None: @@ -659,7 +659,7 @@ class Command(BaseCommand): ) @staticmethod - def _strip_typename(data: Any) -> Any: # noqa: ANN401 + def _strip_typename(data: Any) -> Any: # ruff:ignore[any-type] """Recursively remove ``__typename`` keys from parsed JSON data. Old commits in the repo included GraphQL ``__typename`` metadata which @@ -683,7 +683,7 @@ class Command(BaseCommand): return [Command._strip_typename(item) for item in data] return data - def _generate_report( # noqa: PLR0914, PLR0915 + def _generate_report( # ruff:ignore[too-many-locals, too-many-statements] self, drops_url: str, rewards_url: str, @@ -707,7 +707,7 @@ class Command(BaseCommand): Raises: ValidationError: If any item fails schema validation and crash_on_error is True. """ - from collections import Counter # noqa: PLC0415 + from collections import Counter # ruff:ignore[import-outside-top-level] self.stdout.write("=" * 60) self.stdout.write(self.style.SUCCESS("COMPARISON REPORT")) @@ -719,14 +719,14 @@ class Command(BaseCommand): # Track differences for field-level comparison sample_limit = 5 - for label, raw_url, model_cls, id_field in [ # noqa: PLR1702 + for label, raw_url, model_cls, id_field in [ # ruff:ignore[too-many-nested-blocks] ("drops", drops_url, DropCampaign, "twitch_id"), ("rewards", rewards_url, RewardCampaign, "twitch_id"), ]: self.stdout.write(f"\n--- {label.upper()} ---") try: raw_data = self._fetch_json(raw_url, f"{label}.json") - except Exception as exc: # noqa: BLE001 + except Exception as exc: # ruff:ignore[blind-except] self.stderr.write( self.style.ERROR(f" Failed to fetch {label}.json: {exc}"), ) @@ -894,7 +894,7 @@ class Command(BaseCommand): self.stdout.write("\n" + "=" * 60) - def _parse_and_import_drops( # noqa: PLR0913 + def _parse_and_import_drops( # ruff:ignore[too-many-arguments] self, raw_data: list[dict[str, Any]], source: str, @@ -976,7 +976,7 @@ class Command(BaseCommand): return total_campaigns - def _process_sunkwibot_reward( # noqa: PLR0915 + def _process_sunkwibot_reward( # ruff:ignore[too-many-statements] self, reward: SunkwiBotRewardSchema, game_obj: Game, @@ -1133,7 +1133,7 @@ class Command(BaseCommand): self, tbd_schema: SunkwiBotTimeBasedDropSchema, campaign_obj: DropCampaign, - source: str, # noqa: ARG002 + source: str, # ruff:ignore[unused-method-argument] *, verbose: bool, ) -> None: @@ -1335,7 +1335,7 @@ class Command(BaseCommand): skip_existing=skip_existing, ) - def _parse_and_import_rewards( # noqa: PLR0913 + def _parse_and_import_rewards( # ruff:ignore[too-many-arguments] self, raw_data: list[dict[str, Any]], source: str, @@ -1563,12 +1563,12 @@ class Command(BaseCommand): self._org_cache[twitch_id] = org_obj return org_obj - def _get_or_create_game( # noqa: PLR0913 + def _get_or_create_game( # ruff:ignore[too-many-arguments] self, twitch_id: str, display_name: str, box_art_url: str, - source: str, # noqa: ARG002 + source: str, # ruff:ignore[unused-method-argument] *, verbose: bool, org_obj: Organization | None = None, diff --git a/twitch/management/commands/watch_imports.py b/twitch/management/commands/watch_imports.py index d1f6ca3..96e8549 100644 --- a/twitch/management/commands/watch_imports.py +++ b/twitch/management/commands/watch_imports.py @@ -37,7 +37,7 @@ class Command(BaseCommand): help="Path to directory to watch for JSON files", ) - def handle(self, *args, **options) -> None: # noqa: ARG002 + def handle(self, *args, **options) -> None: # ruff:ignore[unused-method-argument] """Main entry point for the watch command. Args: @@ -87,7 +87,7 @@ class Command(BaseCommand): importer_command: An instance of the BetterImportDropsCommand to handle the import logic. watch_path: The directory path to watch for JSON files. """ - # TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # noqa: TD003 + # TODO(TheLovinator): Implement actual file watching using watchdog or similar library. # ruff:ignore[missing-todo-link] json_files: list[Path] = [ f for f in watch_path.iterdir() if f.suffix == ".json" and f.is_file() ] diff --git a/twitch/migrations/0007_rename_operation_name_to_operation_names.py b/twitch/migrations/0007_rename_operation_name_to_operation_names.py index 63190e1..bbe6ac2 100644 --- a/twitch/migrations/0007_rename_operation_name_to_operation_names.py +++ b/twitch/migrations/0007_rename_operation_name_to_operation_names.py @@ -5,7 +5,7 @@ from django.db import migrations from django.db import models -def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001 +def migrate_operation_name_to_list(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """Convert operation_name string values to operation_names list.""" DropCampaign = apps.get_model("twitch", "DropCampaign") for campaign in DropCampaign.objects.all(): @@ -14,7 +14,7 @@ def migrate_operation_name_to_list(apps, schema_editor) -> None: # noqa: ANN001 campaign.save(update_fields=["operation_names"]) -def reverse_operation_names_to_string(apps, schema_editor) -> None: # noqa: ANN001 +def reverse_operation_names_to_string(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """Convert operation_names list back to operation_name string (first item only).""" DropCampaign = apps.get_model("twitch", "DropCampaign") for campaign in DropCampaign.objects.all(): diff --git a/twitch/migrations/0016_mark_all_drops_fully_imported.py b/twitch/migrations/0016_mark_all_drops_fully_imported.py index 54b4003..5a9ef00 100644 --- a/twitch/migrations/0016_mark_all_drops_fully_imported.py +++ b/twitch/migrations/0016_mark_all_drops_fully_imported.py @@ -1,7 +1,7 @@ from django.db import migrations -def mark_all_drops_fully_imported(apps, schema_editor) -> None: # noqa: ANN001 +def mark_all_drops_fully_imported(apps, schema_editor) -> None: # ruff:ignore[missing-type-function-argument] """Marks all existing DropCampaigns as fully imported. This was needed to ensure that the Twitch API view only returns campaigns that are ready for display. diff --git a/twitch/models.py b/twitch/models.py index 934d169..7ff20f7 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -137,7 +137,7 @@ class Game(auto_prefetch.Model): default="", ) - box_art = models.URLField( # noqa: DJ001 + box_art = models.URLField( # ruff:ignore[django-nullable-model-string-field] verbose_name="Box art URL", max_length=500, blank=True, @@ -231,12 +231,12 @@ class Game(auto_prefetch.Model): @property def organizations(self) -> models.QuerySet[Organization]: - """Return orgs that own games with campaigns for this game.""" + """Orgs that own games with campaigns for this game.""" return Organization.objects.filter(games__drop_campaigns__game=self).distinct() @property def get_game_name(self) -> str: - """Return the best available name for the game.""" + """The best available name for the game.""" if self.display_name: return self.display_name if self.name: @@ -247,8 +247,8 @@ class Game(auto_prefetch.Model): @property def twitch_directory_url(self) -> str: - """Return Twitch directory URL with drops filter when slug exists.""" - # TODO(TheLovinator): If no slug, get from Twitch API or IGDB? # noqa: TD003 + """Twitch directory URL with drops filter when slug exists.""" + # TODO(TheLovinator): If no slug, get from Twitch API or IGDB? # ruff:ignore[missing-todo-link] if self.slug: return f"https://www.twitch.tv/directory/category/{self.slug}?filter=drops" @@ -256,7 +256,7 @@ class Game(auto_prefetch.Model): @property def box_art_best_url(self) -> str: - """Return the best available URL for the game's box art (local first).""" + """The best available URL for the game's box art (local first).""" try: if self.box_art_file and getattr(self.box_art_file, "url", None): return self.box_art_file.url @@ -271,7 +271,7 @@ class Game(auto_prefetch.Model): @property def dashboard_box_art_url(self) -> str: - """Return dashboard-safe box art URL without touching deferred image fields.""" + """Dashboard-safe box art URL without touching deferred image fields.""" return normalize_twitch_box_art_url(self.box_art or "") @classmethod @@ -570,7 +570,7 @@ class Channel(auto_prefetch.Model): @property def preferred_name(self) -> str: - """Return display name fallback used by channel-facing pages.""" + """Display name fallback used by channel-facing pages.""" return self.display_name or self.name or self.twitch_id def detail_description(self, total_campaigns: int) -> str: @@ -580,7 +580,7 @@ class Channel(auto_prefetch.Model): # MARK: DropCampaign -class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 +class DropCampaign(auto_prefetch.Model): # ruff:ignore[too-many-public-methods] """Represents a Twitch drop campaign.""" twitch_id = models.TextField( @@ -1270,7 +1270,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def clean_name(self) -> str: - """Return the campaign name without the game name prefix. + """The campaign name without the game name prefix. Examples: "Ravendawn - July 2" -> "July 2" @@ -1302,7 +1302,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def single_reward_benefit(self) -> DropBenefit | None: - """Return the only unique reward benefit for this campaign, if it has one.""" + """The only unique reward benefit for this campaign, if it has one.""" benefits: list[DropBenefit] = [] seen_benefit_keys: set[int | str] = set() @@ -1321,7 +1321,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def single_reward_image_best_url(self) -> str: - """Return the best image URL for a campaign that has exactly one reward.""" + """The best image URL for a campaign that has exactly one reward.""" benefit: DropBenefit | None = self.single_reward_benefit if not benefit: return "" @@ -1329,12 +1329,12 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def meta_image_url(self) -> str: - """Return the preferred campaign image URL for SEO metadata.""" + """The preferred campaign image URL for SEO metadata.""" return self.single_reward_image_best_url or self.image_best_url @property def image_best_url(self) -> str: - """Return the best URL for the campaign image. + """The best URL for the campaign image. Priority: 1. Local cached image file @@ -1361,7 +1361,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def listing_image_url(self) -> str: - """Return a campaign image URL optimized for list views. + """A campaign image URL optimized for list views. This intentionally avoids traversing drops/benefits to prevent N+1 queries in list pages that render many campaigns. @@ -1375,7 +1375,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def dashboard_image_url(self) -> str: - """Return dashboard-safe campaign or single-reward image URL.""" + """Dashboard-safe campaign or single-reward image URL.""" benefit: DropBenefit | None = self.single_reward_benefit if benefit and benefit.image_asset_url: return benefit.image_asset_url @@ -1383,7 +1383,7 @@ class DropCampaign(auto_prefetch.Model): # noqa: PLR0904 @property def duration_iso(self) -> str: - """Return the campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). + """The campaign duration in ISO 8601 format (e.g., 'P3DT4H30M'). This is used for the