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/*",