From 95b2171296e6578232c94f6638a6fa410933c525 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Sun, 18 Oct 2026 19:40:11 -0700 Subject: [PATCH] Clear ty's 99 override errors `make ty` (ty 0.0.89) failed with 99 diagnostics on develop. False positives (84): every `class Meta(.Meta)` in codex/models and codex/serializers trips invalid-attribute-override (https://github.com/astral-sh/ty/issues/4711). A [[tool.ty.overrides]] block turns that rule off for those two trees only, as comicbox 5.3.2 does for its schemas. It also hides the deliberate Volume.name and CustomCover.library overrides there; the pyproject comment says to mark them with ty ignores when the block goes. Real (15): - permission_classes (7): AuthMixin declared Sequence[type[BasePermission]], narrower than DRF's Sequence[_PermissionClass], so every subclass that set its own classes clashed with APIView. Using DRF's type fixes all seven, lets three views drop their copied annotation, and retires three pyright reportIncompatibleVariableOverride ignores (AuthAPIView, AuthGenericAPIView, OpenSearch1View). - input_serializer_class (4): BrowserParamsView typed it type[BrowserSettingsSerializerBase], yet choices, metadata and mtime set a superclass or sibling serializer. Widened to their common base, BrowserFilterChoicesInputSerializer, which is all init_params needs; subclasses now inherit the type, dropping three pyright ignores. - NULL_CODE (1): the mixin is typed int | str, since VuetifyCharField compares against a str; drops a pyright ignore. - content_negotiation_class (1): djangorestframework-stubs types it str | None, but DRF resolves the setting to a class, so the override is right; ignored for both checkers with that reason. - Volume.name, CustomCover.library (2): deliberate type-changing model overrides, covered by the scoped override above. Co-Authored-By: Claude Opus 5.5 --- codex/serializers/fields/vuetify.py | 5 +++-- codex/views/admin/tagwrite.py | 5 ++--- codex/views/auth.py | 12 ++++++++---- codex/views/browser/choices.py | 4 +--- codex/views/browser/force_update.py | 5 ++--- codex/views/browser/metadata/__init__.py | 4 +--- codex/views/browser/mtime.py | 4 +--- codex/views/browser/params.py | 6 ++++-- codex/views/frontend.py | 4 ++-- codex/views/opds/binary.py | 4 +++- codex/views/opds/opensearch/v1.py | 2 +- codex/views/opds/v1/feed.py | 2 +- pyproject.toml | 13 +++++++++++++ 13 files changed, 42 insertions(+), 28 deletions(-) diff --git a/codex/serializers/fields/vuetify.py b/codex/serializers/fields/vuetify.py index 7e41aa2b0..73150460d 100644 --- a/codex/serializers/fields/vuetify.py +++ b/codex/serializers/fields/vuetify.py @@ -21,7 +21,8 @@ class VuetifyNullCodeFieldMixin: """Convert Vuetify null codes to None.""" - NULL_CODE: int = VUETIFY_NULL_CODE + # Compared against the parsed value, so char fields set it as a str. + NULL_CODE: int | str = VUETIFY_NULL_CODE def to_internal_value(self, data): """Convert numeric null code to None.""" @@ -57,7 +58,7 @@ class VuetifyIntegerField(VuetifyNullCodeFieldMixin, IntegerField): # pyright: class VuetifyCharField(VuetifyNullCodeFieldMixin, CharField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override] """Char Field with null code conversion.""" - NULL_CODE: str = str(VUETIFY_NULL_CODE) # pyright: ignore[reportIncompatibleVariableOverride] + NULL_CODE = str(VUETIFY_NULL_CODE) class VuetifyBooleanField(VuetifyNullCodeFieldMixin, BooleanField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override] diff --git a/codex/views/admin/tagwrite.py b/codex/views/admin/tagwrite.py index 6fced5581..188497d57 100644 --- a/codex/views/admin/tagwrite.py +++ b/codex/views/admin/tagwrite.py @@ -1,13 +1,12 @@ """Admin Tag Write View.""" import json -from collections.abc import Sequence from functools import cached_property from pathlib import Path from types import MappingProxyType from typing import override -from rest_framework.permissions import BasePermission, IsAdminUser +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.status import HTTP_202_ACCEPTED @@ -43,7 +42,7 @@ class FilteredComicPksView(BrowserFilterView): :class:`~codex.views.browser.force_update.ForceUpdateView`. """ - permission_classes: Sequence[type[BasePermission]] = (IsAdminUser,) + permission_classes = (IsAdminUser,) TARGET: str = "force_update" # recursive folder rel + filter semantics def __init__(self, *args, **kwargs) -> None: diff --git a/codex/views/auth.py b/codex/views/auth.py index 287414a2b..6b19ccec4 100644 --- a/codex/views/auth.py +++ b/codex/views/auth.py @@ -52,7 +52,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.exceptions import NotAuthenticated from rest_framework.generics import GenericAPIView -from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -72,6 +72,8 @@ from codex.views.envelope import EnvelopeJSONRenderer as _EnvelopeJSONRenderer if TYPE_CHECKING: + # _PermissionClass exists only in djangorestframework-stubs, not at runtime. + from rest_framework.permissions import _PermissionClass from rest_framework.request import Request @@ -111,7 +113,9 @@ class AuthMixin: # of the GET dict when the URL pattern doesn't carry it. requires_page: bool = False - permission_classes: Sequence[type[BasePermission]] = ( + # DRF's own type, so subclasses that set their own classes override + # APIView's declaration and this one alike. + permission_classes: Sequence["_PermissionClass"] = ( IsAuthenticatedOrEnabledNonUsers, ) renderer_classes = (_EnvelopeJSONRenderer,) @@ -177,11 +181,11 @@ def _ensure_session_key(self) -> str | None: return session.session_key -class AuthAPIView(AuthMixin, APIView): # pyright: ignore[reportIncompatibleVariableOverride] +class AuthAPIView(AuthMixin, APIView): """Auth Policy APIView.""" -class AuthGenericAPIView(AuthMixin, GenericAPIView): # pyright: ignore[reportIncompatibleVariableOverride] +class AuthGenericAPIView(AuthMixin, GenericAPIView): """Auth Policy GenericAPIView.""" diff --git a/codex/views/browser/choices.py b/codex/views/browser/choices.py index 8f5ed6b64..b39158df7 100644 --- a/codex/views/browser/choices.py +++ b/codex/views/browser/choices.py @@ -83,9 +83,7 @@ class BrowserChoicesViewBase(BrowserFilterView): """Get choices for filter dialog.""" - input_serializer_class: type[BrowserFilterChoicesInputSerializer] = ( # pyright: ignore[reportIncompatibleVariableOverride] - BrowserFilterChoicesInputSerializer - ) + input_serializer_class = BrowserFilterChoicesInputSerializer TARGET: str = "choices" @staticmethod diff --git a/codex/views/browser/force_update.py b/codex/views/browser/force_update.py index 996354d31..26cd0223d 100644 --- a/codex/views/browser/force_update.py +++ b/codex/views/browser/force_update.py @@ -1,12 +1,11 @@ """Force update browser view: re-import metadata for a filtered comic set.""" -from collections.abc import Sequence from functools import cached_property from types import MappingProxyType from typing import override from drf_spectacular.utils import extend_schema -from rest_framework.permissions import BasePermission, IsAdminUser +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from codex.librarian.mp_queue import LIBRARIAN_QUEUE @@ -19,7 +18,7 @@ class ForceUpdateView(BrowserFilterView): """Force a metadata re-import for every comic under a collection + filters.""" - permission_classes: Sequence[type[BasePermission]] = (IsAdminUser,) + permission_classes = (IsAdminUser,) serializer_class = OKSerializer TARGET: str = "force_update" diff --git a/codex/views/browser/metadata/__init__.py b/codex/views/browser/metadata/__init__.py index d74fa362b..52015d16f 100644 --- a/codex/views/browser/metadata/__init__.py +++ b/codex/views/browser/metadata/__init__.py @@ -22,9 +22,7 @@ class MetadataView(MetadataCopyIntersectionsView): """Aggregate Collection and Comic Metadata View.""" serializer_class: type[BaseSerializer] | None = MetadataSerializer - input_serializer_class: type[BrowserFilterChoicesInputSerializer] = ( # pyright: ignore[reportIncompatibleVariableOverride] - BrowserFilterChoicesInputSerializer - ) + input_serializer_class = BrowserFilterChoicesInputSerializer TARGET: str = "metadata" ADMIN_FLAGS = (AdminFlagChoices.FOLDER_VIEW,) diff --git a/codex/views/browser/mtime.py b/codex/views/browser/mtime.py index c19b60ce0..3f6fd76f0 100644 --- a/codex/views/browser/mtime.py +++ b/codex/views/browser/mtime.py @@ -24,9 +24,7 @@ class MtimeView(BrowserCollectionMtimeView): """Get the mtimes for the submitted collections.""" - input_serializer_class: type[CollectionsMtimeSerializer] = ( # pyright: ignore[reportIncompatibleVariableOverride] - CollectionsMtimeSerializer - ) + input_serializer_class = CollectionsMtimeSerializer serializer_class: type[BaseSerializer] | None = MtimeSerializer TARGET: str = "mtime" diff --git a/codex/views/browser/params.py b/codex/views/browser/params.py index 61b4f76f6..65a39c41b 100644 --- a/codex/views/browser/params.py +++ b/codex/views/browser/params.py @@ -10,8 +10,8 @@ from codex.librarian.bookmark.tasks import LastRouteUpdateTask from codex.librarian.mp_queue import LIBRARIAN_QUEUE from codex.serializers.browser.settings import ( + BrowserFilterChoicesInputSerializer, BrowserSettingsSerializer, - BrowserSettingsSerializerBase, ) from codex.views.browser.settings import BrowserSettingsBaseView @@ -19,7 +19,9 @@ class BrowserParamsView(BrowserSettingsBaseView): """Browser Params Parsing.""" - input_serializer_class: type[BrowserSettingsSerializerBase] = ( + # The common base of every input serializer a subclass sets: choices + # and metadata take filters only, mtime its own shape. + input_serializer_class: type[BrowserFilterChoicesInputSerializer] = ( BrowserSettingsSerializer ) diff --git a/codex/views/frontend.py b/codex/views/frontend.py index f817f6812..573011721 100644 --- a/codex/views/frontend.py +++ b/codex/views/frontend.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from caseconverter import camelcase -from rest_framework.permissions import AllowAny, BasePermission +from rest_framework.permissions import AllowAny from rest_framework.renderers import BaseRenderer, TemplateHTMLRenderer from rest_framework.response import Response @@ -15,7 +15,7 @@ class IndexView(BrowserSettingsBaseView, UserActiveMixin): """The main app.""" - permission_classes: Sequence[type[BasePermission]] = (AllowAny,) + permission_classes = (AllowAny,) renderer_classes: Sequence[type[BaseRenderer]] = [TemplateHTMLRenderer] template_name = "index.html" diff --git a/codex/views/opds/binary.py b/codex/views/opds/binary.py index e7c003462..ccc73cc24 100644 --- a/codex/views/opds/binary.py +++ b/codex/views/opds/binary.py @@ -45,6 +45,8 @@ class OPDSDownloadView(OPDSAuthMixin, DownloadView): class OPDSPageView(OPDSAuthMixin, ReaderPageView): """Page View with Basic Auth.""" - content_negotiation_class: type[BaseContentNegotiation] = ( # pyright:ignore[reportIncompatibleVariableOverride] + # djangorestframework-stubs types this `str | None`, but DRF resolves + # the DEFAULT_CONTENT_NEGOTIATION_CLASS import string to a class. + content_negotiation_class: type[BaseContentNegotiation] = ( # pyright: ignore[reportIncompatibleVariableOverride], # ty: ignore[invalid-attribute-override] IgnoreClientContentNegotiation ) diff --git a/codex/views/opds/opensearch/v1.py b/codex/views/opds/opensearch/v1.py index c5bd1fe21..d58ebfd42 100644 --- a/codex/views/opds/opensearch/v1.py +++ b/codex/views/opds/opensearch/v1.py @@ -39,7 +39,7 @@ class _OpenSearchTemplateMixin(CodexXMLTemplateMixin): @extend_schema(responses={("200", MimeType.OPENSEARCH): OpenApiTypes.BYTE}) -class OpenSearch1View(_OpenSearchTemplateMixin, OPDSAuthMixin, CodexAPIView): # pyright: ignore[reportIncompatibleVariableOverride] +class OpenSearch1View(_OpenSearchTemplateMixin, OPDSAuthMixin, CodexAPIView): """OpenSearchView.""" # ``_OpenSearchTemplateMixin`` must precede ``OPDSAuthMixin`` so its diff --git a/codex/views/opds/v1/feed.py b/codex/views/opds/v1/feed.py index c2d1dace1..185da6eac 100644 --- a/codex/views/opds/v1/feed.py +++ b/codex/views/opds/v1/feed.py @@ -77,7 +77,7 @@ class OPDS1FeedView(OPDS1LinksView): template_name = "opds_v1/index.xml" serializer_class: type[BaseSerializer] | None = OPDS1TemplateSerializer - input_serializer_class: type[OPDSSettingsSerializer] = OPDSSettingsSerializer + input_serializer_class = OPDSSettingsSerializer throttle_classes: Sequence[type[BaseThrottle]] = (ScopedRateThrottle,) throttle_scope = "opds" # The collection feed URLs carry no page segment; AuthMixin pulls diff --git a/pyproject.toml b/pyproject.toml index afaa10cae..c05a47cd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -396,6 +396,19 @@ exclude = [ "vulture_ignorelist.py", ] +# ty >= 0.0.86 rejects the `class Meta(.Meta)` idiom of Django models and +# DRF serializers as an invalid override, though the nested class subclasses the +# one it replaces (https://github.com/astral-sh/ty/issues/4711). Scoped to the +# models and serializers so the check still guards every other module; drop this +# once that issue is fixed, and then mark the two deliberate overrides it also +# hides, Volume.name and CustomCover.library, with +# `# ty: ignore[invalid-attribute-override]`. +[[tool.ty.overrides]] +include = ["codex/models/**", "codex/serializers/**"] + +[tool.ty.overrides.rules] +invalid-attribute-override = "ignore" + [tool.vulture] exclude = [ "*/.venv/*",