**Replace Flake8 with Ruff, refactor type hints, and enhance archive processing logic**

- Removed `flake8` and `flake8-annotations` from dependencies, replaced by `ruff` for linting.
- Introduced cleaner type hinting and modern Python annotations (PEP 604 style) across multiple modules.
- Moved archive processing to a dedicated helper (`comic.archives`) for improved maintainability.
- Updated and simplified logic for archive file retrieval, directory cleaning, and user access validation.
- Improved error handling and added support for user classification restrictions in API responses.
This commit is contained in:
ajurna committed 2026-10-13 11:36:28 +01:00
1 parent 147a3a54ef
commit 1148f4072b
13 files changed
+226 -217

No files matched your search

+5
View File
@@ -8,3 +8,8 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.0
hooks:
- id: ruff
args: [ --fix ]
+4 -3
View File
@@ -23,10 +23,11 @@ from drf_yasg import openapi
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from rest_framework import permissions from rest_framework import permissions
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
# from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView
from comic import rest, feeds # from rest_framework_extensions.routers import ExtendedDefaultRouter
from rest_framework_simplejwt.views import TokenBlacklistView, TokenObtainPairView, TokenRefreshView
from comic import feeds, rest
schema_view = get_schema_view( schema_view = get_schema_view(
openapi.Info( openapi.Info(
+1 -2
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from .models import Directory, ComicBook, ComicStatus, UserMisc from .models import ComicBook, ComicStatus, Directory, UserMisc
@admin.register(Directory) @admin.register(Directory)
+30
View File
@@ -0,0 +1,30 @@
"""Shared helpers for listing the pages inside a comic archive."""
import mimetypes
import zipfile
from typing import NamedTuple
import rarfile
class ArchiveFile(NamedTuple):
file_name: str
mime_type: str
def get_archive_files(archive: zipfile.ZipFile | rarfile.RarFile) -> list[ArchiveFile]:
"""Return the image entries of an archive in page order.
Metadata entries such as ComicInfo.xml are not pages. Counting them would make
page_count disagree with the page list the reader uses, so a book could never
be marked as finished.
"""
result = []
for name in sorted(archive.namelist()):
if name.endswith('/'):
continue
mime_type = mimetypes.guess_type(name)[0]
if not mime_type or name.lower().endswith('.xml'):
continue
result.append(ArchiveFile(name, mime_type))
return result
+2 -2
View File
@@ -1,10 +1,10 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import Case, When, PositiveSmallIntegerField, F, QuerySet from django.db.models import Case, F, PositiveSmallIntegerField, QuerySet, When
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from comic.models import ComicBook, UserMisc, Directory from comic.models import ComicBook, Directory, UserMisc
class RecentComicsAPI(Feed): class RecentComicsAPI(Feed):
+1 -2
View File
@@ -1,4 +1,3 @@
from typing import Optional
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
@@ -27,7 +26,7 @@ class Command(BaseCommand):
self.OUTPUT = options.get('out', False) self.OUTPUT = options.get('out', False)
self.scan_directory() self.scan_directory()
def scan_directory(self, user: Optional[AbstractBaseUser] = None, directory: Optional[Directory] = None) -> None: def scan_directory(self, user: AbstractBaseUser | None = None, directory: Directory | None = None) -> None:
if not user: if not user:
user_model = get_user_model() user_model = get_user_model()
user = user_model.objects.first() user = user_model.objects.first()
+17 -23
View File
@@ -1,16 +1,13 @@
import io import io
import mimetypes
import uuid import uuid
import zipfile import zipfile
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from typing import Optional, List, Union, Tuple, Final, IO from typing import IO, Final
# noinspection PyPackageRequirements # noinspection PyPackageRequirements
import pymupdf import pymupdf
import rarfile import rarfile
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
@@ -19,7 +16,10 @@ from django.db.models import UniqueConstraint
from django_boost.models.fields import AutoOneToOneField from django_boost.models.fields import AutoOneToOneField
from imagekit.models import ProcessedImageField from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFill from imagekit.processors import ResizeToFill
from PIL import Image, UnidentifiedImageError
from PIL.Image import Image as Image_type
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
if settings.UNRAR_TOOL: if settings.UNRAR_TOOL:
@@ -84,7 +84,7 @@ class Directory(models.Model):
return reduce(lambda x, y: Path(x, y), path_items) return reduce(lambda x, y: Path(x, y), path_items)
return Path(path_items[0]) return Path(path_items[0])
def get_path_items(self, path_items: Optional[List] = None) -> List[Path]: def get_path_items(self, path_items: list | None = None) -> list[Path]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self.name) path_items.append(self.name)
@@ -92,7 +92,7 @@ class Directory(models.Model):
self.parent.get_path_items(path_items) self.parent.get_path_items(path_items)
return path_items return path_items
def get_path_objects(self, path_items: Optional[List] = None) -> List["Directory"]: def get_path_objects(self, path_items: list | None = None) -> list[Directory]:
if path_items is None: if path_items is None:
path_items = [] path_items = []
path_items.append(self) path_items.append(self)
@@ -141,7 +141,7 @@ class ComicBook(models.Model):
return Path(base_dir, self.directory.get_path(), self.file_name) return Path(base_dir, self.directory.get_path(), self.file_name)
return Path(base_dir, self.file_name) return Path(base_dir, self.file_name)
def get_image(self, page: int) -> Union[Tuple[IO[bytes], str], Tuple[bool, bool]]: def get_image(self, page: int) -> tuple[IO[bytes], str] | tuple[bool, bool]:
if self.file_name.lower().endswith('.pdf'): if self.file_name.lower().endswith('.pdf'):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
doc = pymupdf.open(self.get_pdf()) doc = pymupdf.open(self.get_pdf())
@@ -168,15 +168,16 @@ class ComicBook(models.Model):
except zipfile.BadZipfile: except zipfile.BadZipfile:
return False, False return False, False
file_name, file_mime = self.get_archive_files(archive)[page] file_name, file_mime = get_archive_files(archive)[page]
return archive.open(file_name), file_mime return archive.open(file_name), file_mime
def generate_thumbnail_pdf(self, page_index: int = 0) -> Tuple[io.BytesIO, Image_type, str]: def generate_thumbnail_pdf(self, page_index: int = 0) -> tuple[io.BytesIO, Image_type, str]:
img, pil_data = self._get_pdf_image(page_index if page_index else 0) img, pil_data = self._get_pdf_image(page_index if page_index else 0)
return img, pil_data, 'Image/JPEG' return img, pil_data, 'Image/JPEG'
def generate_thumbnail_archive(self, page_index: int = 0) -> Union[Tuple[io.BytesIO, Image_type, str], def generate_thumbnail_archive(
Tuple[bool, bool, bool]]: self, page_index: int = 0
) -> tuple[io.BytesIO, Image_type, str] | tuple[bool, bool, bool]:
img, content_type, pil_data = False, False, False img, content_type, pil_data = False, False, False
if page_index: if page_index:
img, content_type = self.get_image(page_index) img, content_type = self.get_image(page_index)
@@ -209,7 +210,7 @@ class ComicBook(models.Model):
) )
self.save() self.save()
def _get_pdf_image(self, page_index: int) -> Tuple[io.BytesIO, Image_type]: def _get_pdf_image(self, page_index: int) -> tuple[io.BytesIO, Image_type]:
doc = pymupdf.open(self.get_pdf()) doc = pymupdf.open(self.get_pdf())
page = doc[page_index] page = doc[page_index]
pix = page.get_pixmap() pix = page.get_pixmap()
@@ -222,7 +223,7 @@ class ComicBook(models.Model):
return img, pil_data return img, pil_data
@staticmethod @staticmethod
def process_comic_book(comic_file_path: Path, directory: "Directory" = False) -> Union["ComicBook", Path]: def process_comic_book(comic_file_path: Path, directory: Directory = False) -> ComicBook | Path:
try: try:
book = ComicBook.objects.get(file_name=comic_file_path.name, version=0) book = ComicBook.objects.get(file_name=comic_file_path.name, version=0)
book.directory = directory book.directory = directory
@@ -240,7 +241,7 @@ class ComicBook(models.Model):
return comic_file_path return comic_file_path
if archive_type == 'archive': if archive_type == 'archive':
book.page_count = len(book.get_archive_files(archive)) book.page_count = len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
book.page_count = archive.page_count book.page_count = archive.page_count
@@ -252,7 +253,7 @@ class ComicBook(models.Model):
return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name) return Path(settings.COMIC_BOOK_VOLUME, self.directory.get_path(), self.file_name)
return Path(settings.COMIC_BOOK_VOLUME, self.file_name) return Path(settings.COMIC_BOOK_VOLUME, self.file_name)
def get_archive(self) -> Tuple[Union[rarfile.RarFile, zipfile.ZipFile, pymupdf.Document], str]: def get_archive(self) -> tuple[rarfile.RarFile | zipfile.ZipFile | pymupdf.Document, str]:
archive_path = self.get_archive_path archive_path = self.get_archive_path
try: try:
return rarfile.RarFile(archive_path), 'archive' return rarfile.RarFile(archive_path), 'archive'
@@ -273,17 +274,10 @@ class ComicBook(models.Model):
def get_page_count(self) -> int: def get_page_count(self) -> int:
archive, archive_type = self.get_archive() archive, archive_type = self.get_archive()
if archive_type == 'archive': if archive_type == 'archive':
return len(self.get_archive_files(archive)) return len(get_archive_files(archive))
elif archive_type == 'pdf': elif archive_type == 'pdf':
return archive.page_count return archive.page_count
@staticmethod
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[Tuple[str, str]]:
return [
(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0] and not x.endswith('xml')
]
class ComicStatus(models.Model): class ComicStatus(models.Model):
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE) user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
+20 -26
View File
@@ -1,21 +1,27 @@
import mimetypes
import zipfile
from itertools import chain from itertools import chain
from pathlib import Path from pathlib import Path
from typing import NamedTuple, List, Optional, Union
import rarfile
from django.conf import settings from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.db.models import Count, Q, F, Case, When, PositiveSmallIntegerField, QuerySet, ExpressionWrapper, \ from django.db.models import (
IntegerField Case,
Count,
ExpressionWrapper,
F,
IntegerField,
PositiveSmallIntegerField,
Q,
QuerySet,
When,
)
from comic import models from comic import models
from comic.archives import get_archive_files
from comic.errors import NotCompatibleArchive from comic.errors import NotCompatibleArchive
def generate_directory(user: AbstractBaseUser, directory: Optional[models.Directory] = None) \ def generate_directory(user: AbstractBaseUser, directory: models.Directory | None = None) \
-> List[Union[models.Directory, models.ComicBook]]: -> list[models.Directory | models.ComicBook]:
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
files = [] files = []
@@ -63,9 +69,9 @@ def generate_directory(user: AbstractBaseUser, directory: Optional[models.Direct
return files return files
def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional[models.Directory] = None) -> None: def clean_directories(directories: QuerySet, dir_path: Path, directory: models.Directory | None = None) -> None:
dir_db_set = set(Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories) dir_db_set = {Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories}
dir_list = set(x for x in sorted(dir_path.glob('*')) if x.is_dir()) dir_list = {x for x in sorted(dir_path.glob('*')) if x.is_dir()}
# Create new directories db instances # Create new directories db instances
for new_directory in dir_list - dir_db_set: for new_directory in dir_list - dir_db_set:
models.Directory(name=new_directory.name, parent=directory).save() models.Directory(name=new_directory.name, parent=directory).save()
@@ -75,10 +81,10 @@ def clean_directories(directories: QuerySet, dir_path: Path, directory: Optional
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete() models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: Optional[models.Directory] = None) \ def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directory: models.Directory | None = None) \
-> None: -> None:
file_list = set(x for x in sorted(dir_path.glob('*')) if x.is_file()) file_list = {x for x in sorted(dir_path.glob('*')) if x.is_file()}
files_db_set = set(Path(dir_path, x.file_name) for x in files) files_db_set = {Path(dir_path, x.file_name) for x in files}
# Parse new comics # Parse new comics
books_to_add = [] books_to_add = []
@@ -106,15 +112,3 @@ def clean_files(files: QuerySet, user: AbstractBaseUser, dir_path: Path, directo
# Remove stale comic instances # Remove stale comic instances
for stale_comic in files_db_set - file_list: for stale_comic in files_db_set - file_list:
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete() models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
class ArchiveFile(NamedTuple):
file_name: str
mime_type: str
def get_archive_files(archive: Union[zipfile.ZipFile, rarfile.RarFile]) -> List[ArchiveFile]:
return [
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
if not x.endswith('/') and mimetypes.guess_type(x)[0]
]
+95 -77
View File
@@ -1,17 +1,18 @@
import json
from collections.abc import Iterable
from http.client import HTTPResponse from http.client import HTTPResponse
from pathlib import Path from pathlib import Path
from typing import Union, Optional, Dict, Iterable, List
from uuid import UUID from uuid import UUID
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Case, When, F, PositiveSmallIntegerField, FileField, QuerySet from django.db.models import Case, F, FileField, PositiveSmallIntegerField, QuerySet, When
from django.utils.crypto import get_random_string
from django.http import FileResponse from django.http import FileResponse
from django.utils.crypto import get_random_string
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers from rest_framework import mixins, permissions, renderers, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -23,6 +24,17 @@ from comic.processing import generate_directory
from comic.util import generate_breadcrumbs_from_path from comic.util import generate_breadcrumbs_from_path
def user_can_read(user: models.User, directory: models.Directory | None) -> bool:
"""Whether the user's classification allowance covers this book.
Books that are not inside a directory are treated as the highest rating, which
matches how the browse API reports them.
"""
misc, _ = models.UserMisc.objects.get_or_create(user=user)
classification = directory.classification if directory else models.Directory.Classification.C_18
return classification <= misc.allowed_to_read
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read', classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
source='usermisc') source='usermisc')
@@ -61,8 +73,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
""" """
target_user = get_object_or_404(User, id=pk) target_user = get_object_or_404(User, id=pk)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid() and target_user.username == serializer.data['username']:
if target_user.username == serializer.data['username']:
password = get_random_string(12) password = get_random_string(12)
target_user.set_password(password) target_user.set_password(password)
target_user.save() target_user.save()
@@ -89,7 +100,7 @@ class UserViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
class BrowseFileField(serializers.FileField): class BrowseFileField(serializers.FileField):
def to_representation(self, value: Optional[FileField]) -> Optional[str]: def to_representation(self, value: FileField | None) -> str | None:
if not value: if not value:
return None return None
return Path(settings.MEDIA_URL, value.name).as_posix() return Path(settings.MEDIA_URL, value.name).as_posix()
@@ -127,7 +138,7 @@ class BrowseViewSet(viewsets.GenericViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)}) @swagger_auto_schema(responses={status.HTTP_200_OK: BrowseSerializer(many=True)})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
directory = models.Directory.objects.get(selector=selector) directory = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(generate_directory(request.user, directory), many=True) serializer = self.get_serializer(generate_directory(request.user, directory), many=True)
return Response(serializer.data) return Response(serializer.data)
@@ -135,12 +146,8 @@ class BrowseViewSet(viewsets.GenericViewSet):
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer) @action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
def breadcrumbs(self, _request: Request, selector: UUID) -> Response: def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
queryset = [] queryset = []
comic = False comic = models.ComicBook.objects.filter(selector=selector).first()
try: directory = comic.directory if comic else get_object_or_404(models.Directory, selector=selector)
directory = models.Directory.objects.get(selector=selector)
except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector)
directory = comic.directory
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)): for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
queryset.append({ queryset.append({
@@ -165,23 +172,15 @@ class GenerateThumbnailViewSet(viewsets.ViewSet):
@swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: GenerateThumbnailSerializer()})
def retrieve(self, _request: Request, selector: UUID) -> Response: def retrieve(self, _request: Request, selector: UUID) -> Response:
try: try:
directory = models.Directory.objects.get(selector=selector) target = models.Directory.objects.get(selector=selector)
if not directory.thumbnail:
directory.generate_thumbnail()
return Response(
self.serializer_class({
"selector": directory.selector,
"thumbnail": directory.thumbnail
}).data
)
except models.Directory.DoesNotExist: except models.Directory.DoesNotExist:
comic = models.ComicBook.objects.get(selector=selector) target = get_object_or_404(models.ComicBook, selector=selector)
if not comic.thumbnail: if not target.thumbnail:
comic.generate_thumbnail() target.generate_thumbnail()
return Response( return Response(
self.serializer_class({ self.serializer_class({
"selector": comic.selector, "selector": target.selector,
"thumbnail": comic.thumbnail "thumbnail": target.thumbnail
}).data }).data
) )
@@ -213,9 +212,12 @@ class ReadViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
lookup_field = 'selector' lookup_field = 'selector'
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer(),
status.HTTP_403_FORBIDDEN: 'User below classification allowed'})
def retrieve(self, request: Request, selector: UUID) -> Response: def retrieve(self, request: Request, selector: UUID) -> Response:
comic = get_object_or_404(models.ComicBook, selector=selector) comic = get_object_or_404(models.ComicBook, selector=selector)
if not user_can_read(request.user, comic.directory):
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
_, _ = models.UserMisc.objects.get_or_create(user=request.user) _, _ = models.UserMisc.objects.get_or_create(user=request.user)
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name')) comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
@@ -247,17 +249,17 @@ class ReadViewSet(viewsets.GenericViewSet):
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data', @swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'}) status.HTTP_403_FORBIDDEN: 'User below classification allowed',
status.HTTP_404_NOT_FOUND: 'Comic file not found'})
@action(methods=['get'], detail=True) @action(methods=['get'], detail=True)
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response, HTTPResponse]: def pdf(self, request: Request, selector: UUID) -> FileResponse | Response | HTTPResponse:
book = models.ComicBook.objects.get(selector=selector) book = get_object_or_404(models.ComicBook, selector=selector)
misc, _ = models.UserMisc.objects.get_or_create(user=request.user) if not user_can_read(request.user, book.directory):
try: return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
if book.directory.classification > misc.allowed_to_read: pdf_path = book.get_pdf()
return Response(status=400, data={'errors': 'Not allowed to read.'}) if not pdf_path.is_file():
except AttributeError: return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Comic file not found.'})
pass return FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()}) @swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
@action(methods=['get'], detail=True) @action(methods=['get'], detail=True)
@@ -272,15 +274,11 @@ class ReadViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
comic_status, _ = models.ComicStatus.objects.annotate(page_count=F('comic__page_count')) \ comic = get_object_or_404(models.ComicBook, selector=selector)
.get_or_create(comic_id=selector, user=request.user) comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
comic_status.last_read_page = serializer.data['page'] comic_status.last_read_page = serializer.data['page']
comic_status.unread = False comic_status.unread = False
if comic_status.page_count - 1 == comic_status.last_read_page: comic_status.finished = comic.page_count > 0 and comic_status.last_read_page >= comic.page_count - 1
comic_status.finished = True
else:
comic_status.finished = False
comic_status.save() comic_status.save()
return Response(self.get_serializer(comic_status).data) return Response(self.get_serializer(comic_status).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -293,20 +291,30 @@ class PassthroughRenderer(renderers.BaseRenderer): # pylint: disable=too-few-pu
media_type = '*/*' media_type = '*/*'
format = '' format = ''
def render(self, data: bytes, accepted_media_type: Optional[str] = None, renderer_context: Optional[str] = None) \ def render(self, data: bytes, accepted_media_type: str | None = None, renderer_context: str | None = None) \
-> bytes: -> bytes:
if isinstance(data, (bytes, bytearray)):
return data return data
# Error responses still need to be serialised.
return json.dumps(data).encode()
class ImageViewSet(viewsets.ViewSet): class ImageViewSet(viewsets.ViewSet):
queryset = models.ComicBook.objects.all() queryset = models.ComicBook.objects.all()
lookup_field = 'page' lookup_field = 'page'
renderer_classes = [PassthroughRenderer] renderer_classes = [PassthroughRenderer, renderers.JSONRenderer]
permission_classes = [permissions.IsAuthenticated]
@swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response"}) @swagger_auto_schema(responses={status.HTTP_200_OK: "A Binary Image response",
def retrieve(self, _request: Request, selector: UUID, page: int) -> FileResponse: status.HTTP_403_FORBIDDEN: 'User below classification allowed',
book = models.ComicBook.objects.get(selector=selector) status.HTTP_404_NOT_FOUND: 'Comic or page not found'})
def retrieve(self, _request: Request, selector: UUID, page: int) -> Response:
book = get_object_or_404(models.ComicBook, selector=selector)
if not user_can_read(_request.user, book.directory):
return Response(status=status.HTTP_403_FORBIDDEN, data={'errors': 'Not allowed to read.'})
img, content = book.get_image(int(page) - 1) img, content = book.get_image(int(page) - 1)
if not img:
return Response(status=status.HTTP_404_NOT_FOUND, data={'errors': 'Page could not be read.'})
self.renderer_classes[0].media_type = content self.renderer_classes[0].media_type = content
return FileResponse(img, content_type=content) return FileResponse(img, content_type=content)
@@ -434,7 +442,7 @@ class ActionViewSet(viewsets.GenericViewSet):
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user) obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
obj.unread = False obj.unread = False
obj.finished = True obj.finished = True
obj.last_read_page = comic.total_pages obj.last_read_page = max(comic.page_count - 1, 0)
status_to_update.append(obj) status_to_update.append(obj)
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page']) models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
return Response({'status': 'marked_read'}) return Response({'status': 'marked_read'})
@@ -466,7 +474,7 @@ class ActionViewSet(viewsets.GenericViewSet):
return Response(serializer.errors, return Response(serializer.errors,
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
def get_comics(self, selectors: Iterable[Union[str, UUID]]) -> List[str]: def get_comics(self, selectors: Iterable[str | UUID]) -> list[str]:
data = set() data = set()
data = data.union( data = data.union(
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True))) set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
@@ -508,10 +516,10 @@ class PasswordResetSerializer(serializers.Serializer):
try: try:
validate_password(data) validate_password(data)
except ValidationError as err: except ValidationError as err:
raise serializers.ValidationError(err) raise serializers.ValidationError(err) from err
return data return data
def validate(self, attrs: Dict[str, str]) -> Dict[str, str]: def validate(self, attrs: dict[str, str]) -> dict[str, str]:
super().validate(attrs) super().validate(attrs)
if attrs['new_password'] != attrs['new_password_confirm']: if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError('New passwords do not match') raise serializers.ValidationError('New passwords do not match')
@@ -582,37 +590,47 @@ class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
lookup_field = 'selector' lookup_field = 'selector'
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)}) @staticmethod
def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ def _descendants(directory: models.Directory) -> list[models.Directory]:
""" """The directory itself plus every directory below it."""
This will set the classification of a directory and all it's children. to_update = [directory]
""" to_visit = [directory]
main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
main_parent.classification = serializer.data['classification']
to_update = {main_parent}
to_visit = {main_parent}
while to_visit: while to_visit:
parent = to_visit.pop() parent = to_visit.pop()
for child in models.Directory.objects.filter(parent=parent): for child in models.Directory.objects.filter(parent=parent):
child.classification = serializer.data['classification'] to_visit.append(child)
to_visit.add(child) to_update.append(child)
to_update.add(child) return to_update
models.Directory.objects.bulk_update(to_update, fields=['classification'])
data = models.Directory.objects.filter(directory__in=to_update) @swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
response = self.get_serializer(data, many=True) def update(self, request: Request, selector: UUID) -> Response: # pylint: disable=arguments-differ
return Response(response.data) """
This will set the classification of a directory and all of its children.
"""
main_parent = get_object_or_404(models.Directory, selector=selector)
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
to_update = self._descendants(main_parent)
for directory in to_update:
directory.classification = serializer.data['classification']
models.Directory.objects.bulk_update(to_update, fields=['classification'])
return Response(self.get_serializer(to_update, many=True).data)
@swagger_auto_schema(responses={200: DirectorySerializer()})
def partial_update(self, request: Request, *args, **kwargs) -> Response: def partial_update(self, request: Request, *args, **kwargs) -> Response:
""" """
This will set the classification of a directory and none of its children. This will set the classification of a directory and none of its children.
""" """
return super().update(request, *args, **kwargs) directory = self.get_object()
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
directory.classification = serializer.data['classification']
directory.save()
return Response(self.get_serializer(directory).data)
class InitialSetupSerializer(serializers.Serializer): class InitialSetupSerializer(serializers.Serializer):
+5 -6
View File
@@ -1,5 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from comic.models import ComicBook, Directory from comic.models import ComicBook, Directory
@@ -10,12 +9,12 @@ class Breadcrumb:
selector: str = '' selector: str = ''
def generate_breadcrumbs_from_path(directory: Optional[Directory] = None, book: Optional[ComicBook] = None): def generate_breadcrumbs_from_path(
directory: Directory | None = None,
book: ComicBook | None = None,
) -> list[Breadcrumb]:
output = [Breadcrumb()] output = [Breadcrumb()]
if directory: folders = directory.get_path_objects() if directory else []
folders = directory.get_path_objects()
else:
folders = []
for item in folders[::-1]: for item in folders[::-1]:
output.append( output.append(
Breadcrumb( Breadcrumb(
+19 -2
View File
@@ -39,13 +39,30 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"coverage>=7.13.5", "coverage>=7.13.5",
"flake8>=7.3.0",
"flake8-annotations>=3.2.0",
"ipython>=9.13.0", "ipython>=9.13.0",
"mypy>=2", "mypy>=2",
"pre-commit>=4.6", "pre-commit>=4.6",
"pylint>=4.0.5", "pylint>=4.0.5",
"pylint-django>=2.7.0", "pylint-django>=2.7.0",
"pyopenssl>=26.2.0", "pyopenssl>=26.2.0",
"ruff>=0.17.0",
"werkzeug>=3.1.8", "werkzeug>=3.1.8",
] ]
[tool.ruff]
target-version = "py314"
line-length = 120
extend-exclude = [
"*/migrations/*",
"cbreader/settings",
"frontend",
]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "ANN", "B", "C4", "SIM", "UP"]
# These were ignored by the previous flake8-annotations setup (ANN001 is the
# modern name for the old ANN101).
ignore = ["ANN001", "ANN002", "ANN003"]
[tool.ruff.lint.isort]
combine-as-imports = true
-26
View File
@@ -1,26 +0,0 @@
[tool:pytest]
addopts = --flake8
[flake8]
max-line-length = 120
ignore =
* ANN101
* ANN002
* ANN003
# Ignore rules which contradicts black's formatting choices:
; * E501
; * W503
; * W504
; * E266
exclude =
# Exclude these files
*/migrations/*
cbreader/settings
frontend
[tool:isort]
line_length = 119
indent = ' '
multi_line_output = 3
include_trailing_comma = 1
Generated
+27 -48
View File
@@ -129,14 +129,13 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "coverage" }, { name = "coverage" },
{ name = "flake8" },
{ name = "flake8-annotations" },
{ name = "ipython" }, { name = "ipython" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pylint" }, { name = "pylint" },
{ name = "pylint-django" }, { name = "pylint-django" },
{ name = "pyopenssl" }, { name = "pyopenssl" },
{ name = "ruff" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
@@ -173,14 +172,13 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "coverage", specifier = ">=7.13.5" }, { name = "coverage", specifier = ">=7.13.5" },
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "flake8-annotations", specifier = ">=3.2.0" },
{ name = "ipython", specifier = ">=9.13.0" }, { name = "ipython", specifier = ">=9.13.0" },
{ name = "mypy", specifier = ">=2" }, { name = "mypy", specifier = ">=2" },
{ name = "pre-commit", specifier = ">=4.6" }, { name = "pre-commit", specifier = ">=4.6" },
{ name = "pylint", specifier = ">=4.0.5" }, { name = "pylint", specifier = ">=4.0.5" },
{ name = "pylint-django", specifier = ">=2.7.0" }, { name = "pylint-django", specifier = ">=2.7.0" },
{ name = "pyopenssl", specifier = ">=26.2.0" }, { name = "pyopenssl", specifier = ">=26.2.0" },
{ name = "ruff", specifier = ">=0.17.0" },
{ name = "werkzeug", specifier = ">=3.1.8" }, { name = "werkzeug", specifier = ">=3.1.8" },
] ]
@@ -607,32 +605,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
] ]
[[package]]
name = "flake8"
version = "7.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mccabe" },
{ name = "pycodestyle" },
{ name = "pyflakes" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
]
[[package]]
name = "flake8-annotations"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flake8" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/ea/f36a8e013154f214715de01d46d98f12a673c7b978533079ac5caf4cbcce/flake8_annotations-3.2.0.tar.gz", hash = "sha256:71b52ec85a4ec82a10edfad497fe2b390609c95b2ad7d61cc94704bc88a498a0", size = 14169, upload-time = "2025-10-09T21:17:57.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/49/ae687154c2cfe623e273143000b81dbd3901d29e13ef30155f1652c18e7c/flake8_annotations-3.2.0-py3-none-any.whl", hash = "sha256:2b0203cac0eb33c43463f1a986c3cc70a448d9d593da75567173f46f64e9213c", size = 16873, upload-time = "2025-10-09T21:17:56.265Z" },
]
[[package]] [[package]]
name = "gprof2dot" name = "gprof2dot"
version = "2025.4.14" version = "2025.4.14"
@@ -1130,15 +1102,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
] ]
[[package]]
name = "pycodestyle"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.23" version = "2.23"
@@ -1148,15 +1111,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
] ]
[[package]]
name = "pyflakes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -1379,6 +1333,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" },
] ]
[[package]]
name = "ruff"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
{ url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
{ url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
{ url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
{ url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
{ url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
{ url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "84.0.0" version = "84.0.0"