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
9 changes: 5 additions & 4 deletions debug_al.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@

import numpy as np
from PIL import Image
from faststack.imaging.editor import ImageEditor


def debug_run():
editor = ImageEditor()
w, h = 200, 200
arr = np.zeros((h, w, 3), dtype=np.uint8)
arr[:] = 200
arr[0, 0, 0] = 255
img = Image.fromarray(arr, 'RGB')

img = Image.fromarray(arr, "RGB")
editor.original_image = img
editor._preview_image = img

blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1)
print(f"RESULT: p_high={p_high}")


if __name__ == "__main__":
debug_run()
2 changes: 1 addition & 1 deletion faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
QPoint,
QCoreApplication,
)
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox
from PySide6.QtWidgets import QApplication, QFileDialog
from PySide6.QtQml import QQmlApplicationEngine
from PIL import Image

Expand Down
44 changes: 34 additions & 10 deletions faststack/io/deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@

log = logging.getLogger(__name__)


def _mkdir(path: Path) -> None:
"""Helper for mocking Path.mkdir safely."""
path.mkdir(parents=True, exist_ok=True)


def _unlink(path: Path) -> None:
"""Helper for mocking Path.unlink safely."""
path.unlink()


def ensure_recycle_bin_dir(recycle_bin_dir: Path) -> bool:
"""Try to create the recycle bin directory.

Expand All @@ -14,12 +25,13 @@ def ensure_recycle_bin_dir(recycle_bin_dir: Path) -> bool:
False if creation failed (e.g., permission denied).
"""
try:
recycle_bin_dir.mkdir(parents=True, exist_ok=True)
_mkdir(recycle_bin_dir)
return True
except (PermissionError, OSError) as e:
log.error("Failed to create recycle bin directory: %s", e)
log.exception("Failed to create recycle bin directory: %s", e)
return False


def confirm_permanent_delete(image_file, reason: str = "") -> bool:
"""Show a confirmation dialog for permanent deletion of a single image.

Expand All @@ -34,10 +46,22 @@ def confirm_permanent_delete(image_file, reason: str = "") -> bool:
raw_path = image_file.raw_pair

# Build list of files that will be deleted
files_to_delete = [str(jpg_path.name)]
files_to_delete = []

# Handle primary JPG
if jpg_path:
files_to_delete.append(str(jpg_path.name))
else:
log.warning("confirm_permanent_delete called with image_file.path=None")

# Handle RAW pair
if raw_path and raw_path.exists():
files_to_delete.append(str(raw_path.name))

if not files_to_delete:
log.warning("No files to delete found for confirmation.")
return False

file_list = "\n".join(f" • {f}" for f in files_to_delete)

msg_box = QMessageBox()
Expand All @@ -53,16 +77,15 @@ def confirm_permanent_delete(image_file, reason: str = "") -> bool:
f"The following files will be permanently deleted:\n{file_list}"
)

delete_btn = msg_box.addButton(
"Delete Permanently", QMessageBox.DestructiveRole
)
delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.DestructiveRole)
cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole)
msg_box.setDefaultButton(cancel_btn)

msg_box.exec()

return msg_box.clickedButton() == delete_btn


def confirm_batch_permanent_delete(images: list, reason: str = "") -> bool:
"""Show a confirmation dialog for permanent deletion of multiple images.

Expand Down Expand Up @@ -117,6 +140,7 @@ def confirm_batch_permanent_delete(images: list, reason: str = "") -> bool:

return msg_box.clickedButton() == delete_btn


def permanently_delete_image_files(image_file) -> bool:
"""Permanently delete an image and its RAW pair from disk.

Expand All @@ -135,19 +159,19 @@ def permanently_delete_image_files(image_file) -> bool:
# Delete JPG
if jpg_path and jpg_path.exists():
try:
jpg_path.unlink()
_unlink(jpg_path)
log.info("Permanently deleted: %s", jpg_path.name)
deleted_any = True
except OSError as e:
log.error("Failed to permanently delete %s: %s", jpg_path.name, e)
log.exception("Failed to permanently delete %s: %s", jpg_path.name, e)

# Delete RAW if exists
if raw_path and raw_path.exists():
try:
raw_path.unlink()
_unlink(raw_path)
log.info("Permanently deleted: %s", raw_path.name)
deleted_any = True
except OSError as e:
log.error("Failed to permanently delete %s: %s", raw_path.name, e)
log.exception("Failed to permanently delete %s: %s", raw_path.name, e)

return deleted_any
2 changes: 1 addition & 1 deletion faststack/io/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def __init__(self, directory: Path, callback):
self.observer: Optional[Observer] = None # Initialize to None
self.event_handler = ImageDirectoryEventHandler(callback)
self.directory = directory
self.callback = callback # Store callback for new observer
self.callback = callback

def start(self):
"""Starts watching the directory."""
Expand Down
5 changes: 1 addition & 4 deletions faststack/tests/check_imports.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sys
import os
import traceback

# Add current directory to path
sys.path.append(os.getcwd())
Expand All @@ -11,12 +12,10 @@
print("Success faststack.app")
except ImportError as e:
print(f"ImportError faststack.app: {e}")
import traceback

traceback.print_exc()
except Exception as e:
print(f"Non-ImportError during import of faststack.app: {e}")
import traceback

traceback.print_exc()

Expand All @@ -27,11 +26,9 @@
print("Success test_raw_pipeline")
except ImportError as e:
print(f"ImportError test_raw_pipeline: {e}")
import traceback

traceback.print_exc()
except Exception as e:
print(f"Non-ImportError during import of test_raw_pipeline: {e}")
import traceback

traceback.print_exc()
2 changes: 1 addition & 1 deletion faststack/tests/test_config_setters.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def decorator(func):
sys.modules["PIL.Image"] = mock_pil.Image

# Mock numpy
sys.modules["numpy"] = MagicMock()
#sys.modules["numpy"] = MagicMock()

# Mock faststack.config
mock_config_module = MagicMock()
Expand Down
72 changes: 45 additions & 27 deletions faststack/tests/test_permanent_delete.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,94 @@
"""Tests for permanent delete logic in faststack.io.deletion."""

import pytest
from pathlib import Path
from unittest.mock import Mock, patch

# Import the standalone module, avoiding heavy app imports
from faststack.io.deletion import (
ensure_recycle_bin_dir,
confirm_permanent_delete,
permanently_delete_image_files
permanently_delete_image_files,
)


class MockImageFile:
"""Simple mock for ImageFile."""
def __init__(self, jpg_path: Path, raw_path: Path = None):

def __init__(self, jpg_path: Path | None, raw_path: Path | None = None):
self.path = jpg_path
self.raw_pair = raw_path
self.is_video = False


class TestEnsureRecycleBinDir:
def test_creation_success(self, tmp_path):
"""Should return True and create directory when successful."""
recycle_bin = tmp_path / "RecycleBin"
assert not recycle_bin.exists()

result = ensure_recycle_bin_dir(recycle_bin)

assert result is True
assert recycle_bin.exists()
assert recycle_bin.is_dir()

def test_creation_failure(self, tmp_path):
"""Should return False when creation raises PermissionError."""
recycle_bin = tmp_path / "RecycleBin"

with patch.object(Path, "mkdir", side_effect=PermissionError("Mock perm error")):

with patch(
"faststack.io.deletion._mkdir",
side_effect=PermissionError("Mock perm error"),
):
result = ensure_recycle_bin_dir(recycle_bin)
assert result is False


class TestConfirmPermanentDelete:
def test_confirm_yes(self):
"""Should return True when user accepts dialog."""
mock_img = MockImageFile(Path("test.jpg"))

with patch("faststack.io.deletion.QMessageBox") as MockMSG:
instance = MockMSG.return_value
instance.exec.return_value = 0
instance.exec.return_value = 0

mock_delete_btn = Mock(name="DeleteButton")
mock_cancel_btn = Mock(name="CancelButton")

instance.addButton.side_effect = [mock_delete_btn, mock_cancel_btn]
instance.clickedButton.return_value = mock_delete_btn

result = confirm_permanent_delete(mock_img)
assert result is True

def test_confirm_no(self):
"""Should return False when user cancels."""
mock_img = MockImageFile(Path("test.jpg"))

with patch("faststack.io.deletion.QMessageBox") as MockMSG:
instance = MockMSG.return_value
instance.exec.return_value = 0

mock_delete_btn = Mock(name="DeleteButton")
mock_cancel_btn = Mock(name="CancelButton")

instance.addButton.side_effect = [mock_delete_btn, mock_cancel_btn]
instance.clickedButton.return_value = mock_cancel_btn


result = confirm_permanent_delete(mock_img)
assert result is False

def test_confirm_handles_none_path(self):
"""Should return False (and log warning) if image_file.path is None."""
mock_img = MockImageFile(None)

with patch("faststack.io.deletion.log") as mock_log:
# Should return False because files_to_delete will be empty
result = confirm_permanent_delete(mock_img)
assert result is False
assert mock_log.warning.call_count >= 1


class TestPermanentlyDeleteImageFiles:
def test_delete_success(self, tmp_path):
Expand All @@ -80,11 +97,11 @@ def test_delete_success(self, tmp_path):
raw = tmp_path / "img.orf"
jpg.touch()
raw.touch()

img = MockImageFile(jpg, raw)

result = permanently_delete_image_files(img)

assert result is True
assert not jpg.exists()
assert not raw.exists()
Expand All @@ -94,31 +111,32 @@ def test_delete_jpg_only(self, tmp_path):
jpg = tmp_path / "img.jpg"
jpg.touch()
img = MockImageFile(jpg, None)

result = permanently_delete_image_files(img)

assert result is True
assert not jpg.exists()

def test_delete_handles_missing_files(self, tmp_path):
"""Should return False if files don't exist."""
jpg = tmp_path / "missing.jpg"
img = MockImageFile(jpg, None)

result = permanently_delete_image_files(img)

assert result is False

def test_delete_failure_logging(self, tmp_path):
"""Should log errors and return False if deletion fails."""
jpg = tmp_path / "protected.jpg"
jpg.touch()
img = MockImageFile(jpg, None)

with patch.object(Path, "unlink", side_effect=OSError("Protected")):

# Patch the localized helper instead of Path.unlink
with patch("faststack.io.deletion._unlink", side_effect=OSError("Protected")):
with patch("faststack.io.deletion.log") as mock_log:
result = permanently_delete_image_files(img)

assert result is False
assert jpg.exists()
mock_log.error.assert_called()
mock_log.exception.assert_called()
3 changes: 2 additions & 1 deletion faststack/ui/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class ImageProvider(QQuickImageProvider):
def __init__(self, app_controller):
super().__init__(QQuickImageProvider.ImageType.Image)
self.app_controller = app_controller
self._app_controller = app_controller # Backward compatibility alias
self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888)
self.placeholder.fill(Qt.GlobalColor.darkGray)
# Keepalive queue to prevent GC of buffers currently in use by QImage
Expand Down Expand Up @@ -137,7 +138,6 @@ class UIState(QObject):
metadataChanged = Signal()
themeChanged = Signal()
preloadingStateChanged = Signal()
preloadingStateChanged = Signal()
preloadProgressChanged = Signal()

# Recycle Bin Signals
Expand Down Expand Up @@ -214,6 +214,7 @@ class UIState(QObject):
def __init__(self, app_controller):
super().__init__()
self.app_controller = app_controller
self._app_controller = app_controller # Backward compatibility alias
self._is_preloading = False
self._preload_progress = 0
# 1 = light, 0 = dark (controller will overwrite this on startup)
Expand Down
3 changes: 1 addition & 2 deletions inspect_app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@

from faststack.app import AppController
import inspect

methods = inspect.getmembers(AppController, predicate=inspect.isfunction)
print("Methods found:")
found = False
for name, _ in methods:
if 'auto_level' in name:
if "auto_level" in name:
print(f" {name}")
found = True

Expand Down
Loading