diff --git a/faststack/app.py b/faststack/app.py index 97939f9..3b16dae 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -14,7 +14,6 @@ import re import shutil import uuid -import bisect import functools from collections import deque @@ -53,7 +52,7 @@ from faststack.config import config from faststack.logging_setup import setup_logging from faststack.models import ImageFile, DecodedImage -from faststack.io.indexer import find_images, find_images_with_variants, image_sort_key +from faststack.io.indexer import find_images, find_images_with_variants from faststack.io.variants import ( VariantGroup, build_badge_list, @@ -86,7 +85,7 @@ get_file_counts_by_extension, ) import numpy as np -from faststack.io.indexer import RAW_EXTENSIONS +from faststack.io.indexer import RAW_EXTENSIONS, JPG_EXTENSIONS from faststack.io.deletion import ( confirm_permanent_delete, confirm_batch_permanent_delete, @@ -96,9 +95,8 @@ DeleteJob, DeleteResult, DeleteRecord, - DeleteWarning, - DeleteFailure, DeletionErrorCodes, + UIStateRestoration, ) @@ -242,6 +240,20 @@ def __init__( "" # Detail message from last auto_levels() call ) + # Deferred-init state: set to safe defaults, populated later by their methods + self._save_initiated_path: Optional[str] = None + self._batch_indices_cache: set = set() + self._batch_indices_cache_key: Optional[tuple] = None + self.recycle_bin_dir: Optional[Path] = None + self.reporter: Optional[AppController.ProgressReporter] = None + self._last_rendered_preview_index: int = -1 + self._last_rendered_preview_gen: int = -1 + self._batch_al_indices: list = [] + self._batch_al_pos: int = 0 + self._batch_al_processed: int = 0 + self._batch_al_cancelled: bool = False + self._batch_al_t_start: float = 0.0 + # Variant state self._variant_map: Dict[str, VariantGroup] = {} self.view_override_path: Optional[str] = None # normalized absolute string @@ -270,7 +282,9 @@ def __init__( # Cache Warning State self._last_cache_warning_time = 0 self._eviction_lock = threading.Lock() - self._eviction_timestamps: deque[float] = deque() # Rolling window for rate detection + self._eviction_timestamps: deque[float] = ( + deque() + ) # Rolling window for rate detection self.display_ready = False # Track if display size has been reported self.pending_prefetch_index: Optional[int] = None # Deferred prefetch index @@ -395,7 +409,9 @@ def __init__( self._metadata_cache = {} self._metadata_cache_index = (-1, -1) self._exif_brief_cache: dict = {} # normalized path key → formatted EXIF string - self._exif_pending_path: Optional[str] = None # path currently awaiting EXIF read + self._exif_pending_path: Optional[str] = ( + None # path currently awaiting EXIF read + ) with self._last_image_lock: self.last_displayed_image = None self._logged_empty_metadata = False @@ -535,8 +551,6 @@ def get_active_edit_path(self, index: int) -> Path: # ImageFile.path is the main file. ImageFile.raw_pair is the sidecar RAW. # If raw_pair is None but path is a RAW extension, it's RAW-only. is_raw_only = False - from faststack.io.indexer import RAW_EXTENSIONS - if img.raw_pair is None and img.path.suffix.lower() in RAW_EXTENSIONS: is_raw_only = True @@ -657,7 +671,11 @@ def _bump_display_generation(self): def on_display_size_changed(self, width: int, height: int): """Debounces display size change events to prevent spamming resizes.""" log.debug( - f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}" + "on_display_size_changed called with %dx%d. Current: %dx%d", + width, + height, + self.display_width, + self.display_height, ) if width <= 0 or height <= 0: log.debug("Ignoring invalid resize event") @@ -704,7 +722,7 @@ def set_zoomed(self, zoomed: bool): if self.is_zoomed == zoomed: return # No-op: avoid unnecessary cache invalidation if _debug_mode: - log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") + log.info("AppController.set_zoomed: %s -> %s", self.is_zoomed, zoomed) self.is_zoomed = zoomed self.display_generation += 1 # Invalidates old entries via cache key self.is_zoomed_changed.emit(zoomed) @@ -921,9 +939,8 @@ def _request_watcher_refresh(self, path=None): path, ) return - else: - # Cleanup expired entry - del self._suppressed_paths[key] + # Cleanup expired entry + del self._suppressed_paths[key] try: QMetaObject.invokeMethod( @@ -997,15 +1014,15 @@ def _on_watcher_refresh(self): else: # Image gone — clear stale variant override self._clear_variant_override() - self.current_index = min( - self.current_index, len(self.image_files) - 1 - ) + self.current_index = min(self.current_index, len(self.image_files) - 1) else: # List empty or no preserved path — clear stale variant override self._clear_variant_override() - self.current_index = max(0, min( - self.current_index, len(self.image_files) - 1 - )) if self.image_files else 0 + self.current_index = ( + max(0, min(self.current_index, len(self.image_files) - 1)) + if self.image_files + else 0 + ) self._do_prefetch(self.current_index) self.sync_ui_state() @@ -1172,7 +1189,7 @@ def _maybe_decode_current_image( # Log reason for debugging if _debug_mode and reason: - log.debug(f"Triggering decode: {reason}") + log.debug("Triggering decode: %s", reason) # Trigger prefetch/decode for current index self._do_prefetch(self.current_index, override_path=override_path) @@ -1411,11 +1428,13 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: try: result = future.result(timeout=5.0) except concurrent.futures.TimeoutError: - log.warning(f"Timeout decoding image at index {index} (background)") + log.warning( + "Timeout decoding image at index %d (background)", index + ) return None except concurrent.futures.CancelledError: log.debug( - f"Decode cancelled for image at index {index} (background)" + "Decode cancelled for image at index %d (background)", index ) return None @@ -1570,7 +1589,7 @@ def do_save(): except RuntimeError as e: return {"success": False, "error": str(e)} except Exception as e: - log.exception(f"Unexpected error during save: {e}") + log.exception("Unexpected error during save: %s", e) return {"success": False, "error": "Failed to save image"} def on_done(future): @@ -1687,8 +1706,6 @@ def _set_current_index( # (This implements the "Default state on navigation" requirement) img = self.image_files[index] is_raw_only = False - from faststack.io.indexer import RAW_EXTENSIONS, JPG_EXTENSIONS - # Robust RAW-only check: Main path is RAW and it's not a JPEG is_jpeg_main = img.path.suffix.lower() in JPG_EXTENSIONS is_raw_main = img.path.suffix.lower() in RAW_EXTENSIONS @@ -1916,8 +1933,6 @@ def _set_grid_view_active(self, active: bool): grid_index = self._thumbnail_model.find_image_index(current_path) if grid_index >= 0: # Emit after isGridViewActiveChanged so QML has created the view - from PySide6.QtCore import QTimer - QTimer.singleShot( 0, lambda: self.ui_state.gridScrollToIndex.emit(grid_index) ) @@ -2163,9 +2178,8 @@ def _get_bulk_metadata_map(self) -> Dict[str, dict]: def _invalidate_batch_cache(self): """Clear the batch indices cache. Call after mutating self.batches.""" - if hasattr(self, "_batch_indices_cache"): - self._batch_indices_cache = set() - self._batch_indices_cache_key = None + self._batch_indices_cache = set() + self._batch_indices_cache_key = None def _get_batch_indices(self) -> Set[int]: """Get set of all indices that are in any batch (for thumbnail model). @@ -2199,8 +2213,6 @@ def toggle_uploaded(self): if not self.image_files or self.current_index >= len(self.image_files): return - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -2224,8 +2236,6 @@ def toggle_todo(self): if not self.image_files or self.current_index >= len(self.image_files): return - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -2249,8 +2259,6 @@ def toggle_edited(self): if not self.image_files or self.current_index >= len(self.image_files): return - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -2274,8 +2282,6 @@ def toggle_restacked(self): if not self.image_files or self.current_index >= len(self.image_files): return - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -2317,8 +2323,6 @@ def toggle_stacked(self): if not self.image_files or self.current_index >= len(self.image_files): return - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -2454,7 +2458,7 @@ def _worker(key=exif_key, p=str(source_path)): try: brief = get_exif_brief(p) except Exception as e: - log.error(f"Failed to get EXIF brief for {p}: {e}", exc_info=True) + log.error("Failed to get EXIF brief for %s: %s", p, e, exc_info=True) brief = "" signal.emit(key, brief) @@ -3374,8 +3378,7 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): if bottom > 1000: top -= bottom - 1000 # shift up bottom = 1000 - if top < 0: - top = 0 # double clamp + top = max(0, top) # double clamp # Recenter width (if changed) cx = (left + right) / 2 @@ -3389,13 +3392,12 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): if right > 1000: left -= right - 1000 right = 1000 - if left < 0: - left = 0 + left = max(0, left) self.ui_state.currentCropBox = (left, top, right, bottom) self.image_editor.set_crop_box((left, top, right, bottom)) - log.debug(f"AppController.set_straighten_angle: {angle}") + log.debug("AppController.set_straighten_angle: %s", angle) # Pass the angle as-is (degrees CW). # QML rotation is CW-positive. # ImageEditor expects CW-positive and handles the inversion for PIL internally. @@ -3787,7 +3789,6 @@ def _move_to_recycle(src: Path, _created_bins: set | None = None) -> Optional[Pa log.error("Failed to recycle %s: %s", src.name, e) return None - @staticmethod def _perm_delete_worker( job_id: int, @@ -4315,38 +4316,39 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No self.prefetcher.update_prefetch(self.current_index) # Restore saved batch state if present - if job.saved_batches is not None and items: + ui = job.ui_state + if ui is not None and ui.saved_batches is not None and items: original = {idx for idx, _ in job.removed_items} restored = {idx for idx, _ in items} if restored == original: # Full rollback: restore pre-delete snapshot directly - self.batches = [b[:] for b in job.saved_batches] - self.batch_start_index = job.saved_batch_start_index + self.batches = [b[:] for b in ui.saved_batches] + self.batch_start_index = ui.saved_batch_start_index else: # Partial rollback: re-apply the deletions that were not reversed still_deleted = sorted(original - restored) self.batches = self._recompute_batches_after_deletions( - job.saved_batches, still_deleted + ui.saved_batches, still_deleted ) self.batch_start_index = self._shift_start_index( - job.saved_batch_start_index, still_deleted + ui.saved_batch_start_index, still_deleted ) self._invalidate_batch_cache() # Restore saved stack state if present - if job.saved_stacks is not None and items: + if ui is not None and ui.saved_stacks is not None and items: original = {idx for idx, _ in job.removed_items} restored = {idx for idx, _ in items} if restored == original: - self.stacks = [s[:] for s in job.saved_stacks] - self.stack_start_index = job.saved_stack_start_index + self.stacks = [s[:] for s in ui.saved_stacks] + self.stack_start_index = ui.saved_stack_start_index else: still_deleted = sorted(original - restored) self.stacks = self._recompute_batches_after_deletions( - job.saved_stacks, still_deleted + ui.saved_stacks, still_deleted ) self.stack_start_index = self._shift_start_index( - job.saved_stack_start_index, still_deleted + ui.saved_stack_start_index, still_deleted ) self.sidecar.data.stacks = self.stacks self._metadata_cache_index = (-1, -1) @@ -4356,8 +4358,6 @@ def _schedule_delete_refresh(self) -> None: if self._refresh_scheduled: return self._refresh_scheduled = True - from PySide6.QtCore import QTimer - QTimer.singleShot(200, self._fire_delete_refresh) def _fire_delete_refresh(self) -> None: @@ -4459,7 +4459,9 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: else: # Current image survived → shift index down for each deletion before it shift = sum(1 for d in validated_sorted if d < previous_index) - self.current_index = max(0, min(previous_index - shift, len(self.image_files) - 1)) + self.current_index = max( + 0, min(previous_index - shift, len(self.image_files) - 1) + ) # Save batch/stack state before mutation so rollback can restore it. pre_batch_snapshot = [b[:] for b in self.batches] @@ -4616,10 +4618,12 @@ def _shift(orig_idx: int) -> int: cancel_event=cancel_event, previous_index=previous_index, images_to_delete=images_to_delete, - saved_batches=pre_batch_snapshot, - saved_batch_start_index=pre_batch_start_snapshot, - saved_stacks=pre_stack_snapshot, - saved_stack_start_index=pre_stack_start_snapshot, + ui_state=UIStateRestoration( + saved_batches=pre_batch_snapshot, + saved_batch_start_index=pre_batch_start_snapshot, + saved_stacks=pre_stack_snapshot, + saved_stack_start_index=pre_stack_start_snapshot, + ), ) # Add single placeholder undo entry per job @@ -4721,10 +4725,11 @@ def delete_batch_images(self): # 4. Clear batches optimistically; save state in job for rollback job_id = summary["job_id"] if job_id in self._pending_delete_jobs: - self._pending_delete_jobs[job_id].saved_batches = saved_batches - self._pending_delete_jobs[job_id].saved_batch_start_index = ( - saved_batch_start - ) + job = self._pending_delete_jobs[job_id] + if job.ui_state is None: + job.ui_state = UIStateRestoration() + job.ui_state.saved_batches = saved_batches + job.ui_state.saved_batch_start_index = saved_batch_start self.batches = [] self.batch_start_index = None @@ -4776,7 +4781,7 @@ def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> boo if not temp_path.exists(): log.error("Temp file %s not found after move!", temp_path) - raise OSError(f"Failed to create temp file {temp_path}") + raise OSError(f"Failed to create temp file {temp_path}") from pe # Try to force-move the temp file over the target (replace) try: @@ -4841,7 +4846,7 @@ def _restore_from_recycle_bin_safe( else: return False, "move_failed" except OSError as e: - log.error(f"Failed to restore {bin_path.name}: {e}") + log.error("Failed to restore %s: %s", bin_path.name, e) return False, "move_failed" def _post_undo_refresh_and_select( @@ -4916,14 +4921,15 @@ def undo_delete(self): self.prefetcher.update_prefetch(self.current_index) self._rebuild_path_to_index() # Restore batch state that was shifted during _delete_indices - if job.saved_batches is not None and removed_items: - self.batches = job.saved_batches - self.batch_start_index = job.saved_batch_start_index + ui = job.ui_state + if ui is not None and ui.saved_batches is not None and removed_items: + self.batches = ui.saved_batches + self.batch_start_index = ui.saved_batch_start_index self._invalidate_batch_cache() # Restore stack state that was shifted during _delete_indices - if job.saved_stacks is not None and removed_items: - self.stacks = job.saved_stacks - self.stack_start_index = job.saved_stack_start_index + if ui is not None and ui.saved_stacks is not None and removed_items: + self.stacks = ui.saved_stacks + self.stack_start_index = ui.saved_stack_start_index self.sidecar.data.stacks = self.stacks self._metadata_cache_index = (-1, -1) self.sync_ui_state() @@ -5144,7 +5150,6 @@ def shutdown_nonqt(self): self._shutting_down = True # gate async callbacks during shutdown_nonqt too self._exif_pending_path = None # optional but consistent with shutdown_qt - # Clear pending delete jobs and remove associated undo placeholders if self._pending_delete_jobs: log.info( @@ -5165,11 +5170,17 @@ def shutdown_nonqt(self): self._safe_shutdown_executor(self._hist_executor, "histogram", wait=False) self._safe_shutdown_executor(self._preview_executor, "preview", wait=False) self._safe_shutdown_executor( - getattr(self, "_exif_executor", None), "exif", wait=False, + getattr(self, "_exif_executor", None), + "exif", + wait=False, ) # wait=True ensures pending saves/deletes complete to avoid data loss/corruption - self._safe_shutdown_executor(self._save_executor, "save", wait=True, cancel_futures=False) - self._safe_shutdown_executor(self._delete_executor, "delete", wait=True, cancel_futures=False) + self._safe_shutdown_executor( + self._save_executor, "save", wait=True, cancel_futures=False + ) + self._safe_shutdown_executor( + self._delete_executor, "delete", wait=True, cancel_futures=False + ) # Shutdown prefetcher try: @@ -5569,8 +5580,6 @@ def start_drag_current_image(self): # Mark all dragged files as uploaded if drag was successful if result in (Qt.CopyAction, Qt.MoveAction): - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") for idx in existing_indices: @@ -5681,7 +5690,7 @@ def worker(): run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW try: - result = subprocess.run(cmd, **run_kwargs) + result = subprocess.run(cmd, check=False, **run_kwargs) if result.returncode == 0: if tif_path.exists() and tif_path.stat().st_size > 0: @@ -5691,12 +5700,11 @@ def worker(): 0, functools.partial(self._on_develop_finished, True, None) ) return # Success path - else: - msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" - log.error(msg) - QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, False, msg) - ) + msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" + log.error(msg) + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, False, msg) + ) else: stderr = result.stderr.strip() if result.stderr else "(no stderr)" stdout = result.stdout.strip() if result.stdout else "(no stdout)" @@ -5780,7 +5788,7 @@ def load_image_for_editing(self): source_exif = src_im.info.get("exif") except Exception as e: log.warning( - f"Failed to capture source EXIF from {jpeg_path}: {e}" + "Failed to capture source EXIF from %s: %s", jpeg_path, e ) # Load into editor @@ -5813,9 +5821,6 @@ def _sync_editor_state_to_ui(self): # Reset visual components if hasattr(self.ui_state, "aspectRatioNames"): - # This requires IMPORTs? No, just pass list. - from faststack.imaging.editor import ASPECT_RATIOS - self.ui_state.aspectRatioNames = [r["name"] for r in ASPECT_RATIOS] self.ui_state.currentAspectRatioIndex = 0 self.ui_state.currentCropBox = (0, 0, 1000, 1000) @@ -5882,8 +5887,6 @@ def set_edit_parameter(self, key: str, value: Any): @Slot(int, int, int, int) def set_crop_box(self, left: int, top: int, right: int, bottom: int): """Sets the normalized crop box (0-1000) in the editor.""" - from typing import Tuple - crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) self.image_editor.set_crop_box(crop_box) self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) @@ -6025,7 +6028,7 @@ def _kick_histogram_worker(self): ) fut.add_done_callback(self._on_histogram_done) except Exception as e: - log.error(f"Histogram executor failed to submit task: {e}") + log.error("Histogram executor failed to submit task: %s", e) with self._hist_lock: self._hist_inflight = False @@ -6284,7 +6287,7 @@ def toggle_crop_mode(self): match = str(editor_path) == str(filepath) if not match: - log.debug(f"toggle_crop_mode: Loading {filepath} into editor") + log.debug("toggle_crop_mode: Loading %s into editor", filepath) # Use cached preview if available to speed up using get_decoded_image(self.current_index) # note: get_decoded_image verifies index bounds cached_preview = self.get_decoded_image(self.current_index) @@ -6452,8 +6455,6 @@ def stack_source_raws(self): if success: # Mark as restacked on success - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -6550,11 +6551,11 @@ def execute_crop(self): try: save_result = self.image_editor.save_image() except RuntimeError as e: - log.warning(f"execute_crop: Save failed: {e}") + log.warning("execute_crop: Save failed: %s", e) self.update_status_message(f"Failed to save cropped image: {e}") return except Exception as e: - log.exception(f"execute_crop: Unexpected error during save: {e}") + log.exception("execute_crop: Unexpected error during save: %s", e) self.update_status_message("Failed to save cropped image") return @@ -6792,11 +6793,11 @@ def quick_auto_levels(self): save_target_path=save_target_path ) except RuntimeError as e: - log.warning(f"quick_auto_levels: Save failed: {e}") + log.warning("quick_auto_levels: Save failed: %s", e) self.update_status_message(f"Failed to save image: {e}") return except Exception as e: - log.exception(f"quick_auto_levels: Unexpected error during save: {e}") + log.exception("quick_auto_levels: Unexpected error during save: %s", e) self.update_status_message("Failed to save image") return t_save = time.perf_counter() @@ -7029,12 +7030,12 @@ def quick_auto_white_balance(self): try: save_result = self.image_editor.save_image() except RuntimeError as e: - log.warning(f"quick_auto_white_balance: Save failed: {e}") + log.warning("quick_auto_white_balance: Save failed: %s", e) self.update_status_message(f"Failed to save image: {e}") return except Exception as e: log.exception( - f"quick_auto_white_balance: Unexpected error during save: {e}" + "quick_auto_white_balance: Unexpected error during save: %s", e ) self.update_status_message("Failed to save image") return @@ -7096,7 +7097,7 @@ def auto_white_balance(self) -> Optional[str]: elif mode == "rgb": return self.auto_white_balance_legacy() else: - log.error(f"Unknown AWB mode: {mode}") + log.error("Unknown AWB mode: %s", mode) self.update_status_message(f"Error: Unknown AWB mode '{mode}'") return None @@ -7111,13 +7112,6 @@ def auto_white_balance_legacy(self) -> Optional[str]: log.warning("No image loaded in editor for auto white balance") return None - try: - import numpy as np - except ImportError: - log.error("NumPy not found. Please install with: pip install numpy") - self.update_status_message("Error: NumPy not installed") - return None - log.info("Applying legacy (RGB Grey World) Auto White Balance") t_awb_start = time.perf_counter() @@ -7285,7 +7279,7 @@ def auto_white_balance_lab(self) -> Optional[str]: by_value = float(np.clip(by_value, -1.0, 1.0)) mg_value = float(np.clip(mg_value, -1.0, 1.0)) - log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") + log.info("Auto white balance values: B/Y=%.3f, M/G=%.3f", by_value, mg_value) # No-change detection — see _AWB_NOOP_EPS definition for rationale if abs(by_value) < _AWB_NOOP_EPS and abs(mg_value) < _AWB_NOOP_EPS: diff --git a/faststack/config.py b/faststack/config.py index ef043fe..1d9a3d4 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -46,8 +46,8 @@ def version_sort_key(path): # 5.10 > 5.9 matches.sort(key=version_sort_key, reverse=True) return matches[0] - except Exception as e: - log.warning(f"Error detecting RawTherapee path: {e}") + except (OSError, RuntimeError) as e: + log.warning("Error detecting RawTherapee path: %s", e) return None @@ -127,6 +127,8 @@ def version_sort_key(path): class AppConfig: + """Manages application configuration backed by an INI file.""" + def __init__(self): self.config_path = get_app_data_dir() / "faststack.ini" self.config = configparser.ConfigParser() @@ -135,11 +137,11 @@ def __init__(self): def load(self): """Loads the config, creating it with defaults if it doesn't exist.""" if not self.config_path.exists(): - log.info(f"Creating default config at {self.config_path}") + log.info("Creating default config at %s", self.config_path) self.config.read_dict(DEFAULT_CONFIG) self.save() else: - log.info(f"Loading config from {self.config_path}") + log.info("Loading config from %s", self.config_path) self.config.read(self.config_path) # Ensure all sections and keys exist for section, keys in DEFAULT_CONFIG.items(): @@ -155,11 +157,12 @@ def load(self): current_rt_path = self.get("rawtherapee", "exe") if not os.path.exists(current_rt_path): log.warning( - f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection..." + "Configured RawTherapee path not found: %s. Attempting re-detection...", + current_rt_path, ) new_path = detect_rawtherapee_path() if new_path and new_path != current_rt_path: - log.info(f"Found new RawTherapee path: {new_path}") + log.info("Found new RawTherapee path: %s", new_path) self.set("rawtherapee", "exe", new_path) self.save() @@ -169,23 +172,28 @@ def save(self): self.config_path.parent.mkdir(parents=True, exist_ok=True) with self.config_path.open("w") as f: self.config.write(f) - log.info(f"Saved config to {self.config_path}") + log.info("Saved config to %s", self.config_path) except IOError as e: - log.error(f"Failed to save config to {self.config_path}: {e}") + log.error("Failed to save config to %s: %s", self.config_path, e) def get(self, section, key, fallback=None): + """Return a config value as a string.""" return self.config.get(section, key, fallback=fallback) def getint(self, section, key, fallback=None): + """Return a config value as an integer.""" return self.config.getint(section, key, fallback=fallback) def getfloat(self, section, key, fallback=None): + """Return a config value as a float.""" return self.config.getfloat(section, key, fallback=fallback) def getboolean(self, section, key, fallback=None): + """Return a config value as a boolean.""" return self.config.getboolean(section, key, fallback=fallback) def set(self, section, key, value): + """Set a config value, creating the section if needed.""" if not self.config.has_section(section): self.config.add_section(section) self.config.set(section, key, str(value)) diff --git a/faststack/deletion_types.py b/faststack/deletion_types.py index d6ec1e5..69dce5c 100644 --- a/faststack/deletion_types.py +++ b/faststack/deletion_types.py @@ -5,7 +5,6 @@ """ import threading -import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, List, Optional, Tuple @@ -28,6 +27,16 @@ class DeletionErrorCodes(str, Enum): UNKNOWN = "unknown" +@dataclass +class UIStateRestoration: + """Saved UI grouping state for rollback after an undo or cancel.""" + + saved_batches: Optional[list] = None + saved_batch_start_index: Optional[int] = None + saved_stacks: Optional[list] = None + saved_stack_start_index: Optional[int] = None + + @dataclass class DeleteJob: """In-flight delete job tracked in _pending_delete_jobs. @@ -44,10 +53,7 @@ class DeleteJob: images_to_delete: List[Any] # List[ImageFile] objects removed from UI user_undone: bool = False undo_requested: bool = False # Policy 1: auto-restore files on completion - saved_batches: Optional[list] = None - saved_batch_start_index: Optional[int] = None - saved_stacks: Optional[list] = None - saved_stack_start_index: Optional[int] = None + ui_state: Optional[UIStateRestoration] = None @dataclass @@ -80,7 +86,7 @@ class DeleteFailure: @dataclass -class DeleteResult: +class DeleteResult: # pylint: disable=too-many-instance-attributes """Parsed worker result, used on the UI thread side only. The worker still returns a plain dict over the Qt signal boundary. diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index 7692c43..430c489 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -2,7 +2,6 @@ import inspect import logging -from collections import deque from pathlib import Path from typing import Any, Callable, Optional, Union import time @@ -19,29 +18,29 @@ def get_decoded_image_size(item) -> int: # Handle both numpy arrays and memoryview buffers if hasattr(item.buffer, "nbytes"): return item.buffer.nbytes - elif isinstance(item.buffer, (bytes, bytearray)): + if isinstance(item.buffer, (bytes, bytearray)): return len(item.buffer) + # Fallback: estimate from dimensions (more accurate for image buffers than sys.getsizeof) + width = getattr(item, "width", 0) + height = getattr(item, "height", 0) + if width <= 0 or height <= 0: + return 1 # No usable dimensions + + if hasattr(item, "bytes_per_line") and item.bytes_per_line > 0: + bytes_per_pixel = item.bytes_per_line // width else: - # Fallback: estimate from dimensions (more accurate for image buffers than sys.getsizeof) - width = getattr(item, "width", 0) - height = getattr(item, "height", 0) - if width <= 0 or height <= 0: - return 1 # No usable dimensions - - if hasattr(item, "bytes_per_line") and item.bytes_per_line > 0: - bytes_per_pixel = item.bytes_per_line // width - else: - bytes_per_pixel = 4 # Default to RGBA + bytes_per_pixel = 4 # Default to RGBA - # Guard against 0 (e.g. bytes_per_line=0) which would yield size 0 - # and break cache accounting. Don't clamp to 4 — that overcounts - # legitimate RGB (3 Bpp) buffers and causes premature evictions. - bytes_per_pixel = max(1, min(bytes_per_pixel, 16)) + # Guard against 0 (e.g. bytes_per_line=0) which would yield size 0 + # and break cache accounting. Don't clamp to 4 — that overcounts + # legitimate RGB (3 Bpp) buffers and causes premature evictions. + bytes_per_pixel = max(1, min(bytes_per_pixel, 16)) - return width * height * bytes_per_pixel + return width * height * bytes_per_pixel log.warning( - f"Unexpected item type in cache: {type(item)}. Returning estimated size of 1." + "Unexpected item type in cache: %s. Returning estimated size of 1.", + type(item), ) return 1 # Should not happen @@ -74,7 +73,8 @@ def __init__( self._pressure_eviction_active = False self._pressure_eviction_owner: Optional[int] = None log.info( - f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity." + "Initialized byte-aware LRU cache with %.2f MB capacity.", + max_bytes / 1024**2, ) @property @@ -87,7 +87,7 @@ def max_bytes(self, value: int) -> None: """Set the maximum cache size in bytes.""" v = max(0, int(value)) self.maxsize = v - log.debug(f"Cache max_bytes updated to {v / 1024**2:.2f} MB") + log.debug("Cache max_bytes updated to %.2f MB", v / 1024**2) @staticmethod def _detect_arity(callback: Optional[Callable]) -> int: @@ -111,10 +111,12 @@ def _detect_arity(callback: Optional[Callable]) -> int: except (ValueError, TypeError): return 2 - def _fire_evict(self, key: Any, value: Any, info: dict) -> None: + def _fire_evict(self, key: Any, value: Any, info: Optional[dict] = None) -> None: """Invoke on_evict, dispatching by detected arity.""" if not self.on_evict: return + if info is None: + info = {} if self._on_evict_arity >= 3: self.on_evict(key, value, info) else: @@ -130,7 +132,7 @@ def _build_eviction_info(self, reason: str, pre_usage: int) -> dict: "thread_id": threading.get_ident(), } - def __setitem__(self, key, value): + def __setitem__(self, key, value): # pylint: disable=signature-differs pending_callbacks = [] with self._lock: # Check tombstones - prevent caching if key starts with a tombstoned prefix @@ -149,11 +151,11 @@ def __setitem__(self, key, value): for prefix in self._tombstones: if key_str.startswith(prefix): - log.debug(f"Refusing to cache tombstoned key: {key}") + log.debug("Refusing to cache tombstoned key: %s", key) return # Check if this is a replacement to handle its callback if __delitem__ isn't called. - _MISSING = object() + _MISSING = object() # pylint: disable=invalid-name old_value = _MISSING if self.on_evict and key in self: try: @@ -194,17 +196,17 @@ def _replace_cb(k=key, v=old_value, _info=info): self._pending_callbacks_owner = None log.debug( - f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB" + "Cached item '%s'. Cache size: %.2f MB", key, self.currsize / 1024**2 ) # Execute all captured eviction callbacks OUTSIDE the lock for callback in pending_callbacks: try: callback() - except Exception: + except Exception: # pylint: disable=broad-exception-caught log.exception("Error in eviction callback") - def __getitem__(self, key): + def __getitem__(self, key): # pylint: disable=signature-differs """Thread-safe access (updates LRU order).""" with self._lock: return super().__getitem__(key) @@ -214,7 +216,7 @@ def __contains__(self, key): with self._lock: return super().__contains__(key) - def __delitem__(self, key): + def __delitem__(self, key): # pylint: disable=signature-differs """Thread-safe delete with eviction callback.""" callback = None with self._lock: @@ -247,7 +249,7 @@ def __delitem__(self, key): super().__delitem__(key) log.debug( - f"Removed item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB" + "Removed item '%s'. Cache size: %.2f MB", key, self.currsize / 1024**2 ) if self.on_evict: @@ -269,7 +271,7 @@ def _callback_func(k=key, v=value, _info=info): if callback: try: callback() - except Exception: + except Exception: # pylint: disable=broad-exception-caught log.exception("Error in eviction callback") def get(self, key, default=None): @@ -335,12 +337,12 @@ def pop_path(self, path: Union[Path, str]): for callback in pending_callbacks: try: callback() - except Exception: + except Exception: # pylint: disable=broad-exception-caught log.exception("Error in eviction callback") if keys_to_remove: log.debug( - f"Invalidated {len(keys_to_remove)} cache entries for path: {path}" + "Invalidated %d cache entries for path: %s", len(keys_to_remove), path ) def evict_paths(self, paths: list[Union[Path, str]]): @@ -402,7 +404,7 @@ def evict_paths(self, paths: list[Union[Path, str]]): if val is not None: try: size = get_decoded_image_size(val) - except Exception: + except Exception: # pylint: disable=broad-exception-caught size = 0 # Fallback removed_bytes += size finally: @@ -412,7 +414,10 @@ def evict_paths(self, paths: list[Union[Path, str]]): if keys_to_remove: log.info( - f"Evicted {len(keys_to_remove)} entries ({removed_bytes / 1024**2:.2f} MB) for {len(paths)} paths" + "Evicted %d entries (%.2f MB) for %d paths", + len(keys_to_remove), + removed_bytes / 1024**2, + len(paths), ) diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 511f0af..a340ae8 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -1,16 +1,19 @@ +"""Non-destructive image editor: crop, rotate, exposure, contrast, WB, sharpness.""" + import logging +import math import os -import shutil import re -import math +import shutil +import threading import time import uuid from pathlib import Path from typing import Optional, Dict, Any, List, Tuple + import numpy as np from PIL import Image, ImageFilter, ImageOps, ExifTags - from faststack.models import DecodedImage from faststack.imaging.math_utils import ( _srgb_to_linear, @@ -31,8 +34,6 @@ from faststack.imaging.optional_deps import cv2 -import threading - log = logging.getLogger(__name__) @@ -142,7 +143,7 @@ def _gaussian_blur_float(arr: np.ndarray, radius: float) -> np.ndarray: # Fallback: Use Pillow's GaussianBlur in 'F' mode (float32) per channel # This preserves values > 1.0 (headroom) which is critical for highlight recovery. try: - h, w, c = arr.shape + c = arr.shape[2] blurred_channels = [] # Process each channel independently @@ -708,7 +709,7 @@ def _rotate_float_image( if abs(angle_deg) < 0.01: return img_arr - h, w, c = img_arr.shape + c = img_arr.shape[2] channels = [] for i in range(c): # Convert channel to PIL Float image @@ -722,11 +723,8 @@ def _rotate_float_image( ) channels.append(rot_c) - # Merge back - # Assume all channels rotated to same size - nw, nh = channels[0].size - new_arr = np.stack([np.array(ch) for ch in channels], axis=-1) - return new_arr + # Merge back (all channels rotated to same size) + return np.stack([np.array(ch) for ch in channels], axis=-1) def _apply_edits( self, @@ -741,7 +739,6 @@ def _apply_edits( if edits is None: edits = self.current_edits - is_export = for_export # Alias arr = img_arr @@ -761,7 +758,7 @@ def _apply_edits( if arr.size > 0: sample = arr.reshape(-1)[:2000] s_max = sample.max() - if s_max > 1.0 and s_max <= 255.0: + if 1.0 < s_max <= 255.0: arr /= 255.0 elif s_max <= 1.0: # Double check full array only if sample was small or ambiguous @@ -1329,12 +1326,7 @@ def auto_levels( if img_arr is None: # Fallback for tests or cases where float data isn't initialized yet - if hasattr(self, "_preview_image") and self._preview_image is not None: - img_arr = ( - np.array(self._preview_image.convert("RGB")).astype(np.float32) - / 255.0 - ) - elif self.original_image is not None: + if self.original_image is not None: img_arr = ( np.array(self.original_image.convert("RGB")).astype(np.float32) / 255.0 @@ -1852,8 +1844,6 @@ def _get_sanitized_exif_bytes(self) -> Optional[bytes]: bytes object of EXIF data, or None if sanitization/serialization failed. """ try: - from PIL import Image, ExifTags - exif = None # 1. Try to build an Exif object from raw bytes (best: preserves all tags) @@ -2019,11 +2009,6 @@ def save_image( # Save as 16-bit TIFF using custom writer self._write_tiff_16bit(original_path, final_float) else: - # Check for geometric transforms - rotation = edits_snapshot.get("rotation", 0) - straighten_angle = float(edits_snapshot.get("straighten_angle", 0.0)) - transforms_applied = (rotation != 0) or (abs(straighten_angle) > 0.001) - # Determine EXIF bytes to write exif_bytes = None if self.original_image: @@ -2113,7 +2098,7 @@ def save_image( except Exception as e: log.exception("Failed to save %s: %s", self.current_filepath, e) - raise RuntimeError("Save failed: %s" % str(e)) from e + raise RuntimeError(f"Save failed: {e}") from e def save_image_uint8_levels( self, diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index aa9f58d..55b8bbb 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -1,6 +1,7 @@ """High-performance JPEG decoding using PyTurboJPEG with a Pillow fallback.""" import logging +from io import BytesIO from typing import Optional, Tuple import numpy as np @@ -13,14 +14,14 @@ try: from turbojpeg import TurboJPEG, TJPF_RGB except ImportError: - jpeg_decoder = None + JPEG_DECODER = None TURBO_AVAILABLE = False log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") else: try: - jpeg_decoder = TurboJPEG() + JPEG_DECODER = TurboJPEG() except Exception: - jpeg_decoder = None + JPEG_DECODER = None TURBO_AVAILABLE = False log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") else: @@ -30,7 +31,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: """Decodes JPEG bytes into an RGB numpy array.""" - if TURBO_AVAILABLE and jpeg_decoder: + if TURBO_AVAILABLE and JPEG_DECODER: try: # Decode with proper color space handling (no TJFLAG_FASTDCT) # This ensures proper YCbCr->RGB conversion with correct gamma @@ -38,19 +39,17 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd if fast_dct: # TJFLAG_FASTDCT = 2048 flags |= 2048 - return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) + return JPEG_DECODER.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) except Exception as e: - log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") + log.exception("PyTurboJPEG failed to decode image: %s. Trying Pillow.", e) # Fall through to Pillow fallback # Fallback to Pillow try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") return np.array(img) except Exception as e: - log.exception(f"Pillow also failed to decode image: {e}") + log.exception("Pillow also failed to decode image: %s", e) return None @@ -58,15 +57,15 @@ def decode_jpeg_thumb_rgb( jpeg_bytes: bytes, max_dim: int = 256 ) -> Optional[np.ndarray]: """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" - if TURBO_AVAILABLE and jpeg_decoder: + if TURBO_AVAILABLE and JPEG_DECODER: try: # Get image header to determine dimensions - width, height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + width, height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) # Find the best scaling factor scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim) - decoded = jpeg_decoder.decode( + decoded = JPEG_DECODER.decode( jpeg_bytes, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, @@ -79,18 +78,16 @@ def decode_jpeg_thumb_rgb( return decoded except Exception as e: log.exception( - f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow." + "PyTurboJPEG failed to decode thumbnail: %s. Trying Pillow.", e ) # Fallback to Pillow try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) img.thumbnail((max_dim, max_dim)) return np.array(img.convert("RGB")) except Exception as e: - log.exception(f"Pillow also failed to decode thumbnail: {e}") + log.exception("Pillow also failed to decode thumbnail: %s", e) return None @@ -98,12 +95,12 @@ def _get_turbojpeg_scaling_factor( width: int, height: int, max_dim: int ) -> Optional[Tuple[int, int]]: """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" - if not TURBO_AVAILABLE or not jpeg_decoder: + if not TURBO_AVAILABLE or not JPEG_DECODER: return None # PyTurboJPEG provides a set of supported scaling factors supported_factors = sorted( - jpeg_decoder.scaling_factors, + JPEG_DECODER.scaling_factors, key=lambda x: x[0] / x[1], reverse=True, ) @@ -123,10 +120,10 @@ def decode_jpeg_resized( if width <= 0 or height <= 0: return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) - if TURBO_AVAILABLE and jpeg_decoder: + if TURBO_AVAILABLE and JPEG_DECODER: try: # Get image header to determine dimensions - img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + img_width, img_height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) # Determine which dimension is the limiting factor if img_width * height > img_height * width: @@ -144,7 +141,7 @@ def decode_jpeg_resized( # TJFLAG_FASTDCT = 2048 flags |= 2048 - decoded = jpeg_decoder.decode( + decoded = JPEG_DECODER.decode( jpeg_bytes, scaling_factor=scale_factor, pixel_format=TJPF_RGB, @@ -153,20 +150,16 @@ def decode_jpeg_resized( # Only use Pillow for final resize if needed if decoded.shape[0] > height or decoded.shape[1] > width: - from io import BytesIO - img = Image.fromarray(decoded) # Use BILINEAR for speed img.thumbnail((width, height), Image.Resampling.BILINEAR) return np.array(img) return decoded except Exception as e: - log.exception(f"PyTurboJPEG failed: {e}") + log.exception("PyTurboJPEG failed: %s", e) # Fallback to Pillow (existing code) try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) if width <= 0 or height <= 0: @@ -185,5 +178,5 @@ def decode_jpeg_resized( img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) except Exception as e: - log.exception(f"Pillow failed to decode and resize image: {e}") + log.exception("Pillow failed to decode and resize image: %s", e) return None diff --git a/faststack/imaging/metadata.py b/faststack/imaging/metadata.py index a736326..35f1cd8 100644 --- a/faststack/imaging/metadata.py +++ b/faststack/imaging/metadata.py @@ -41,21 +41,61 @@ def clean_exif_value(value: Any) -> str: # Camera-style 1/3-stop shutter speed labels (Nikon/Canon convention) _SHUTTER_TABLE = [ - (30.0, "30s"), (25.0, "25s"), (20.0, "20s"), (15.0, "15s"), (13.0, "13s"), - (10.0, "10s"), (8.0, "8s"), (6.0, "6s"), (5.0, "5s"), (4.0, "4s"), - (3.2, "3.2s"), (2.5, "2.5s"), (2.0, "2s"), (1.6, "1.6s"), (1.3, "1.3s"), - (1.0, "1s"), (0.8, "0.8s"), (0.6, "0.6s"), (0.5, "0.5s"), (0.4, "0.4s"), (0.3, "0.3s"), - (1/4, "1/4s"), (1/5, "1/5s"), (1/6, "1/6s"), (1/8, "1/8s"), - (1/10, "1/10s"), (1/13, "1/13s"), (1/15, "1/15s"), - (1/20, "1/20s"), (1/25, "1/25s"), (1/30, "1/30s"), - (1/40, "1/40s"), (1/50, "1/50s"), (1/60, "1/60s"), - (1/80, "1/80s"), (1/100, "1/100s"), (1/125, "1/125s"), - (1/160, "1/160s"), (1/200, "1/200s"), (1/250, "1/250s"), - (1/320, "1/320s"), (1/400, "1/400s"), (1/500, "1/500s"), - (1/640, "1/640s"), (1/800, "1/800s"), (1/1000, "1/1000s"), - (1/1250, "1/1250s"), (1/1600, "1/1600s"), (1/2000, "1/2000s"), - (1/2500, "1/2500s"), (1/3200, "1/3200s"), (1/4000, "1/4000s"), - (1/5000, "1/5000s"), (1/6400, "1/6400s"), (1/8000, "1/8000s"), + (30.0, "30s"), + (25.0, "25s"), + (20.0, "20s"), + (15.0, "15s"), + (13.0, "13s"), + (10.0, "10s"), + (8.0, "8s"), + (6.0, "6s"), + (5.0, "5s"), + (4.0, "4s"), + (3.2, "3.2s"), + (2.5, "2.5s"), + (2.0, "2s"), + (1.6, "1.6s"), + (1.3, "1.3s"), + (1.0, "1s"), + (0.8, "0.8s"), + (0.6, "0.6s"), + (0.5, "0.5s"), + (0.4, "0.4s"), + (0.3, "0.3s"), + (1 / 4, "1/4s"), + (1 / 5, "1/5s"), + (1 / 6, "1/6s"), + (1 / 8, "1/8s"), + (1 / 10, "1/10s"), + (1 / 13, "1/13s"), + (1 / 15, "1/15s"), + (1 / 20, "1/20s"), + (1 / 25, "1/25s"), + (1 / 30, "1/30s"), + (1 / 40, "1/40s"), + (1 / 50, "1/50s"), + (1 / 60, "1/60s"), + (1 / 80, "1/80s"), + (1 / 100, "1/100s"), + (1 / 125, "1/125s"), + (1 / 160, "1/160s"), + (1 / 200, "1/200s"), + (1 / 250, "1/250s"), + (1 / 320, "1/320s"), + (1 / 400, "1/400s"), + (1 / 500, "1/500s"), + (1 / 640, "1/640s"), + (1 / 800, "1/800s"), + (1 / 1000, "1/1000s"), + (1 / 1250, "1/1250s"), + (1 / 1600, "1/1600s"), + (1 / 2000, "1/2000s"), + (1 / 2500, "1/2500s"), + (1 / 3200, "1/3200s"), + (1 / 4000, "1/4000s"), + (1 / 5000, "1/5000s"), + (1 / 6400, "1/6400s"), + (1 / 8000, "1/8000s"), ] _SHUTTER_SECONDS = [t for (t, _) in _SHUTTER_TABLE] _SHUTTER_LOG_SECONDS = [math.log(t) for t in _SHUTTER_SECONDS] @@ -132,7 +172,15 @@ def get_exif_brief(path: Union[str, Path]) -> str: Supported formats: JPEG, TIFF, HEIF. """ path = Path(path) - if path.suffix.lower() not in {".jpg", ".jpeg", ".jpe", ".tif", ".tiff", ".heif", ".heic"}: + if path.suffix.lower() not in { + ".jpg", + ".jpeg", + ".jpe", + ".tif", + ".tiff", + ".heif", + ".heic", + }: return "" if not path.exists(): return "" @@ -142,8 +190,10 @@ def get_exif_brief(path: Union[str, Path]) -> str: exif = img.getexif() # getexif() nests EXIF sub-IFD tags; merge them for flat access # Read them while file is open to avoid "I/O on closed file" - exif_ifd = dict(exif.get_ifd(ExifTags.IFD.Exif) if hasattr(ExifTags, "IFD") else {}) - + exif_ifd = dict( + exif.get_ifd(ExifTags.IFD.Exif) if hasattr(ExifTags, "IFD") else {} + ) + if not exif: return "" except Exception: @@ -218,9 +268,11 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: exif_obj = img.getexif() if not exif_obj: return {"summary": {}, "full": {}} - + # Merge sub-IFD tags (ISO, Lens, etc.) - exif_ifd = dict(exif_obj.get_ifd(ExifTags.IFD.Exif) if hasattr(ExifTags, "IFD") else {}) + exif_ifd = dict( + exif_obj.get_ifd(ExifTags.IFD.Exif) if hasattr(ExifTags, "IFD") else {} + ) # Fetch GPS sub-IFD while image is still open (Pillow ≥8.2 # stores GPSInfo as an integer IFD offset, not a dict) @@ -229,7 +281,7 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: # Normalize to a dict for consistency exif = dict(exif_obj) exif.update(exif_ifd) - + except Exception as e: log.warning(f"Failed to extract EXIF from {path}: {e}") return {"summary": {}, "full": {}} diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index 8db3f1f..8218bb5 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -368,7 +368,9 @@ def submit_task( return None requested_path = ( - override_path if override_path is not None else self.image_files[index].path + override_path + if override_path is not None + else self.image_files[index].path ) # We track by index. If we already have a job for this index, @@ -497,7 +499,11 @@ def _decode_and_cache( 274, 1 ) except Exception: - log.debug("Failed to read EXIF from mmap for %s", target_path, exc_info=True) + log.debug( + "Failed to read EXIF from mmap for %s", + target_path, + exc_info=True, + ) except Exception as e: log.warning( "Decode failed (ICC path) index=%d path=%s: %s", @@ -599,7 +605,11 @@ def _decode_and_cache( with PILImage.open(mmapped) as pil_img: orientation = pil_img.getexif().get(274, 1) except Exception: - log.debug("Failed to read EXIF from mmap for %s", target_path, exc_info=True) + log.debug( + "Failed to read EXIF from mmap for %s", + target_path, + exc_info=True, + ) except Exception: buffer = None diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index 98a2f6f..c1adcf0 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -120,15 +120,23 @@ def save(self): self.start_watcher() @overload - def get_metadata(self, image_stem: str, *, create: Literal[True] = True) -> EntryMetadata: ... + def get_metadata( + self, image_stem: str, *, create: Literal[True] = True + ) -> EntryMetadata: ... @overload - def get_metadata(self, image_stem: str, *, create: Literal[False]) -> Optional[EntryMetadata]: ... + def get_metadata( + self, image_stem: str, *, create: Literal[False] + ) -> Optional[EntryMetadata]: ... @overload - def get_metadata(self, image_stem: str, *, create: bool) -> Optional[EntryMetadata]: ... + def get_metadata( + self, image_stem: str, *, create: bool + ) -> Optional[EntryMetadata]: ... - def get_metadata(self, image_stem: str, *, create: bool = True) -> Optional[EntryMetadata]: + def get_metadata( + self, image_stem: str, *, create: bool = True + ) -> Optional[EntryMetadata]: """Get metadata for an image, optionally creating a persistent entry. When create=True (default), always returns an EntryMetadata (creating diff --git a/faststack/io/variants.py b/faststack/io/variants.py index aa44b9c..1be67ee 100644 --- a/faststack/io/variants.py +++ b/faststack/io/variants.py @@ -240,5 +240,3 @@ def build_badge_list(group: VariantGroup) -> List[Dict]: ) return badges - - diff --git a/faststack/models.py b/faststack/models.py index 1207245..e36d322 100644 --- a/faststack/models.py +++ b/faststack/models.py @@ -2,7 +2,7 @@ import dataclasses from pathlib import Path -from typing import Optional, Dict, List +from typing import Any, Optional, Dict, List @dataclasses.dataclass @@ -33,6 +33,7 @@ def raw_path(self) -> Optional[Path]: @property def has_raw(self) -> bool: + """Returns True if a RAW file is associated with this image.""" return self.raw_pair is not None @property @@ -42,6 +43,7 @@ def working_tif_path(self) -> Path: @property def has_working_tif(self) -> bool: + """Returns True if a valid working TIFF file exists on disk.""" try: return ( self.working_tif_path.exists() @@ -58,9 +60,10 @@ def developed_jpg_path(self) -> Path: return self.path.with_name(f"{self.path.stem}-developed.jpg") +# pylint: disable=too-many-instance-attributes @dataclasses.dataclass class EntryMetadata: - """Sidecar metadata for a single image entry.""" + """Flat sidecar metadata for a single image entry (mirrors JSON schema).""" stack_id: Optional[int] = None stacked: bool = False @@ -94,7 +97,8 @@ class DecodedImage: width: int height: int bytes_per_line: int - format: object # QImage.Format + format: Any # QImage.Format def __sizeof__(self) -> int: + """Returns the size of the image buffer in bytes.""" return self.buffer.nbytes diff --git a/faststack/tests/benchmark_decode_bilinear.py b/faststack/tests/benchmark_decode_bilinear.py index 97c76ef..6b5b069 100644 --- a/faststack/tests/benchmark_decode_bilinear.py +++ b/faststack/tests/benchmark_decode_bilinear.py @@ -6,7 +6,7 @@ decode_jpeg_rgb, _get_turbojpeg_scaling_factor, TURBO_AVAILABLE, - jpeg_decoder, + JPEG_DECODER, TJPF_RGB, ) @@ -16,10 +16,10 @@ def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): if width == 0 or height == 0: return decode_jpeg_rgb(jpeg_bytes) - if TURBO_AVAILABLE and jpeg_decoder: + if TURBO_AVAILABLE and JPEG_DECODER: try: # Get image header to determine dimensions - img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + img_width, img_height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) # Determine which dimension is the limiting factor if img_width * height > img_height * width: @@ -30,7 +30,7 @@ def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) if scale_factor: - decoded = jpeg_decoder.decode( + decoded = JPEG_DECODER.decode( jpeg_bytes, scaling_factor=scale_factor, pixel_format=TJPF_RGB, diff --git a/faststack/tests/debug_exif.py b/faststack/tests/debug_exif.py index 8c41427..03ed9c5 100644 --- a/faststack/tests/debug_exif.py +++ b/faststack/tests/debug_exif.py @@ -31,7 +31,7 @@ def test_editor_full_workflow_exif(self): try: tmp_path = Path(tmp_dir) img_path = tmp_path / "test_exif_workflow.jpg" - + # 1. Create source file with EXIF Orientation 6 img = Image.new("RGB", (100, 50), color="blue") exif = img.getexif() @@ -41,21 +41,21 @@ def test_editor_full_workflow_exif(self): # 2. Load into editor editor = ImageEditor() self.assertTrue(editor.load_image(str(img_path))) - + # 3. Verify editor state self.assertIsNotNone(editor.float_image) - # ImageEditor.load_image bakes orientation, so original (100x50) [WxH] orient 6 [90 CW] + # ImageEditor.load_image bakes orientation, so original (100x50) [WxH] orient 6 [90 CW] # becomes (50x100) [WxH]. In NumPy (H, W, C), this is (100, 50, 3). - self.assertEqual(editor.float_image.shape[0], 100) # Height + self.assertEqual(editor.float_image.shape[0], 100) # Height self.assertEqual(editor.float_image.shape[1], 50) # Width - + # 4. Apply edit and save editor.set_edit_param("brightness", 0.5) # This triggers backup and save saved = editor.save_image() self.assertIsNotNone(saved) saved_path, _ = saved - + # 5. Verify saved file has sterilized orientation with Image.open(saved_path) as out_img: out_exif = out_img.getexif() diff --git a/faststack/tests/test_cache.py b/faststack/tests/test_cache.py index 6ede503..6e0c7cf 100644 --- a/faststack/tests/test_cache.py +++ b/faststack/tests/test_cache.py @@ -108,9 +108,7 @@ class MockBuffer: buffer = MockBuffer() # Use SimpleNamespace to build a minimal object that lacks bytes_per_line - item = SimpleNamespace( - buffer=buffer, width=10, height=10 - ) + item = SimpleNamespace(buffer=buffer, width=10, height=10) # size = 10 * 10 * 4 = 400 assert get_decoded_image_size(item) == 400 diff --git a/faststack/tests/test_cache_replacement_callback.py b/faststack/tests/test_cache_replacement_callback.py index f269afb..e70f449 100644 --- a/faststack/tests/test_cache_replacement_callback.py +++ b/faststack/tests/test_cache_replacement_callback.py @@ -36,6 +36,7 @@ def test_replacement_plus_lru_eviction(): cache["a"] = 70 from collections import defaultdict + evicted_map = defaultdict(list) for k, v in evicted: evicted_map[k].append(v) diff --git a/faststack/tests/test_deletion_perf_structure.py b/faststack/tests/test_deletion_perf_structure.py index d476cc3..2b195d6 100644 --- a/faststack/tests/test_deletion_perf_structure.py +++ b/faststack/tests/test_deletion_perf_structure.py @@ -50,9 +50,7 @@ def test_delete_uses_targeted_eviction(mock_app): app = mock_app # Setup data - img1 = ImageFile( - Path("c:/images/img1.jpg"), raw_pair=Path("c:/images/img1.CR2") - ) + img1 = ImageFile(Path("c:/images/img1.jpg"), raw_pair=Path("c:/images/img1.CR2")) img2 = ImageFile(Path("c:/images/img2.jpg")) app.image_files = [img1, img2] diff --git a/faststack/tests/test_metadata.py b/faststack/tests/test_metadata.py index 7904edf..530627e 100644 --- a/faststack/tests/test_metadata.py +++ b/faststack/tests/test_metadata.py @@ -118,20 +118,24 @@ def test_get_exif_data_real_file_not_found(self): def test_get_exif_brief_failure(self, mock_open, mock_log, mock_exists): # Setup mock image mock_img = MagicMock() - + # Use MockExif similar to other tests class MockExif(dict): - def get_ifd(self, tag): return {} - + def get_ifd(self, tag): + return {} + # 36867 is DateTimeOriginal (0x9003) mock_img.getexif.return_value = MockExif({36867: "2023:01:01 12:00:00"}) - + mock_open.return_value.__enter__.return_value = mock_img - + # Patch clean_exif_value to raise Exception - with patch("faststack.imaging.metadata.clean_exif_value", side_effect=ValueError("Forced Error")): + with patch( + "faststack.imaging.metadata.clean_exif_value", + side_effect=ValueError("Forced Error"), + ): get_exif_brief(Path("dummy.jpg")) - + # Verify log.error was called mock_log.error.assert_called() call_args = mock_log.error.call_args[0] diff --git a/faststack/tests/thumbnail_view/test_provider_logic.py b/faststack/tests/thumbnail_view/test_provider_logic.py index 5527b81..09dfe69 100644 --- a/faststack/tests/thumbnail_view/test_provider_logic.py +++ b/faststack/tests/thumbnail_view/test_provider_logic.py @@ -4,7 +4,11 @@ from pathlib import Path from unittest.mock import MagicMock -from faststack.thumbnail_view.provider import ThumbnailProvider, PLACEHOLDER_COLOR, ERROR_COLOR +from faststack.thumbnail_view.provider import ( + ThumbnailProvider, + PLACEHOLDER_COLOR, + ERROR_COLOR, +) from faststack.thumbnail_view.prefetcher import ThumbnailCache from PySide6.QtCore import QSize diff --git a/faststack/tests/thumbnail_view/test_reason_tracking.py b/faststack/tests/thumbnail_view/test_reason_tracking.py index 7f6edce..6aae26c 100644 --- a/faststack/tests/thumbnail_view/test_reason_tracking.py +++ b/faststack/tests/thumbnail_view/test_reason_tracking.py @@ -98,7 +98,7 @@ def test_refresh_sets_deferred_clear(model): from faststack.models import ImageFile with patch("faststack.thumbnail_view.model.find_images") as mock_find: - # Note: This only verifies model._next_source_reason is preserved synchronously + # Note: This only verifies model._next_source_reason is preserved synchronously # after refresh() returns; the deferred clearing via QTimer is not tested here. mock_find.return_value = [ ImageFile(path=Path("/fake/dir/img1.jpg")), diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index e0c1b38..7874d21 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -521,7 +521,7 @@ def put(self, key: str, value: bytes): if key in self._cache: self._current_bytes -= len(self._cache[key]) self._cache.move_to_end(key, last=True) - + self._cache[key] = value self._current_bytes += len(value) diff --git a/faststack/thumbnail_view/provider.py b/faststack/thumbnail_view/provider.py index bdbd83b..d27ccf5 100644 --- a/faststack/thumbnail_view/provider.py +++ b/faststack/thumbnail_view/provider.py @@ -173,12 +173,17 @@ def _parse_id(self, id_str: str) -> ParsedId: path_hash = parts[1] mtime_ns = int(parts[2]) return ParsedId( - id_clean, parts, thumb_size, path_hash, mtime_ns, reason, is_folder, True + id_clean, + parts, + thumb_size, + path_hash, + mtime_ns, + reason, + is_folder, + True, ) except (ValueError, IndexError): - return ParsedId( - id_clean, parts, None, None, None, reason, is_folder, False - ) + return ParsedId(id_clean, parts, None, None, None, reason, is_folder, False) def requestImage(self, id_str: str, size: QSize, _requestedSize: QSize) -> QImage: """Request an image for the given ID. @@ -214,7 +219,7 @@ def requestImage(self, id_str: str, size: QSize, _requestedSize: QSize) -> QImag # Deferred logging setup timer = None cache_key = parsed.id_clean - + # Resolve path - we already have path_hash and mtime_ns path = self._path_resolver(parsed.path_hash) if self._path_resolver else None