Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 109 additions & 115 deletions faststack/app.py

Large diffs are not rendered by default.

24 changes: 16 additions & 8 deletions faststack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand All @@ -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():
Expand All @@ -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()

Expand All @@ -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))
Expand Down
18 changes: 12 additions & 6 deletions faststack/deletion_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
"""

import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, List, Optional, Tuple
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 39 additions & 34 deletions faststack/imaging/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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]]):
Expand Down Expand Up @@ -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:
Expand All @@ -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),
)


Expand Down
Loading
Loading