**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 12:07:47 +01:00
1 parent 1148f4072b
commit a7e9bd0743
16 files changed
+553 -4

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
"""Shared fixtures for the test suite."""
from pathlib import Path
import pytest
from comic import models
from comic.tests.factories import make_cbz
@pytest.fixture()
def comic_volume(settings, tmp_path) -> Path:
"""Point COMIC_BOOK_VOLUME at an empty temporary directory."""
settings.COMIC_BOOK_VOLUME = tmp_path
return tmp_path
@pytest.fixture()
def admin_user(db) -> models.User:
return models.User.objects.create_superuser('admin', 'admin@example.com', 'password12345')
@pytest.fixture()
def reader_user(db) -> models.User:
"""A normal user, whose reading allowance defaults to the highest rating."""
return models.User.objects.create_user('reader', 'reader@example.com', 'password12345')
@pytest.fixture()
def restricted_user(db) -> models.User:
"""A user whose allowance is G, so anything rated above G is blocked."""
user = models.User.objects.create_user('restricted', 'restricted@example.com', 'password12345')
models.UserMisc.objects.create(user=user, allowed_to_read=models.Directory.Classification.C_G)
return user
@pytest.fixture()
def rated_directory(db) -> models.Directory:
return models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
@pytest.fixture()
def book(db, comic_volume: Path, rated_directory: models.Directory) -> models.ComicBook:
"""A book with a three page archive inside an 18 rated directory."""
directory_path = comic_volume / rated_directory.name
directory_path.mkdir(parents=True, exist_ok=True)
make_cbz(directory_path / 'issue.cbz', pages=3)
return models.ComicBook.objects.create(file_name='issue.cbz', directory=rated_directory, page_count=3)
+31
View File
@@ -0,0 +1,31 @@
"""Helpers for creating comic files on disk."""
import io
import zipfile
from pathlib import Path
import pymupdf
from PIL import Image
def make_cbz(path: Path, pages: int = 3, metadata: bool = True) -> Path:
"""Write a .cbz containing PNG pages and, optionally, a ComicInfo.xml."""
with zipfile.ZipFile(path, 'w') as archive:
for page in range(pages):
image = Image.new('RGB', (10, 10))
buffer = io.BytesIO()
image.save(buffer, format='PNG')
archive.writestr(f'page{page:02}.png', buffer.getvalue())
if metadata:
archive.writestr('ComicInfo.xml', '<ComicInfo><Title>Test</Title></ComicInfo>')
return path
def make_pdf(path: Path, pages: int = 2) -> Path:
"""Write a PDF with the given number of pages."""
document = pymupdf.open()
for _ in range(pages):
document.new_page(width=10, height=10)
document.save(path)
document.close()
return path
+150
View File
@@ -0,0 +1,150 @@
"""Tests for the API behaviour of the comic viewsets."""
import pytest
from comic import models
UNKNOWN = '00000000-0000-0000-0000-000000000000'
@pytest.mark.django_db
def test_unknown_directory_returns_404(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/browse/{UNKNOWN}/').status_code == 404
@pytest.mark.django_db
def test_unknown_selector_returns_404_in_breadcrumbs(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/browse/{UNKNOWN}/breadcrumbs/').status_code == 404
@pytest.mark.django_db
def test_unknown_book_returns_404_for_an_image(client, reader_user) -> None:
client.force_login(reader_user)
assert client.get(f'/api/read/{UNKNOWN}/image/1/').status_code == 404
@pytest.mark.django_db
def test_image_requires_authentication(client, book) -> None:
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 401
@pytest.mark.django_db
def test_image_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
client.force_login(restricted_user)
assert client.get(f'/api/read/{book.selector}/image/1/').status_code == 403
@pytest.mark.django_db
def test_image_returns_the_page_for_an_allowed_user(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.get(f'/api/read/{book.selector}/image/1/')
assert response.status_code == 200
assert response['Content-Type'] == 'image/png'
assert b''.join(response.streaming_content)
@pytest.mark.django_db
def test_read_view_is_blocked_for_a_user_below_the_rating(client, book, restricted_user) -> None:
client.force_login(restricted_user)
assert client.get(f'/api/read/{book.selector}/').status_code == 403
@pytest.mark.django_db
def test_read_view_returns_progress_for_an_allowed_user(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.get(f'/api/read/{book.selector}/')
assert response.status_code == 200
assert response.json()['pages'] == 3
assert response.json()['title'] == 'issue.cbz'
@pytest.mark.django_db
def test_set_page_creates_the_status_and_marks_the_book_finished(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
f'/api/read/{book.selector}/set_page/',
data={'page': book.page_count - 1},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert status.last_read_page == 2
assert status.finished
assert not status.unread
@pytest.mark.django_db
def test_set_page_leaves_the_book_unfinished_before_the_last_page(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
f'/api/read/{book.selector}/set_page/',
data={'page': 1},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert not status.finished
@pytest.mark.django_db
def test_mark_read_records_the_last_page(client, book, reader_user) -> None:
client.force_login(reader_user)
response = client.put(
'/api/action/mark_read/',
data={'selectors': [str(book.selector)]},
content_type='application/json',
)
assert response.status_code == 200
status = models.ComicStatus.objects.get(user=reader_user, comic=book)
assert status.last_read_page == book.page_count - 1
assert status.finished
@pytest.mark.django_db
def test_directory_update_cascades_to_children(client, admin_user) -> None:
client.force_login(admin_user)
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
child = models.Directory.objects.create(name='Child', parent=parent)
response = client.put(
f'/api/directory/{parent.selector}/',
data={'classification': models.Directory.Classification.C_PG},
content_type='application/json',
)
assert response.status_code == 200
assert sorted(item['classification'] for item in response.json()) == [1, 1]
child.refresh_from_db()
assert child.classification == models.Directory.Classification.C_PG
@pytest.mark.django_db
def test_directory_partial_update_does_not_cascade(client, admin_user) -> None:
client.force_login(admin_user)
parent = models.Directory.objects.create(name='Parent', classification=models.Directory.Classification.C_18)
child = models.Directory.objects.create(name='Child', parent=parent)
response = client.patch(
f'/api/directory/{child.selector}/',
data={'classification': models.Directory.Classification.C_15},
content_type='application/json',
)
assert response.status_code == 200
assert response.json() == {'selector': str(child.selector), 'classification': 3}
parent.refresh_from_db()
assert parent.classification == models.Directory.Classification.C_18
@pytest.mark.django_db
def test_directory_partial_update_rejects_an_unknown_classification(client, admin_user) -> None:
client.force_login(admin_user)
directory = models.Directory.objects.create(name='Parent')
response = client.patch(
f'/api/directory/{directory.selector}/',
data={'classification': 9},
content_type='application/json',
)
assert response.status_code == 400
+42
View File
@@ -0,0 +1,42 @@
"""Tests for the shared archive page listing in comic.archives."""
import zipfile
from pathlib import Path
from comic.archives import get_archive_files
from comic.tests.factories import make_cbz
def names(archive: zipfile.ZipFile) -> list[str]:
return [entry.file_name for entry in get_archive_files(archive)]
def test_metadata_entries_are_not_pages(tmp_path: Path) -> None:
path = make_cbz(tmp_path / 'issue.cbz', pages=2, metadata=True)
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['page00.png', 'page01.png']
def test_entries_are_returned_in_page_order(tmp_path: Path) -> None:
path = tmp_path / 'issue.cbz'
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('page10.png', b'x')
archive.writestr('page02.png', b'x')
archive.writestr('page01.png', b'x')
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['page01.png', 'page02.png', 'page10.png']
def test_directory_entries_are_ignored(tmp_path: Path) -> None:
path = tmp_path / 'issue.cbz'
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('cover.png', b'x')
archive.writestr('images/', b'')
with zipfile.ZipFile(path) as archive:
assert names(archive) == ['cover.png']
def test_mime_type_is_guessed_from_the_file_name(tmp_path: Path) -> None:
path = make_cbz(tmp_path / 'issue.cbz', pages=1, metadata=False)
with zipfile.ZipFile(path) as archive:
assert get_archive_files(archive)[0].mime_type == 'image/png'
+71
View File
@@ -0,0 +1,71 @@
"""Tests for the comic models."""
from pathlib import Path
import pytest
from comic import models
from comic.tests.factories import make_cbz, make_pdf
@pytest.mark.django_db
def test_page_count_excludes_metadata(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=3, metadata=True)
book = models.ComicBook.objects.create(file_name='issue.cbz')
assert book.get_page_count() == 3
@pytest.mark.django_db
def test_pdf_page_count(comic_volume: Path) -> None:
make_pdf(comic_volume / 'issue.pdf', pages=2)
book = models.ComicBook.objects.create(file_name='issue.pdf')
assert book.get_page_count() == 2
@pytest.mark.django_db
def test_unreadable_archive_is_indexed_without_pages(comic_volume: Path) -> None:
path = comic_volume / 'broken.cbz'
path.write_bytes(b'this is not an archive')
result = models.ComicBook.process_comic_book(path)
assert isinstance(result, Path)
assert models.ComicBook.objects.get(file_name='broken.cbz').page_count == 0
@pytest.mark.django_db
def test_archive_path_uses_the_directory_tree(comic_volume: Path) -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
assert book.get_archive_path == comic_volume / 'Series' / 'Volume 1' / 'issue.cbz'
@pytest.mark.django_db
def test_book_without_a_directory_uses_the_volume_root(comic_volume: Path) -> None:
book = models.ComicBook.objects.create(file_name='issue.cbz')
assert book.get_archive_path == comic_volume / 'issue.cbz'
@pytest.mark.django_db
def test_directory_path_is_built_from_its_parents() -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
assert child.get_path() == Path('Series') / 'Volume 1'
@pytest.mark.django_db
def test_get_image_returns_the_requested_page(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=2, metadata=False)
book = models.ComicBook.objects.create(file_name='issue.cbz')
stream, mime_type = book.get_image(1)
assert mime_type == 'image/png'
assert stream.read()
@pytest.mark.django_db
def test_classification_comparison() -> None:
directory = models.Directory.objects.create(name='Rated', classification=models.Directory.Classification.C_18)
misc = models.UserMisc.objects.create(
user=models.User.objects.create_user('pg', 'pg@example.com', 'password12345'),
allowed_to_read=models.Directory.Classification.C_PG,
)
assert directory.classification > misc.allowed_to_read
+48
View File
@@ -0,0 +1,48 @@
"""Tests for the filesystem reconciliation in comic.processing."""
import pytest
from comic import models
from comic.processing import clean_directories, clean_files, generate_directory
from comic.tests.factories import make_cbz
@pytest.mark.django_db
def test_clean_directories_adds_new_and_removes_stale(comic_volume, reader_user) -> None:
parent = models.Directory.objects.create(name='Series')
models.Directory.objects.create(name='Gone', parent=parent)
(comic_volume / 'Series' / 'New').mkdir(parents=True)
clean_directories(models.Directory.objects.filter(parent=parent), comic_volume / 'Series', parent)
names = set(models.Directory.objects.filter(parent=parent).values_list('name', flat=True))
assert names == {'New'}
@pytest.mark.django_db
def test_clean_files_ignores_unsupported_extensions(comic_volume, reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=2)
(target / 'notes.txt').write_text('not a comic')
clean_files(models.ComicBook.objects.filter(directory=directory), reader_user, target, directory)
books = list(models.ComicBook.objects.filter(directory=directory))
assert [book.file_name for book in books] == ['issue.cbz']
assert books[0].page_count == 2
assert models.ComicStatus.objects.filter(user=reader_user).count() == 1
@pytest.mark.django_db
def test_generate_directory_creates_missing_statuses(comic_volume, reader_user) -> None:
directory = models.Directory.objects.create(name='Series')
target = comic_volume / 'Series'
target.mkdir(parents=True)
make_cbz(target / 'issue.cbz', pages=1)
entries = generate_directory(reader_user, directory)
assert [entry.title for entry in entries] == ['issue.cbz']
assert models.ComicStatus.objects.filter(user=reader_user, comic=entries[0]).exists()
+31
View File
@@ -0,0 +1,31 @@
"""Tests for the scan_comics management command."""
from pathlib import Path
import pytest
from django.core.management import call_command
from comic import models
from comic.tests.factories import make_cbz
@pytest.mark.django_db
def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> None:
nested = comic_volume / 'Series' / 'Volume 1'
nested.mkdir(parents=True)
make_cbz(nested / 'issue.cbz', pages=2)
call_command('scan_comics')
directory = models.Directory.objects.get(name='Volume 1', parent__name='Series')
book = models.ComicBook.objects.get(file_name='issue.cbz', directory=directory)
assert book.page_count == 2
@pytest.mark.django_db
def test_scan_without_a_user_stops_before_reading_the_volume(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics')
assert models.ComicBook.objects.count() == 0
+14
View File
@@ -0,0 +1,14 @@
"""Tests for URL resolution that depends on settings."""
import pytest
from django.test import Client
@pytest.mark.parametrize('url', ['/swagger.json', '/swagger.yaml', '/swagger/', '/redoc/'])
def test_schema_urls_resolve(client: Client, url: str) -> None:
"""The schema URLs must match the renderer formats, which no longer include a dot.
cbreader/urls.py captures the format after the dot because
SWAGGER_USE_COMPAT_RENDERERS is False.
"""
assert client.get(url).status_code == 200
+22
View File
@@ -0,0 +1,22 @@
"""Tests for breadcrumb generation."""
import pytest
from comic import models
from comic.util import generate_breadcrumbs_from_path
@pytest.mark.django_db
def test_breadcrumbs_run_from_home_to_the_book() -> None:
parent = models.Directory.objects.create(name='Series')
child = models.Directory.objects.create(name='Volume 1', parent=parent)
book = models.ComicBook.objects.create(file_name='issue.cbz', directory=child)
assert [crumb.name for crumb in generate_breadcrumbs_from_path(child, book)] == [
'Home', 'Series', 'Volume 1', 'issue.cbz',
]
@pytest.mark.django_db
def test_breadcrumbs_without_a_book_stop_at_the_directory() -> None:
directory = models.Directory.objects.create(name='Series')
assert [crumb.name for crumb in generate_breadcrumbs_from_path(directory)] == ['Home', 'Series']