More CI fixes and mypy fixes
Some checks failed
Lint / lint (push) Successful in 20s
Tests / test (push) Successful in 10s
Python CI / test (push) Failing after 1m43s

This commit is contained in:
Duncan Tourolle 2026-04-09 23:07:59 +02:00
parent fd0758dd6c
commit 1fe44e7d8a
23 changed files with 154 additions and 99 deletions

View File

@ -14,24 +14,13 @@ jobs:
runs-on: linux/amd64 runs-on: linux/amd64
container: container:
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14']
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Install project
uses: actions/setup-python@v4 run: pip3 install -e . --no-deps --break-system-packages
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install setuptools coverage-badge interrogate
- name: Download initial failed badges - name: Download initial failed badges
run: | run: |
@ -71,7 +60,7 @@ jobs:
- name: Generate coverage reports - name: Generate coverage reports
if: steps.pytest.outcome == 'success' if: steps.pytest.outcome == 'success'
run: | run: |
python -c " python3 -c "
import json import json
import os import os
if os.path.exists('coverage.json'): if os.path.exists('coverage.json'):
@ -100,15 +89,8 @@ jobs:
name: coverage-reports name: coverage-reports
path: cov_info/ path: cov_info/
- name: Upload coverage reports to Codecov
if: matrix.python-version == '3.11'
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Commit badges to badges branch - name: Commit badges to badges branch
if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11' if: github.ref == 'refs/heads/master'
run: | run: |
git config --local user.email "action@gitea.local" git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action" git config --local user.name "Gitea Action"

View File

@ -6,9 +6,9 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
--no-install-recommends \
# Python # Python
python3 \ python3 \
python3-venv \
python3-pip \ python3-pip \
# PyQt6 / OpenGL runtime deps # PyQt6 / OpenGL runtime deps
libgl1 \ libgl1 \
@ -42,3 +42,24 @@ RUN apt-get update && apt-get install -y \
git \ git \
nodejs \ nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install all Python dependencies so CI runs don't need to download anything
RUN pip3 install --break-system-packages --no-cache-dir \
# Runtime deps
PyQt6 \
PyOpenGL \
numpy \
Pillow \
reportlab \
lxml \
pypdf \
# Dev/test deps
pytest \
pytest-qt \
pytest-cov \
pytest-mock \
pdfplumber \
flake8 \
coverage-badge \
interrogate \
setuptools

View File

@ -345,7 +345,7 @@ class AssetManager:
Returns: Returns:
List of relative paths to unused assets List of relative paths to unused assets
""" """
unused = [] unused: list[str] = []
if not os.path.exists(self.assets_folder): if not os.path.exists(self.assets_folder):
return unused return unused

View File

@ -405,7 +405,7 @@ class AsyncImageLoader(QObject):
Returns: Returns:
Processed PIL Image Processed PIL Image
""" """
img = Image.open(path) img: Image.Image = Image.open(path)
img = convert_to_rgba(img) img = convert_to_rgba(img)
# Downsample if target size specified (preserving aspect ratio) # Downsample if target size specified (preserving aspect ratio)
@ -485,6 +485,7 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request self._pending_requests[path] = request
# Submit to queue (thread-safe) # Submit to queue (thread-safe)
assert self._queue is not None
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop) asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
logger.debug(f"Queued load: {path} (priority: {priority.name})") logger.debug(f"Queued load: {path} (priority: {priority.name})")
@ -768,8 +769,8 @@ class AsyncPDFGenerator(QObject):
# Temporarily patch Image.open # Temporarily patch Image.open
try: try:
Image.open = cached_open Image.open = cached_open # type: ignore[assignment]
return exporter.export(output_path, progress_callback) return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
finally: finally:
# Restore original # Restore original
Image.open = original_open Image.open = original_open

View File

@ -42,6 +42,7 @@ def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
""" """
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -759,6 +760,8 @@ class CommandHistory:
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]: def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
"""Deserialize a single command using registry pattern""" """Deserialize a single command using registry pattern"""
cmd_type = data.get("type") cmd_type = data.get("type")
if cmd_type is None:
return None
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type) deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
if not deserializer: if not deserializer:

View File

@ -310,7 +310,7 @@ class FrameManager:
# Load as PIL Image from bytes buffer # Load as PIL Image from bytes buffer
buffer = io.BytesIO(png_data) buffer = io.BytesIO(png_data)
img = Image.open(buffer) img: Image.Image = Image.open(buffer)
if img.mode != "RGBA": if img.mode != "RGBA":
img = img.convert("RGBA") img = img.convert("RGBA")
@ -836,11 +836,11 @@ class FrameManager:
# Flip horizontally if h position differs # Flip horizontally if h position differs
if asset_h != target_h: if asset_h != target_h:
img = img.transpose(Image.FLIP_LEFT_RIGHT) img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
# Flip vertically if v position differs # Flip vertically if v position differs
if asset_v != target_v: if asset_v != target_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM) img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img return img

View File

@ -4,7 +4,7 @@ Async loading mixin for non-blocking image loading and PDF generation.
import os import os
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional, cast
import logging import logging
from PyQt6.QtCore import QObject from PyQt6.QtCore import QObject
@ -22,11 +22,11 @@ class AsyncLoadingMixin:
# Type hints for expected attributes from mixing class # Type hints for expected attributes from mixing class
_pdf_progress_dialog: Optional[QProgressDialog] _pdf_progress_dialog: Optional[QProgressDialog]
def update(self) -> None: def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...
@ -224,7 +224,9 @@ class AsyncLoadingMixin:
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages) total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self) from PyQt6.QtWidgets import QWidget
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export") self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel) self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)

View File

@ -15,7 +15,7 @@ class ElementManipulationMixin:
drag_start_pos: Optional[Tuple[float, float]] drag_start_pos: Optional[Tuple[float, float]]
drag_start_element_pos: Optional[Tuple[float, float]] drag_start_element_pos: Optional[Tuple[float, float]]
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...
@ -62,12 +62,12 @@ class ElementManipulationMixin:
self._resize_element_no_snap(dx, dy) self._resize_element_no_snap(dx, dy)
return return
parent_page = self.selected_element._parent_page parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
snap_sys = parent_page.layout.snapping_system snap_sys = parent_page.layout.snapping_system
# Get page size # Get page size
page_size = parent_page.layout.size page_size = parent_page.layout.size
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi # type: ignore[attr-defined]
# Apply snapping to resize # Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams from pyPhotoAlbum.snapping import SnapResizeParams
@ -80,7 +80,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle, resize_handle=self.resize_handle,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project, project=main_window.project, # type: ignore[attr-defined]
) )
new_pos, new_size = snap_sys.snap_resize(params) new_pos, new_size = snap_sys.snap_resize(params)
@ -106,6 +106,8 @@ class ElementManipulationMixin:
""" """
if not self.resize_start_pos or not self.resize_start_size: if not self.resize_start_pos or not self.resize_start_size:
return return
if self.selected_element is None:
return
start_x, start_y = self.resize_start_pos start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size start_w, start_h = self.resize_start_size

View File

@ -14,7 +14,7 @@ class ElementSelectionMixin:
# Type hints for expected attributes from mixing class # Type hints for expected attributes from mixing class
_page_renderers: list _page_renderers: list
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...
@ -86,7 +86,7 @@ class ElementSelectionMixin:
# Store the renderer with the element for later use # Store the renderer with the element for later use
element._page_renderer = renderer # type: ignore[attr-defined] element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page # type: ignore[attr-defined] element._parent_page = page # type: ignore[attr-defined]
return element return element # type: ignore[no-any-return]
return None return None

View File

@ -123,7 +123,7 @@ class InteractionState:
Returns: Returns:
Dict with non-None state values Dict with non-None state values
""" """
state = {} state: Dict[str, Any] = {}
if self.position is not None: if self.position is not None:
state["position"] = self.position state["position"] = self.position
if self.size is not None: if self.size is not None:

View File

@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
""" """
import math import math
from typing import TYPE_CHECKING, Any, Optional, Set
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData from PyQt6.QtGui import QCursor
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class MouseInteractionMixin: class MouseInteractionMixin:
@ -16,6 +21,39 @@ class MouseInteractionMixin:
the current interaction state. the current interaction state.
""" """
# Type declarations for attributes provided by other mixins/base classes
selected_elements: Set[BaseLayoutElement]
selected_element: Optional[BaseLayoutElement]
rotation_mode: bool
rotation_snap_angle: int
rotation_start_angle: Optional[float]
pan_offset: list
zoom_level: float
image_pan_mode: bool
current_page_index: int
_page_renderers: list
resize_start_pos: Optional[Any]
resize_start_size: Optional[Any]
if TYPE_CHECKING:
def window(self) -> "QMainWindow": ...
def update(self) -> None: ...
def setCursor(self, cursor: Any) -> None: ...
def setFocus(self, *args: Any) -> None: ...
def _begin_rotate(self, element: Any) -> None: ...
def _begin_resize(self, element: Any) -> None: ...
def _begin_image_pan(self, element: Any) -> None: ...
def _begin_move(self, element: Any) -> None: ...
def _end_interaction(self) -> None: ...
def _resize_element(self, dx: float, dy: float) -> None: ...
def _get_page_at(self, x: float, y: float) -> Any: ...
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
def _update_page_status(self, x: float, y: float) -> None: ...
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -27,6 +65,7 @@ class MouseInteractionMixin:
def _handle_rotation_start(self, x: float, y: float): def _handle_rotation_start(self, x: float, y: float):
"""Start rotation interaction for selected element.""" """Start rotation interaction for selected element."""
assert self.selected_element is not None
self._begin_rotate(self.selected_element) self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation self.rotation_start_angle = self.selected_element.rotation
@ -34,6 +73,7 @@ class MouseInteractionMixin:
def _handle_resize_start(self, x: float, y: float, handle): def _handle_resize_start(self, x: float, y: float, handle):
"""Start resize interaction for selected element.""" """Start resize interaction for selected element."""
assert self.selected_element is not None
self._begin_resize(self.selected_element) self._begin_resize(self.selected_element)
self.resize_handle = handle self.resize_handle = handle
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
@ -139,10 +179,10 @@ class MouseInteractionMixin:
def _handle_rotation_move(self, x: float, y: float): def _handle_rotation_move(self, x: float, y: float):
"""Handle element rotation during drag.""" """Handle element rotation during drag."""
if not hasattr(self.selected_element, "_page_renderer"): if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
return return
renderer = self.selected_element._page_renderer renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
elem_x, elem_y = self.selected_element.position elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size elem_w, elem_h = self.selected_element.size
@ -174,10 +214,11 @@ class MouseInteractionMixin:
def _handle_element_move(self, x: float, y: float): def _handle_element_move(self, x: float, y: float):
"""Handle element movement during drag, including page transfer.""" """Handle element movement during drag, including page transfer."""
assert self.selected_element is not None
current_page, current_page_index, current_renderer = self._get_page_at(x, y) current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, "_parent_page"): if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page source_page = self.selected_element._parent_page # type: ignore[attr-defined]
if current_page is not source_page: if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer) self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
@ -201,17 +242,18 @@ class MouseInteractionMixin:
new_x = self.drag_start_element_pos[0] + total_dx new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy new_y = self.drag_start_element_pos[1] + total_dy
assert self.selected_element is not None
main_window = self.window() main_window = self.window()
snap_sys = page.layout.snapping_system snap_sys = page.layout.snapping_system
page_size = page.layout.size page_size = page.layout.size
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi # type: ignore[attr-defined]
snapped_pos = snap_sys.snap_position( snapped_pos = snap_sys.snap_position(
position=(new_x, new_y), position=(new_x, new_y),
size=self.selected_element.size, size=self.selected_element.size,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project, project=main_window.project, # type: ignore[attr-defined]
) )
self.selected_element.position = snapped_pos self.selected_element.position = snapped_pos

View File

@ -23,15 +23,15 @@ class AlignmentOperationsMixin:
status_msg: Status message format string (will receive element count) status_msg: Status message format string (will receive element count)
""" """
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return return
changes = alignment_func(elements) changes = alignment_func(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Align Left", label="Align Left",

View File

@ -23,15 +23,15 @@ class DistributionOperationsMixin:
status_msg: Status message format string (will receive element count) status_msg: Status message format string (will receive element count)
""" """
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): if not self.require_selection(min_count=3): # type: ignore[attr-defined]
return return
changes = distribution_func(elements) changes = distribution_func(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Distribute H", label="Distribute H",

View File

@ -301,7 +301,7 @@ class FileOperationsMixin:
if not file_path: if not file_path:
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
) )
if not file_path: if not file_path:
@ -319,7 +319,7 @@ class FileOperationsMixin:
# Bridge object: signals are thread-safe so background thread can # Bridge object: signals are thread-safe so background thread can
# emit them and slots always run on the main (GUI) thread. # emit them and slots always run on the main (GUI) thread.
bridge = _SaveBridge(parent=self) bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
def _on_progress(progress: int, message: str): def _on_progress(progress: int, message: str):
if hasattr(self, "_loading_widget"): if hasattr(self, "_loading_widget"):
@ -344,7 +344,7 @@ class FileOperationsMixin:
print(f"Successfully saved project to: {file_path}") print(f"Successfully saved project to: {file_path}")
if getattr(self, "_pending_close", False): if getattr(self, "_pending_close", False):
self._pending_close = False self._pending_close = False
self.close() self.close() # type: ignore[attr-defined]
else: else:
self._pending_close = False self._pending_close = False
error_msg = f"Failed to save project: {error}" error_msg = f"Failed to save project: {error}"
@ -419,7 +419,9 @@ class FileOperationsMixin:
"then click 'Attempt Healing' to find and import them." "then click 'Attempt Healing' to find and import them."
) )
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets") btn = msg.button(QMessageBox.StandardButton.Open)
if btn is not None:
btn.setText("Open Heal Assets")
result = msg.exec() result = msg.exec()
if result == QMessageBox.StandardButton.Open: if result == QMessageBox.StandardButton.Open:

View File

@ -23,15 +23,15 @@ class SizeOperationsMixin:
status_msg: Status message format string (will receive element count) status_msg: Status message format string (will receive element count)
""" """
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return return
changes = resize_func(elements) changes = resize_func(elements)
if changes: if changes:
cmd = ResizeElementsCommand(changes) cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
def _execute_fit_to_page(self, fit_func, status_msg: str): def _execute_fit_to_page(self, fit_func, status_msg: str):
""" """
@ -41,22 +41,22 @@ class SizeOperationsMixin:
fit_func: Function that takes (element, page) and returns a change tuple fit_func: Function that takes (element, page) and returns a change tuple
status_msg: Status message to display on success status_msg: Status message to display on success
""" """
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1): # type: ignore[attr-defined]
return return
page = self.get_current_page() page = self.get_current_page() # type: ignore[attr-defined]
if not page: if not page:
self.show_warning("No Page", "Please create a page first.") self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
return return
element = next(iter(self.gl_widget.selected_elements)) element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
change = fit_func(element, page) change = fit_func(element, page)
if change: if change:
cmd = ResizeElementsCommand([change]) cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd) self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg, 2000) self.show_status(status_msg, 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Same Size", label="Same Size",

View File

@ -31,7 +31,7 @@ class StyleOperationsMixin:
""" """
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
# Store old styles for undo # Store old styles for undo
@ -86,10 +86,10 @@ class StyleOperationsMixin:
return None return None
cmd = StyleChangeCommand(old_styles, style_updater, description) cmd = StyleChangeCommand(old_styles, style_updater, description)
self.project.history.execute(cmd) self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() self.update_view() # type: ignore[attr-defined]
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
# ========================================================================= # =========================================================================
# Corner Radius # Corner Radius
@ -106,7 +106,7 @@ class StyleOperationsMixin:
"""Show dialog to set corner radius""" """Show dialog to set corner radius"""
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
@ -151,7 +151,7 @@ class StyleOperationsMixin:
"""Show dialog to configure border""" """Show dialog to configure border"""
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
@ -198,7 +198,7 @@ class StyleOperationsMixin:
"""Show dialog to configure drop shadow""" """Show dialog to configure drop shadow"""
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
@ -235,7 +235,7 @@ class StyleOperationsMixin:
"""Toggle shadow enabled/disabled""" """Toggle shadow enabled/disabled"""
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
# Toggle based on first selected image # Toggle based on first selected image
@ -330,7 +330,7 @@ class StyleOperationsMixin:
"""Show dialog to select decorative frame""" """Show dialog to select decorative frame"""
images = self._get_selected_images() images = self._get_selected_images()
if not images: if not images:
self.show_status("No images selected", 2000) self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return return
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog

View File

@ -13,11 +13,11 @@ class PageNavigationMixin:
pan_offset: Tuple[float, float] pan_offset: Tuple[float, float]
zoom_level: float zoom_level: float
def update(self) -> None: def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...
def window(self) -> "QMainWindow": def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget""" """Expected from QWidget"""
... ...

View File

@ -181,7 +181,7 @@ class ViewportMixin:
content_height = bounds["height"] content_height = bounds["height"]
# Save original for page selection (prevents clamping from changing which page we target) # Save original for page selection (prevents clamping from changing which page we target)
original_pan_y = self.pan_offset[1] original_pan_y: float = self.pan_offset[1]
if content_height > viewport_height: if content_height > viewport_height:
max_pan_up = 0 # Can't pan beyond top edge max_pan_up = 0 # Can't pan beyond top edge

View File

@ -103,15 +103,15 @@ class ImageStyle:
""" """
self.corner_radius = corner_radius self.corner_radius = corner_radius
self.border_width = border_width self.border_width = border_width
self.border_color = tuple(border_color) self.border_color: Tuple[int, int, int] = border_color
self.shadow_enabled = shadow_enabled self.shadow_enabled = shadow_enabled
self.shadow_offset = tuple(shadow_offset) self.shadow_offset: Tuple[float, float] = shadow_offset
self.shadow_blur = shadow_blur self.shadow_blur = shadow_blur
self.shadow_color = tuple(shadow_color) self.shadow_color: Tuple[int, int, int, int] = shadow_color
self.frame_style = frame_style self.frame_style = frame_style
self.frame_color = tuple(frame_color) self.frame_color: Tuple[int, int, int] = frame_color
# frame_corners: (top_left, top_right, bottom_right, bottom_left) # frame_corners: (top_left, top_right, bottom_right, bottom_left)
self.frame_corners = tuple(frame_corners) if frame_corners else (True, True, True, True) self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True)
def copy(self) -> "ImageStyle": def copy(self) -> "ImageStyle":
"""Create a copy of this style.""" """Create a copy of this style."""
@ -148,7 +148,7 @@ class ImageStyle:
} }
@classmethod @classmethod
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle": def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
"""Deserialize style from dictionary.""" """Deserialize style from dictionary."""
if data is None: if data is None:
return cls() return cls()
@ -526,7 +526,7 @@ class ImageData(BaseLayoutElement):
frame_manager = get_frame_manager() frame_manager = get_frame_manager()
frame_manager.render_frame_opengl( frame_manager.render_frame_opengl(
frame_name=self.style.frame_style, frame_name=self.style.frame_style, # type: ignore[arg-type]
x=x, x=x,
y=y, y=y,
width=w, width=w,

View File

@ -121,7 +121,7 @@ class PageLayout:
if hasattr(self, "_async_loader"): if hasattr(self, "_async_loader"):
loader = self._async_loader loader = self._async_loader
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"): elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
else: else:
loader = None loader = None
@ -131,7 +131,7 @@ class PageLayout:
# Determine priority based on visibility (HIGH for now, can be refined) # Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, "request_image_load"): if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
element._async_load_requested = True element._async_load_requested = True
element._async_loading = True element._async_loading = True

View File

@ -68,7 +68,7 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
# Try to open the image - this is the most likely failure point # Try to open the image - this is the most likely failure point
try: try:
img = Image.open(task.image_path) img: Image.Image = Image.open(task.image_path)
except Exception as open_err: except Exception as open_err:
import traceback import traceback
@ -389,7 +389,7 @@ class PDFExporter:
Scans all pages and elements to build a list of ImageTask objects Scans all pages and elements to build a list of ImageTask objects
that can be processed in parallel. that can be processed in parallel.
""" """
tasks = [] tasks: List[ImageTask] = []
dpi = self.project.working_dpi dpi = self.project.working_dpi
for page in self.project.pages: for page in self.project.pages:
@ -580,7 +580,7 @@ class PDFExporter:
img.load() img.load()
# Store both image and buffer reference to prevent garbage collection # Store both image and buffer reference to prevent garbage collection
# Some PIL operations may still reference the source buffer # Some PIL operations may still reference the source buffer
img._ppa_buffer = buffer # Keep buffer alive with image img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
self._processed_images[task_id] = img self._processed_images[task_id] = img
except Exception as e: except Exception as e:
@ -760,7 +760,7 @@ class PDFExporter:
y_pt=y_pt, y_pt=y_pt,
width_pt=width_pt, width_pt=width_pt,
height_pt=height_pt, height_pt=height_pt,
page_number=page_number, page_number=int(page_number),
) )
self._render_image(ctx) self._render_image(ctx)
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
@ -943,7 +943,7 @@ class PDFExporter:
try: try:
# Load image using resolved path # Load image using resolved path
img = Image.open(image_full_path) img: Image.Image = Image.open(image_full_path)
img = convert_to_rgba(img) img = convert_to_rgba(img)
# Apply PIL-level rotation if needed # Apply PIL-level rotation if needed
@ -1072,7 +1072,7 @@ class PDFExporter:
frame_manager.render_frame_pdf( frame_manager.render_frame_pdf(
canvas=ctx.canvas, canvas=ctx.canvas,
frame_name=style.frame_style, frame_name=style.frame_style, # type: ignore[arg-type]
x_pt=ctx.x_pt, x_pt=ctx.x_pt,
y_pt=ctx.y_pt, y_pt=ctx.y_pt,
width_pt=ctx.width_pt, width_pt=ctx.width_pt,

View File

@ -342,7 +342,7 @@ class Project:
""" """
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
layout = [] layout: list[tuple[str, Optional["Page"], int]] = []
current_position = 1 # Start at position 1 (right page) current_position = 1 # Start at position 1 (right page)
for page in self.pages: for page in self.pages:

View File

@ -317,7 +317,7 @@ class ThumbnailGLWidget(QOpenGLWidget):
self.current_folder = folder_path self.current_folder = folder_path
# Find all image files # Find all image files
self.image_files = [] self.image_files: list[Path] = []
for ext in IMAGE_EXTENSIONS: for ext in IMAGE_EXTENSIONS:
self.image_files.extend(folder_path.glob(f"*{ext}")) self.image_files.extend(folder_path.glob(f"*{ext}"))
self.image_files.extend(folder_path.glob(f"*{ext.upper()}")) self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))