diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 63aa5ea..51b285a 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -14,24 +14,13 @@ jobs: runs-on: linux/amd64 container: image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest - strategy: - matrix: - python-version: ['3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout code uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - 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: Install project + run: pip3 install -e . --no-deps --break-system-packages - name: Download initial failed badges run: | @@ -71,7 +60,7 @@ jobs: - name: Generate coverage reports if: steps.pytest.outcome == 'success' run: | - python -c " + python3 -c " import json import os if os.path.exists('coverage.json'): @@ -100,15 +89,8 @@ jobs: name: coverage-reports 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 - if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11' + if: github.ref == 'refs/heads/master' run: | git config --local user.email "action@gitea.local" git config --local user.name "Gitea Action" diff --git a/Dockerfile.ci b/Dockerfile.ci index 1f09e51..0d756a1 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -6,9 +6,9 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y \ + --no-install-recommends \ # Python python3 \ - python3-venv \ python3-pip \ # PyQt6 / OpenGL runtime deps libgl1 \ @@ -42,3 +42,24 @@ RUN apt-get update && apt-get install -y \ git \ nodejs \ && 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 diff --git a/pyPhotoAlbum/asset_manager.py b/pyPhotoAlbum/asset_manager.py index 8c19b62..1899860 100644 --- a/pyPhotoAlbum/asset_manager.py +++ b/pyPhotoAlbum/asset_manager.py @@ -345,7 +345,7 @@ class AssetManager: Returns: List of relative paths to unused assets """ - unused = [] + unused: list[str] = [] if not os.path.exists(self.assets_folder): return unused diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index 331a40b..7acbbdd 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -405,7 +405,7 @@ class AsyncImageLoader(QObject): Returns: Processed PIL Image """ - img = Image.open(path) + img: Image.Image = Image.open(path) img = convert_to_rgba(img) # Downsample if target size specified (preserving aspect ratio) @@ -485,6 +485,7 @@ class AsyncImageLoader(QObject): self._pending_requests[path] = request # Submit to queue (thread-safe) + assert self._queue is not None asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop) logger.debug(f"Queued load: {path} (priority: {priority.name})") @@ -768,8 +769,8 @@ class AsyncPDFGenerator(QObject): # Temporarily patch Image.open try: - Image.open = cached_open - return exporter.export(output_path, progress_callback) + Image.open = cached_open # type: ignore[assignment] + return exporter.export(output_path, progress_callback) # type: ignore[no-any-return] finally: # Restore original Image.open = original_open diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index b46a367..c1cc5ae 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -42,6 +42,7 @@ def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement: """ elem_type = elem_data.get("type") + element: BaseLayoutElement if elem_type == "image": element = ImageData() elif elem_type == "placeholder": @@ -759,6 +760,8 @@ class CommandHistory: def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]: """Deserialize a single command using registry pattern""" cmd_type = data.get("type") + if cmd_type is None: + return None deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type) if not deserializer: diff --git a/pyPhotoAlbum/frame_manager.py b/pyPhotoAlbum/frame_manager.py index 6e0812b..6080e35 100644 --- a/pyPhotoAlbum/frame_manager.py +++ b/pyPhotoAlbum/frame_manager.py @@ -310,7 +310,7 @@ class FrameManager: # Load as PIL Image from bytes buffer buffer = io.BytesIO(png_data) - img = Image.open(buffer) + img: Image.Image = Image.open(buffer) if img.mode != "RGBA": img = img.convert("RGBA") @@ -836,11 +836,11 @@ class FrameManager: # Flip horizontally if h position differs 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 if asset_v != target_v: - img = img.transpose(Image.FLIP_TOP_BOTTOM) + img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) return img diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py index 1f9c02a..9346d45 100644 --- a/pyPhotoAlbum/mixins/async_loading.py +++ b/pyPhotoAlbum/mixins/async_loading.py @@ -4,7 +4,7 @@ Async loading mixin for non-blocking image loading and PDF generation. import os from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, cast import logging from PyQt6.QtCore import QObject @@ -22,11 +22,11 @@ class AsyncLoadingMixin: # Type hints for expected attributes from mixing class _pdf_progress_dialog: Optional[QProgressDialog] - def update(self) -> None: + def update(self) -> None: # type: ignore[empty-body] """Expected from QWidget""" ... - def window(self) -> "QMainWindow": + def window(self) -> "QMainWindow": # type: ignore[empty-body] """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) - 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.setWindowTitle("PDF Export") self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel) diff --git a/pyPhotoAlbum/mixins/element_manipulation.py b/pyPhotoAlbum/mixins/element_manipulation.py index a169341..0cdb524 100644 --- a/pyPhotoAlbum/mixins/element_manipulation.py +++ b/pyPhotoAlbum/mixins/element_manipulation.py @@ -15,7 +15,7 @@ class ElementManipulationMixin: drag_start_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""" ... @@ -62,12 +62,12 @@ class ElementManipulationMixin: self._resize_element_no_snap(dx, dy) 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 # Get page 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 from pyPhotoAlbum.snapping import SnapResizeParams @@ -80,7 +80,7 @@ class ElementManipulationMixin: resize_handle=self.resize_handle, page_size=page_size, dpi=dpi, - project=main_window.project, + project=main_window.project, # type: ignore[attr-defined] ) 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: return + if self.selected_element is None: + return start_x, start_y = self.resize_start_pos start_w, start_h = self.resize_start_size diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py index e5e54ad..a685319 100644 --- a/pyPhotoAlbum/mixins/element_selection.py +++ b/pyPhotoAlbum/mixins/element_selection.py @@ -14,7 +14,7 @@ class ElementSelectionMixin: # Type hints for expected attributes from mixing class _page_renderers: list - def window(self) -> "QMainWindow": + def window(self) -> "QMainWindow": # type: ignore[empty-body] """Expected from QWidget""" ... @@ -86,7 +86,7 @@ class ElementSelectionMixin: # Store the renderer with the element for later use element._page_renderer = renderer # type: ignore[attr-defined] element._parent_page = page # type: ignore[attr-defined] - return element + return element # type: ignore[no-any-return] return None diff --git a/pyPhotoAlbum/mixins/interaction_command_factory.py b/pyPhotoAlbum/mixins/interaction_command_factory.py index 693b465..4e30e8b 100644 --- a/pyPhotoAlbum/mixins/interaction_command_factory.py +++ b/pyPhotoAlbum/mixins/interaction_command_factory.py @@ -123,7 +123,7 @@ class InteractionState: Returns: Dict with non-None state values """ - state = {} + state: Dict[str, Any] = {} if self.position is not None: state["position"] = self.position if self.size is not None: diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index 483d395..db82b4e 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events """ import math +from typing import TYPE_CHECKING, Any, Optional, Set 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: @@ -16,6 +21,39 @@ class MouseInteractionMixin: 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): super().__init__(*args, **kwargs) @@ -27,6 +65,7 @@ class MouseInteractionMixin: def _handle_rotation_start(self, x: float, y: float): """Start rotation interaction for selected element.""" + assert self.selected_element is not None self._begin_rotate(self.selected_element) self.drag_start_pos = (x, y) self.rotation_start_angle = self.selected_element.rotation @@ -34,6 +73,7 @@ class MouseInteractionMixin: def _handle_resize_start(self, x: float, y: float, handle): """Start resize interaction for selected element.""" + assert self.selected_element is not None self._begin_resize(self.selected_element) self.resize_handle = handle self.drag_start_pos = (x, y) @@ -139,10 +179,10 @@ class MouseInteractionMixin: def _handle_rotation_move(self, x: float, y: float): """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 - 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_w, elem_h = self.selected_element.size @@ -174,10 +214,11 @@ class MouseInteractionMixin: def _handle_element_move(self, x: float, y: float): """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) 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: 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_y = self.drag_start_element_pos[1] + total_dy + assert self.selected_element is not None main_window = self.window() snap_sys = page.layout.snapping_system 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( position=(new_x, new_y), size=self.selected_element.size, page_size=page_size, dpi=dpi, - project=main_window.project, + project=main_window.project, # type: ignore[attr-defined] ) self.selected_element.position = snapped_pos diff --git a/pyPhotoAlbum/mixins/operations/alignment_ops.py b/pyPhotoAlbum/mixins/operations/alignment_ops.py index e899abd..6ff80d6 100644 --- a/pyPhotoAlbum/mixins/operations/alignment_ops.py +++ b/pyPhotoAlbum/mixins/operations/alignment_ops.py @@ -23,15 +23,15 @@ class AlignmentOperationsMixin: status_msg: Status message format string (will receive element count) """ 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 changes = alignment_func(elements) if changes: cmd = AlignElementsCommand(changes) - self.project.history.execute(cmd) - self.update_view() - self.show_status(status_msg.format(len(elements)), 2000) + self.project.history.execute(cmd) # type: ignore[attr-defined] + self.update_view() # type: ignore[attr-defined] + self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined] @ribbon_action( label="Align Left", diff --git a/pyPhotoAlbum/mixins/operations/distribution_ops.py b/pyPhotoAlbum/mixins/operations/distribution_ops.py index 757456c..5417091 100644 --- a/pyPhotoAlbum/mixins/operations/distribution_ops.py +++ b/pyPhotoAlbum/mixins/operations/distribution_ops.py @@ -23,15 +23,15 @@ class DistributionOperationsMixin: status_msg: Status message format string (will receive element count) """ 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 changes = distribution_func(elements) if changes: cmd = AlignElementsCommand(changes) - self.project.history.execute(cmd) - self.update_view() - self.show_status(status_msg.format(len(elements)), 2000) + self.project.history.execute(cmd) # type: ignore[attr-defined] + self.update_view() # type: ignore[attr-defined] + self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined] @ribbon_action( label="Distribute H", diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 310dc38..5dee0ce 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -301,7 +301,7 @@ class FileOperationsMixin: if not file_path: 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: @@ -319,7 +319,7 @@ class FileOperationsMixin: # Bridge object: signals are thread-safe so background thread can # 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): if hasattr(self, "_loading_widget"): @@ -344,7 +344,7 @@ class FileOperationsMixin: print(f"Successfully saved project to: {file_path}") if getattr(self, "_pending_close", False): self._pending_close = False - self.close() + self.close() # type: ignore[attr-defined] else: self._pending_close = False error_msg = f"Failed to save project: {error}" @@ -419,7 +419,9 @@ class FileOperationsMixin: "then click 'Attempt Healing' to find and import them." ) 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() if result == QMessageBox.StandardButton.Open: diff --git a/pyPhotoAlbum/mixins/operations/size_ops.py b/pyPhotoAlbum/mixins/operations/size_ops.py index 7dd1ba3..f4e3850 100644 --- a/pyPhotoAlbum/mixins/operations/size_ops.py +++ b/pyPhotoAlbum/mixins/operations/size_ops.py @@ -23,15 +23,15 @@ class SizeOperationsMixin: status_msg: Status message format string (will receive element count) """ 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 changes = resize_func(elements) if changes: cmd = ResizeElementsCommand(changes) - self.project.history.execute(cmd) - self.update_view() - self.show_status(status_msg.format(len(elements)), 2000) + self.project.history.execute(cmd) # type: ignore[attr-defined] + self.update_view() # type: ignore[attr-defined] + self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined] 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 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 - page = self.get_current_page() + page = self.get_current_page() # type: ignore[attr-defined] 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 - 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) if change: cmd = ResizeElementsCommand([change]) - self.project.history.execute(cmd) - self.update_view() - self.show_status(status_msg, 2000) + self.project.history.execute(cmd) # type: ignore[attr-defined] + self.update_view() # type: ignore[attr-defined] + self.show_status(status_msg, 2000) # type: ignore[attr-defined] @ribbon_action( label="Same Size", diff --git a/pyPhotoAlbum/mixins/operations/style_ops.py b/pyPhotoAlbum/mixins/operations/style_ops.py index 27783af..a49ee1f 100644 --- a/pyPhotoAlbum/mixins/operations/style_ops.py +++ b/pyPhotoAlbum/mixins/operations/style_ops.py @@ -31,7 +31,7 @@ class StyleOperationsMixin: """ images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return # Store old styles for undo @@ -86,10 +86,10 @@ class StyleOperationsMixin: return None 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.show_status(f"{description} applied to {len(images)} image(s)", 2000) + self.update_view() # type: ignore[attr-defined] + self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined] # ========================================================================= # Corner Radius @@ -106,7 +106,7 @@ class StyleOperationsMixin: """Show dialog to set corner radius""" images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog @@ -151,7 +151,7 @@ class StyleOperationsMixin: """Show dialog to configure border""" images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog @@ -198,7 +198,7 @@ class StyleOperationsMixin: """Show dialog to configure drop shadow""" images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog @@ -235,7 +235,7 @@ class StyleOperationsMixin: """Toggle shadow enabled/disabled""" images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return # Toggle based on first selected image @@ -330,7 +330,7 @@ class StyleOperationsMixin: """Show dialog to select decorative frame""" images = self._get_selected_images() if not images: - self.show_status("No images selected", 2000) + self.show_status("No images selected", 2000) # type: ignore[attr-defined] return from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py index 760c448..dc9f469 100644 --- a/pyPhotoAlbum/mixins/page_navigation.py +++ b/pyPhotoAlbum/mixins/page_navigation.py @@ -13,11 +13,11 @@ class PageNavigationMixin: pan_offset: Tuple[float, float] zoom_level: float - def update(self) -> None: + def update(self) -> None: # type: ignore[empty-body] """Expected from QWidget""" ... - def window(self) -> "QMainWindow": + def window(self) -> "QMainWindow": # type: ignore[empty-body] """Expected from QWidget""" ... diff --git a/pyPhotoAlbum/mixins/viewport.py b/pyPhotoAlbum/mixins/viewport.py index 204bfaa..174ce04 100644 --- a/pyPhotoAlbum/mixins/viewport.py +++ b/pyPhotoAlbum/mixins/viewport.py @@ -181,7 +181,7 @@ class ViewportMixin: content_height = bounds["height"] # 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: max_pan_up = 0 # Can't pan beyond top edge diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index df7d08b..e8b5896 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -103,15 +103,15 @@ class ImageStyle: """ self.corner_radius = corner_radius 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_offset = tuple(shadow_offset) + self.shadow_offset: Tuple[float, float] = shadow_offset 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_color = tuple(frame_color) + self.frame_color: Tuple[int, int, int] = frame_color # 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": """Create a copy of this style.""" @@ -148,7 +148,7 @@ class ImageStyle: } @classmethod - def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle": + def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle": """Deserialize style from dictionary.""" if data is None: return cls() @@ -526,7 +526,7 @@ class ImageData(BaseLayoutElement): frame_manager = get_frame_manager() frame_manager.render_frame_opengl( - frame_name=self.style.frame_style, + frame_name=self.style.frame_style, # type: ignore[arg-type] x=x, y=y, width=w, diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index 72f5800..a6c4b51 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -121,7 +121,7 @@ class PageLayout: if hasattr(self, "_async_loader"): loader = self._async_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: loader = None @@ -131,7 +131,7 @@ class PageLayout: # Determine priority based on visibility (HIGH for now, can be refined) 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_loading = True diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index 1d9b178..ec2758f 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -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: - img = Image.open(task.image_path) + img: Image.Image = Image.open(task.image_path) except Exception as open_err: import traceback @@ -389,7 +389,7 @@ class PDFExporter: Scans all pages and elements to build a list of ImageTask objects that can be processed in parallel. """ - tasks = [] + tasks: List[ImageTask] = [] dpi = self.project.working_dpi for page in self.project.pages: @@ -580,7 +580,7 @@ class PDFExporter: img.load() # Store both image and buffer reference to prevent garbage collection # 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 except Exception as e: @@ -760,7 +760,7 @@ class PDFExporter: y_pt=y_pt, width_pt=width_pt, height_pt=height_pt, - page_number=page_number, + page_number=int(page_number), ) self._render_image(ctx) elif isinstance(element, TextBoxData): @@ -943,7 +943,7 @@ class PDFExporter: try: # Load image using resolved path - img = Image.open(image_full_path) + img: Image.Image = Image.open(image_full_path) img = convert_to_rgba(img) # Apply PIL-level rotation if needed @@ -1072,7 +1072,7 @@ class PDFExporter: frame_manager.render_frame_pdf( canvas=ctx.canvas, - frame_name=style.frame_style, + frame_name=style.frame_style, # type: ignore[arg-type] x_pt=ctx.x_pt, y_pt=ctx.y_pt, width_pt=ctx.width_pt, diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index 4190542..36a40f1 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -342,7 +342,7 @@ class Project: """ from pyPhotoAlbum.models import GhostPageData - layout = [] + layout: list[tuple[str, Optional["Page"], int]] = [] current_position = 1 # Start at position 1 (right page) for page in self.pages: diff --git a/pyPhotoAlbum/thumbnail_browser.py b/pyPhotoAlbum/thumbnail_browser.py index a9a2352..05b823a 100644 --- a/pyPhotoAlbum/thumbnail_browser.py +++ b/pyPhotoAlbum/thumbnail_browser.py @@ -317,7 +317,7 @@ class ThumbnailGLWidget(QOpenGLWidget): self.current_folder = folder_path # Find all image files - self.image_files = [] + self.image_files: list[Path] = [] for ext in IMAGE_EXTENSIONS: self.image_files.extend(folder_path.glob(f"*{ext}")) self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))