Skip to content

Add “Jump to Last Uploaded”, prioritize thumbnails, and improve shutdown safety#42

Merged
AlanRockefeller merged 16 commits intomainfrom
test
Feb 16, 2026
Merged

Add “Jump to Last Uploaded”, prioritize thumbnails, and improve shutdown safety#42
AlanRockefeller merged 16 commits intomainfrom
test

Conversation

@AlanRockefeller
Copy link
Copy Markdown
Owner

@AlanRockefeller AlanRockefeller commented Feb 14, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Batch Auto Levels processing with real-time progress display.
    • Introduced image favoriting system with visual indicators.
    • Added flag-based filtering (Uploaded, Stacked, Edited, Restacked, Favorite).
    • Added "Jump to Last Uploaded" shortcut (Alt+U) and batch actions for favorites.
    • Split "Run Stacks" into RAW and JPG options for flexibility.
  • Bug Fixes

    • Improved delete operations with optimistic UI updates and undo support.
    • Enhanced recycle bin dialog with expandable details section.
    • Improved EXIF orientation handling and image sorting consistency.
  • Performance

    • Optimized thumbnail rendering and cache management.
    • Faster level-based edits through optimized processing paths.

…en no linear-space edits are active (WB/exposure/highlights/shadows/clarity/texture/sharpness), and clamp export output to [0,1] on that path.

Save performance: Avoid float_image.copy() during export when the edit set guarantees the pipeline won’t mutate the input buffer.

Load performance: Apply EXIF orientation on the 8-bit Pillow path before float conversion (rotate uint8), and only rotate the float buffer on the 16-bit OpenCV path.

Logging/robustness: Switch warnings/errors to lazy log formatting and improve load/save diagnostics.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 14, 2026

Walkthrough

This PR delivers version 1.5.8 with significant enhancements: background deletion handling via typed deletion data structures and priority executors, favorites and flag-based filtering (uploaded, stacked, edited, restacked), batch auto-levels processing with UI progress tracking, improved cache thread-safety and path-based eviction, optimized fast-path image saving for level-only edits, daemon executor utilities for safe background threading, and extensive test coverage. Multiple debug/reproduction scripts are removed, and core modules like ImageEditor, ByteLRUCache, and ThumbnailModel are substantially refactored for robustness and performance.

Changes

Cohort / File(s) Summary
Version & Documentation
README.md, ChangeLog.md, pyproject.toml
Version bumped from 1.5.4 to 1.5.8; changelog entries added for v1.5.8 and v1.5.7 with feature/stability enhancements.
.gitignore
.gitignore
Consolidated and broadened ignore patterns: added var/, **/*fail*.txt, **/*final*.txt, temp_venv/, and refactored Python/test artifact patterns; removed explicit documentation files and metadata directories.
Deletion System (Types & Data Structures)
faststack/deletion_types.py
New module introducing typed deletion pipeline: DeletionErrorCodes enum, DeleteJob, DeleteRecord, DeleteWarning, DeleteFailure, and DeleteResult dataclass with from_worker_dict parser for background worker results.
Deletion Tests
faststack/tests/test_delete_worker_*.py, faststack/tests/test_deletion_*.py, faststack/tests/test_handle_*.py
Comprehensive deletion workflow tests: edge cases (invalid shapes, permissions, cancellation), integration scenarios (recycle/rollback), result handling, optimistic UI updates, and undo semantics.
Imaging Cache & Thread-Safety
faststack/imaging/cache.py
Added re-entrant locking, tombstone-based invalidation for deleted items, thread-safe accessors (__getitem__, __contains__, get), targeted eviction via evict_paths, and normalized path handling (backslash → forward slash).
Image Editor (Fast Path & Preview)
faststack/imaging/editor.py
Introduced preview-only load mode, EXIF orientation pre-rotation on PIL, cached uint8 LUT, static methods _edits_skip_linear and _edits_can_share_input, new save_image_uint8_levels fast-save for level-only edits, improved EXIF sanitization, and parameterized logging.
Prefetcher & Decoding
faststack/imaging/prefetch.py
Refactored ICC/EXIF handling with dedicated orientation application, daemon-based thread pool via create_daemon_threadpool_executor, cleanup callback registration on futures, improved cancellation/shutdown semantics with cancel_futures=True, and resilient ICC profile reading.
Executor Utilities
faststack/util/executors.py
New module providing create_daemon_threadpool_executor (daemon worker threads) and create_priority_executor returning PriorityExecutor (priority-queue-based task scheduling with LIFO within same priority and explicit shutdown control).
Image Sorting & Path Utilities
faststack/io/indexer.py, faststack/io/utils.py, faststack/io/watcher.py
New image_sort_key utility for canonical 4-tuple sorting; added sort_name_cf to ImageFile for developed image adjacency; new faststack/io/utils.py with normalize_path_key and compute_path_hash; watcher adds backup file filtering via regex and explicit ignore path logic.
Models (Favorites & Sorting)
faststack/models.py
Added sort_name_cf and favorite fields to ImageFile and EntryMetadata respectively for sorting and favorites persistence.
Thumbnail System (Grid & Model)
faststack/thumbnail_view/model.py, faststack/thumbnail_view/prefetcher.py, faststack/thumbnail_view/provider.py
Complete redesign: ThumbnailModel and ThumbnailEntry classes with filtering (text & flag-based), role-based data model, folder/image population via refresh_from_controller, favorites support, path-hash integration via compute_path_hash, and removal by path; ThumbnailPrefetcher adds priority-based submit and uses centralized path hashing.
QML UI Enhancements
faststack/qml/Main.qml, faststack/qml/FilterDialog.qml, faststack/qml/ThumbnailTile.qml, faststack/qml/ThumbnailGridView.qml, faststack/qml/BatchProgressDialog.qml
Added batch auto-levels action and new dialogs; expanded filter dialog with flag checkboxes (Uploaded, Stacked, Edited, Restacked, Favorite); batch progress dialog with status and cancel button; favorite badge ("F") in thumbnail tiles; updated filter handling to pass both string and flags.
UI State & Key Bindings
faststack/ui/provider.py, faststack/ui/keystrokes.py
UIState gains batch auto-levels properties/slots (batchAutoLevelsCurrent/Total/Active, batchAutoLevels, cancelBatchAutoLevels), favorites API (isFavorite, toggle_favorite), batch helpers (addFavoritesToBatch, addUploadedToBatch, jumpToLastUploaded), enhanced filter/launch APIs; keybindings add F for toggle favorite and Alt+U for jump to last uploaded.
AppController Deletion & Batch
faststack/app.py (referenced in tests)
Implied changes to support background deletion workers, asynchronous result handling, optimistic UI updates, batch auto-levels processing with progress signals, and integration with new deletion types/executors.
Test Infrastructure
faststack/tests/conftest.py
Added pytest_configure to enable faulthandler and SIGUSR2 signal handling for debugging multi-threaded test failures.
Editor & Cache Tests
faststack/tests/test_editor_*.py, faststack/tests/test_skip_linear.py, faststack/tests/test_deletion_perf_structure.py
New tests for fast-path saves, skip-linear/no-copy optimizations, error handling, EXIF orientation, cache eviction behavior, and targeted path-based invalidation.
Executor & Threading Tests
faststack/tests/test_executor_shutdown.py, faststack/tests/test_executors.py, faststack/tests/test_prespawn_strategy.py, faststack/tests/repro_*.py
Tests for priority executor behavior, daemon threadpool creation, shutdown semantics, future cleanup, and daemon worker context inheritance.
Thumbnail & Grid Tests
faststack/tests/thumbnail_view/test_*.py, faststack/tests/test_thumbnail_*.py
Tests for thumbnail model filtering, favorites, entry mapping, refresh behavior, prefetcher priority scheduling, and thumbnail ready signal emission.
Delete & Recycle Tests
faststack/tests/test_*delete*.py, faststack/tests/test_recycle_*.py, faststack/tests/test_loupe_delete.py, faststack/tests/test_reactive_delete.py, faststack/tests/test_handle_failures*.py
Extensive testing of optimistic deletion flows, async completion, undo/rollback semantics, recycle bin tracking/restoration, permanent delete fallback, and failure handling with typed results.
Filter & Refresh Tests
faststack/tests/test_config_setters.py, faststack/tests/test_refresh_*.py, faststack/tests/test_sidecar.py
Updated config setter tests with per-test mocking; new refresh optimization and crash regression tests; sidecar tests for favorite flag toggle/persistence.
Helicon & Jump Tests
faststack/tests/test_helicon_launch.py, faststack/tests/test_jump_to_last_uploaded.py
New tests validating launch_helicon RAW/JPG preference logic and jump_to_last_uploaded navigation with uploaded image detection.
UI State & Model Tests
faststack/tests/test_ui_state_recycle.py, faststack/tests/test_generation_aware_preview.py
Tests for recycle bin detailed text formatting and debug cache initialization in mocked controllers.
Removed Debug/Test Scripts
debug_al.py, faststack.json, repro_*.py, reproduce_*.py, verify_fix*.py, inspect_app.py, run_app.py, test_*.py (root-level)
Deleted standalone debug scripts, reproduction cases, inspection utilities, and old test harnesses to clean up repository.
Misc Test Files
faststack/reactive_test_output.txt, faststack/tests/debug_*.py, faststack/tests/test_new_features.py, faststack/tests/test_raw_pipeline.py
Test output snapshots and helper test modules for app initialization, editor error scenarios, EXIF compatibility, and raw image pipeline validation; updated fixtures and test patterns across multiple test files.

Sequence Diagrams

sequenceDiagram
    participant User as User
    participant UI as QML UI
    participant UIState as UIState (Provider)
    participant Controller as AppController
    participant Editor as ImageEditor
    participant Cache as Cache/<br/>Thumbnail

    User->>UI: Click "Add to Batch" / "Run Batch Auto Levels"
    UI->>UIState: batchAutoLevels()
    UIState->>Controller: batch_auto_levels()
    Controller->>Controller: Spawn deletion worker<br/>thread for batch
    Controller->>UIState: Emit batchAutoLevelsProgress<br/>(current, total)
    UIState->>UI: Update batchAutoLevelsCurrent
    UI->>UI: Update progress bar
    loop For each image in batch
        Controller->>Editor: auto_levels(threshold_percent)
        Editor->>Editor: Compute histogram,<br/>apply levels
        Editor->>Cache: Invalidate affected<br/>thumbnail entries
        Controller->>UIState: Emit progress(current++, total)
        UIState->>UI: Progress signal
    end
    Controller->>UIState: Emit batchAutoLevelsFinished
    UIState->>UI: Set batchAutoLevelsActive=false
    UI->>UI: Close progress dialog,<br/>refresh thumbnails
Loading
sequenceDiagram
    participant User as User
    participant UI as QML UI
    participant Controller as AppController
    participant Worker as Delete Worker<br/>(Thread)
    participant Cache as ByteLRUCache
    participant Executor as Priority Executor

    User->>UI: Delete image
    UI->>Controller: delete_current_image()
    Controller->>Controller: Optimistic: remove from<br/>image_files immediately
    Controller->>Cache: evict_paths([image_path])
    Controller->>UI: Emit gridRefresh,<br/>sync_ui_state
    Controller->>Executor: submit(_delete_worker,<br/>job, priority=HIGH)
    Executor->>Worker: Execute in background thread
    Worker->>Worker: Move to recycle bin /<br/>handle failures
    Worker->>Worker: Return result dict<br/>(status, successes,<br/>warnings, failures)
    Worker-->>Executor: Future resolved
    Controller->>Controller: _on_delete_finished(future)
    alt Recycle failed
        Controller->>Controller: _handle_delete_failures()<br/>prompt for permanent delete
        Controller->>UI: Show permanent delete<br/>confirmation dialog
    else Success / Partial
        Controller->>Controller: Update undo history<br/>with completed job
    end
    UI->>UI: Reflect final state<br/>(restored or deleted)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Fix some minor bugs #33: Overlapping modifications to imaging/exif/orientation, prefetch/executor utilities, and editor functions (EXIF handling, orientation application).
  • Performance improvements, modernize Settings dialog #30: Directly related with overlapping changes to imaging cache/prefetch, editor auto-levels API, executor factories, thumbnail subsystem, and path-hash utilities.
  • Version 0.1 #1: Related as foundational PR introducing ByteLRUCache, Prefetcher, and util/executors modules that this PR substantially refactors and extends.
🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (35 files):

⚔️ .gitignore (content)
⚔️ ChangeLog.md (content)
⚔️ README.md (content)
⚔️ faststack/app.py (content)
⚔️ faststack/imaging/cache.py (content)
⚔️ faststack/imaging/editor.py (content)
⚔️ faststack/imaging/prefetch.py (content)
⚔️ faststack/io/indexer.py (content)
⚔️ faststack/io/watcher.py (content)
⚔️ faststack/models.py (content)
⚔️ faststack/qml/FilterDialog.qml (content)
⚔️ faststack/qml/Main.qml (content)
⚔️ faststack/qml/ThumbnailGridView.qml (content)
⚔️ faststack/qml/ThumbnailTile.qml (content)
⚔️ faststack/result.txt (content)
⚔️ faststack/tests/test_config_setters.py (content)
⚔️ faststack/tests/test_deletion_unification.py (content)
⚔️ faststack/tests/test_editor_error_handling.py (content)
⚔️ faststack/tests/test_exif_compat.py (content)
⚔️ faststack/tests/test_exif_orientation.py (content)
⚔️ faststack/tests/test_generation_aware_preview.py (content)
⚔️ faststack/tests/test_loupe_delete.py (content)
⚔️ faststack/tests/test_new_features.py (content)
⚔️ faststack/tests/test_raw_pipeline.py (content)
⚔️ faststack/tests/test_reactive_delete.py (content)
⚔️ faststack/tests/test_recycle_bin_tracking.py (content)
⚔️ faststack/tests/test_sidecar.py (content)
⚔️ faststack/tests/thumbnail_view/test_model.py (content)
⚔️ faststack/tests/thumbnail_view/test_prefetcher.py (content)
⚔️ faststack/thumbnail_view/model.py (content)
⚔️ faststack/thumbnail_view/prefetcher.py (content)
⚔️ faststack/thumbnail_view/provider.py (content)
⚔️ faststack/ui/keystrokes.py (content)
⚔️ faststack/ui/provider.py (content)
⚔️ pyproject.toml (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately reflects the main changes: adding 'Jump to Last Uploaded' functionality, implementing thumbnail prioritization, and enhancing shutdown safety—all clearly visible across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch test
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch test
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
faststack/imaging/cache.py (1)

87-101: ⚠️ Potential issue | 🟡 Minor

on_evict callback may run under lock when popitem is triggered by __setitem__.

When super().__setitem__() (line 69) needs to make room, it calls popitem(). The callback runs after releasing popitem's with self._lock block, but still inside __setitem__'s outer lock context (line 49). So the intent to run the callback outside the lock only holds for direct popitem() calls, not for eviction-triggered ones.

This is unlikely to deadlock (RLock), but if on_evict ever does anything heavyweight or acquires other locks, it could cause contention. Worth a comment noting this subtlety.

🤖 Fix all issues with AI agents
In `@faststack/imaging/editor.py`:
- Around line 1895-1902: The method _ensure_float_image accesses and mutates
shared state (self.float_image and reads self.original_image) without holding
self._lock, causing races with the preview worker and callers like save_image;
fix this by acquiring self._lock around the check-and-assign sequence in
_ensure_float_image (i.e., wrap the early-return check, the original_image None
check/raise, and the conversion/assignment to self.float_image inside a critical
section using self._lock) so all reads/writes of these attributes are protected.
- Around line 1930-1939: The save_image method must acquire the instance lock
before reading or validating shared state: take self._lock (following the
pattern in get_preview_data_cached()) then call _ensure_float_image(), snapshot
self.current_edits and evaluate _edits_can_share_input(self.current_edits) while
holding the lock so the no-copy decision is race-free; set a local variable for
whether to copy and a local reference to self.float_image (or a copy if needed),
release the lock, and only then call _apply_edits(final_source,
for_export=True). Ensure you reference and protect current_edits and
float_image, and keep other methods like set_edit_param unchanged.

In `@faststack/reactive_test_output.txt`:
- Around line 1-15: The committed test output file
faststack/reactive_test_output.txt should be removed from the PR and excluded
from future commits; delete the file from the branch (or revert its addition)
and add an ignore rule such as *test_output.txt (or reactive_test_output.txt) to
.gitignore so similar snapshot/debug artifacts are not committed again, then run
git rm --cached if you need to stop tracking an already committed file before
committing the updated .gitignore.

In `@faststack/test_executors.py`:
- Around line 1-54: The file is a debug/reproduction script
(test_priority_executor) that misuses create_priority_executor (aliasing the
factory as PriorityExecutor), lacks pytest assertions, uses flaky sleep-based
synchronization, duplicates existing tests, and lives in the wrong directory;
fix by either removing this file entirely (and add **/test_executors.py to
.gitignore if it’s a local scratch file) OR convert it into a proper pytest test
under faststack/tests/ by calling the factory create_priority_executor(...)
correctly (don’t alias it as a class), replace print/if checks with assert
statements, replace time.sleep-based waits with synchronization on returned
Future objects (or use executor.shutdown(wait=True) and
future.result(timeout=...)) to deterministically wait for completion, and ensure
shutdown/cancel behavior is asserted via Future.cancelled(); reference
create_priority_executor, test_priority_executor, executor.submit, and
executor.shutdown when making changes.

In `@faststack/tests/debug_editor_error.py`:
- Around line 12-41: The test TestDebugError.test_debug_save_image is
ineffective: set up editor.original_image (e.g., assign a PIL Image or
MagicMock) so save_image proceeds past the early-return, patch Image.fromarray
at the lookup site faststack.imaging.editor.Image.fromarray (not
PIL.Image.fromarray), keep the create_backup_file patch and have the mocked
image's save raise PermissionError, remove all debug print statements, and
replace prints with a proper assertion using self.assertRaises(RuntimeError,
editor.save_image) (or with self.assertRaises in a context manager) to verify
save_image raises the expected RuntimeError.

In `@faststack/tests/repro_futures_cleanup.py`:
- Around line 18-20: The test setup erroneously assigns to the read-only
Path.suffix in the loop over image_files (created via
MagicMock(path=Path(...))), which will raise AttributeError; remove the for-loop
that does "for img in image_files: img.path.suffix = '.jpg'" (or replace it by
making img.path a MagicMock if you really need assignable attributes), leaving
image_files = [MagicMock(path=Path(f"test_{i}.jpg")) for i in range(10)] as the
correct setup for tests that expect ".jpg" suffix.

In `@faststack/tests/test_handle_failures_isolated.py`:
- Around line 25-85: The test copies production logic from
AppController._handle_delete_failures into
MockController._handle_delete_failures causing duplication and potential drift;
instead bind the real method onto the mock like test_handle_failures.py does
using AppController._handle_delete_failures.__get__(mock, MockController) so the
test uses the production implementation, and patch any external UI prompts
(confirm_permanent_delete or confirm_batch_permanent_delete) via a stub/mock so
the method can run in tests without user interaction.

In `@faststack/tests/test_refresh_crash.py`:
- Around line 6-19: The patches in the model fixture currently exit before tests
run because the with-block ends prior to returning the fixture; change the
fixture so the patch context stays active for the test run by yielding the model
from inside the patch context (or by creating patchers with patch(...) and
starting them before constructing the ThumbnailModel and stopping them after
yielding). Specifically update the fixture named model so the patch context that
wraps patch('faststack.thumbnail_view.model.find_images'),
patch('faststack.thumbnail_view.model.count_images_in_folder'), and
patch('faststack.thumbnail_view.model.read_folder_stats') remains active across
the yield, ensuring calls from ThumbnailModel.refresh(),
refresh_from_controller(), and _add_folders_to_entries() use the mocks during
test execution.

In `@faststack/tests/test_thumbnail_ready_emits_datachanged.py`:
- Around line 12-35: The test creates a ThumbnailModel (which subclasses
QAbstractListModel) before a Qt application exists, so add a session-scoped qapp
fixture that ensures a QCoreApplication instance is available (create one if
QCoreApplication.instance() is None) and then accept qapp as a parameter to the
existing thumbnail_model fixture (change def thumbnail_model(): to def
thumbnail_model(qapp):) so ThumbnailModel initialization and its signal/slot
connections occur with a valid QCoreApplication; reference ThumbnailModel,
thumbnail_model fixture, and QCoreApplication/qapp when making the edits.

In `@faststack/thumbnail_view/model.py`:
- Around line 378-388: The filtering currently assumes the metadata returned by
_get_metadata is a dict and uses meta.get(...), which causes AttributeError and
silently drops images; update the filter logic in the block that uses
self._active_filter_flags and self._get_metadata to first normalize the metadata
(reuse the existing _normalize_meta_flags helper) or coerce it to a mapping-like
dict before calling .get, and replace the bare except: pass with a warning log
(don’t swallow errors) so images aren’t silently excluded; apply the same fix
patterns to the similar code paths in refresh_from_controller and
_add_images_to_entries.
🟡 Minor comments (19)
faststack/repro_imports.py-1-13 (1)

1-13: ⚠️ Potential issue | 🟡 Minor

Debug script will ship in the package distribution.

This file sits inside faststack/ and the setuptools.packages.find config (include = ["faststack*"]) will include it in the built package. If this is a throwaway repro script, move it outside the package (e.g., to a top-level scripts/ or tools/ directory) or remove it before release.

faststack/ui/keystrokes.py-41-41 (1)

41-41: ⚠️ Potential issue | 🟡 Minor

F shortcut not documented in README keyboard shortcuts.

The Ftoggle_favorite binding is added here but the README's keyboard shortcuts section (provided in context) doesn't list it. Consider adding it for discoverability.

ChangeLog.md-28-28 (1)

28-28: ⚠️ Potential issue | 🟡 Minor

Stray trailing backtick in changelog entry.

Line 28 ends with an unmatched backtick after "mistakes." which breaks the Markdown formatting.

Proposed fix
-- Refactor `ThumbnailModel` filtering into `set_filter()` with an active filter state; assert refresh runs on the GUI thread to catch threading mistakes.`
+- Refactor `ThumbnailModel` filtering into `set_filter()` with an active filter state; assert refresh runs on the GUI thread to catch threading mistakes.
faststack/tests/test_loupe_delete.py-65-87 (1)

65-87: ⚠️ Potential issue | 🟡 Minor

Useless ternary on line 84 — both branches are identical.

Both the if and else branches produce list(args[0]), making the condition a no-op.

Proposed fix
-        arg0 = list(args[0]) if not isinstance(args[0], (list, tuple, set)) else list(args[0])
+        arg0 = list(args[0])
.gitignore-88-89 (1)

88-89: ⚠️ Potential issue | 🟡 Minor

Overly broad glob patterns risk ignoring legitimate files.

**/*fail*.txt matches any .txt file containing "fail" anywhere in its name (e.g., waterfall_data.txt), and **/*final*.txt matches any containing "final" (e.g., final_report.txt). Consider more specific patterns like **/*test_fail*.txt and **/*_final_*.txt, or scope them to specific directories.

faststack/deletion_types.py-113-114 (1)

113-114: ⚠️ Potential issue | 🟡 Minor

_to_path will create Path("") for empty-string values.

If a worker dict contains "" (rather than None) for a missing path, _to_path will produce Path("") instead of None. Depending on how callers check the result (e.g., if record.jpg), Path("") is truthy and could cause downstream issues such as FileNotFoundError or incorrect path operations.

Proposed defensive fix
         def _to_path(v):
-            return Path(v) if v is not None else None
+            return Path(v) if v else None
faststack/tests/thumbnail_view/test_prefetcher_priority.py-33-56 (1)

33-56: ⚠️ Potential issue | 🟡 Minor

Timing-dependent assertions may be flaky in CI.

The 20ms sleep (line 36) assumes the worker thread has started and pulled med_0 from the queue. On a loaded CI runner, thread scheduling latency could exceed this, causing high_0 to be pulled before med_0 or interleaved differently. Consider increasing the sleep or adding a synchronization primitive (e.g., an Event that the mock sets when it starts processing) to make the test deterministic.

Example: use an Event to confirm med_0 is being processed
import threading

started = threading.Event()

def mock_decode(path, path_hash, mtime_ns, size):
    if path.name == "med_0.jpg":
        started.set()
    time.sleep(0.1)
    finished_jobs.append(path.name)
    return b"fake_data"

# ... after submitting med_0:
started.wait(timeout=1.0)
# Now safe to submit remaining jobs
faststack/qml/BatchProgressDialog.qml-41-46 (1)

41-46: ⚠️ Potential issue | 🟡 Minor

ProgressBar to: 0 when batchAutoLevelsTotal is 0 may cause visual issues.

If the batch starts before batchAutoLevelsTotal is set (or is briefly 0), from: 0 and to: 0 can produce indeterminate behavior in the progress indicator. Consider guarding with Math.max(uiState.batchAutoLevelsTotal, 1).

-            to: uiState ? uiState.batchAutoLevelsTotal : 1
+            to: uiState ? Math.max(uiState.batchAutoLevelsTotal, 1) : 1
faststack/qml/BatchProgressDialog.qml-83-92 (1)

83-92: ⚠️ Potential issue | 🟡 Minor

Connections target may be null at initialization.

If uiState is not yet available when the dialog component is created, target: uiState being null will generate QML warnings. Consider adding a guard:

     Connections {
-        target: uiState
+        target: uiState || null
+        enabled: uiState !== null && uiState !== undefined
faststack/tests/test_exif_orientation.py-167-167 (1)

167-167: ⚠️ Potential issue | 🟡 Minor

Unused variable res.

The return value of save_image() is assigned but never used. Either use it for validation or drop the assignment.

Proposed fix
-        res = self.editor.save_image(write_developed_jpg=True)
+        self.editor.save_image(write_developed_jpg=True)
faststack/tests/test_reactive_delete.py-101-103 (1)

101-103: ⚠️ Potential issue | 🟡 Minor

Unused variable future.

Line 103 assigns future but it's never referenced again. Ruff F841 correctly flags this.

Proposed fix
-        future = app_controller._delete_executor.submit.return_value
+        _ = app_controller._delete_executor.submit.return_value  # noqa: F841

Or simply remove the line if not needed for readability.

faststack/tests/test_reactive_delete.py-259-259 (1)

259-259: ⚠️ Potential issue | 🟡 Minor

Unused fixture argument tmp_path.

test_undo_midflight_auto_restores declares tmp_path in its signature but uses app_controller.image_dir instead.

Proposed fix
-def test_undo_midflight_auto_restores(app_controller, tmp_path):
+def test_undo_midflight_auto_restores(app_controller):
faststack/tests/test_deletion_unification.py-258-258 (1)

258-258: ⚠️ Potential issue | 🟡 Minor

Typo in test function name: "midlight" → "midflight".

test_cancel_midlight_restores_unprocessed should be test_cancel_midflight_restores_unprocessed. The same typo also appears in test_reactive_delete.py (test_cancel_midlight_with_real_files).

Proposed fix
-def test_cancel_midlight_restores_unprocessed(mock_controller):
+def test_cancel_midflight_restores_unprocessed(mock_controller):
faststack/tests/test_reactive_delete.py-2-2 (1)

2-2: ⚠️ Potential issue | 🟡 Minor

Unused import: time.

time is imported but never used in this file.

Proposed fix
-import time
faststack/thumbnail_view/model.py-469-524 (1)

469-524: ⚠️ Potential issue | 🟡 Minor

Same _folder_count timing issue in refresh_from_controller.

Line 519 also computes _folder_count after endResetModel(). Apply the same fix as in refresh().

faststack/tests/thumbnail_view/test_model.py-244-246 (1)

244-246: ⚠️ Potential issue | 🟡 Minor

Unused variable base — dead code.

base is assigned on Line 245 but never referenced in refresh(). The confinement check is handled by navigate_to(), so this leftover assignment should be removed.

Proposed fix
     def refresh(self) -> None:
         """
         Rebuild the entries list based on filesystem + filters.
         """
         cur = self.current_directory.resolve()
-        base = self.base_directory.resolve()
 
         folders: list[ThumbnailEntry] = []
faststack/thumbnail_view/model.py-476-476 (1)

476-476: ⚠️ Potential issue | 🟡 Minor

f-string without placeholders.

Ruff F541: This f-string has no interpolation expressions. Use a plain string or include the thread details.

Proposed fix
-        assert cur == own, f"ThumbnailModel refresh thread mismatch"
+        assert cur == own, (
+            f"ThumbnailModel refresh thread mismatch: current={cur}, owner={own}"
+        )
faststack/tests/thumbnail_view/test_model.py-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Misleading file header comment.

Line 1 says # faststack/thumbnail_view/model.py but this file is faststack/tests/thumbnail_view/test_model.py. This could confuse maintainers about which file they're editing.

-# faststack/thumbnail_view/model.py
+# faststack/tests/thumbnail_view/test_model.py
faststack/thumbnail_view/model.py-352-409 (1)

352-409: ⚠️ Potential issue | 🟡 Minor

_folder_count updated after endResetModel() — potential stale reads.

Line 398 updates self._folder_count after endResetModel() (Line 396). Any slot connected to modelReset that reads folder_count will observe the old value. Consider moving the computation inside the try block before endResetModel().

Proposed fix
+            self._folder_count = sum(1 for e in self._entries if e.is_folder)
+
         finally:
             self.endResetModel()
 
-        self._folder_count = sum(1 for e in self._entries if e.is_folder)
         self.selectionChanged.emit()
🧹 Nitpick comments (44)
faststack/tests/debug_app_init.py (2)

1-6: Unused import: pytest is imported but never used.

Line 1 imports pytest which is not referenced anywhere in the file.

Proposed fix
-import pytest
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, patch

12-37: Redundant QApplication block and unnecessary try/except/print pattern.

  1. Lines 23-29 duplicate the module-level QApplication guard (lines 8-10) and introduce an unused qapp variable (Ruff F841).
  2. The try/except/print/raise pattern on lines 32-37 adds no value — if instantiation fails, the test fails with the exception traceback already. The app variable is also unused (Ruff F841).
  3. print statements in tests should be assertions or removed.
Proposed simplification
 def test_app_init_only():
     """Verify AppController can be instantiated with mocks."""
     with patch("faststack.app.ByteLRUCache"), \
          patch("faststack.app.ThumbnailModel"), \
          patch("faststack.app.Prefetcher"), \
          patch("faststack.app.PathResolver"), \
          patch("faststack.app.Watcher"), \
          patch("faststack.app.uuid"), \
          patch("faststack.app.QTimer"), \
          patch("faststack.app.concurrent.futures.ThreadPoolExecutor"):
-        
-        # Create QApplication instance
-        from PySide6.QtWidgets import QApplication
-        import sys
-        if not QApplication.instance():
-            qapp = QApplication(sys.argv)
-        else:
-            qapp = QApplication.instance()
-            
         mock_engine = MagicMock()
-        try:
-            app = AppController(Path("."), mock_engine)
-            print("AppController instantiated successfully")
-        except Exception as e:
-            print(f"AppController instantiation failed: {e}")
-            raise
+        AppController(Path("."), mock_engine)  # should not raise
faststack/tests/debug_exif.py (2)

1-6: Unused imports: sys and Path.

Neither sys (line 3) nor Path (line 4) is used in this file.

Proposed fix
 import unittest
-import sys
-from pathlib import Path
 from PIL import Image, ExifTags
 from faststack.imaging.editor import ImageEditor, sanitize_exif_orientation

8-31: Debug test has no assertions and contains incomplete placeholder code.

This test relies entirely on print for verification (no assert or self.assert* calls), so it will always pass regardless of output. Lines 28-30 are explicitly marked as incomplete (# ... actually need real flow).

Consider either:

  • Adding actual assertions (e.g., self.assertIsNotNone(sanitized), self.assertEqual(chk.get(ExifTags.Base.Orientation), 1)) to make this a real test, or
  • Moving this out of the test suite if it's only meant for manual debugging.

Also, line 22 uses if sanitized: which is falsy for empty bytes(b"") — use if sanitized is not None: if that distinction matters.

faststack/test_prespawn_strategy.py (1)

1-34: Debug script ships in package, has no assertions, and leaks the executor.

Three concerns:

  1. Ships in distribution: Like repro_imports.py, this sits inside faststack/ and will be packaged. Move to a scripts/ directory or faststack/tests/ if it should be a real test.

  2. No assertions: check_daemon() only prints — the test always passes. If the goal is to verify workers are daemon threads, assert it:

 def check_daemon():
-    print(f"Thread {threading.current_thread().name} daemon: {threading.current_thread().daemon}")
+    return threading.current_thread().daemon
-    executor.submit(check_daemon).result()
+    assert executor.submit(check_daemon).result() is True, "Worker should be a daemon thread"
  1. Executor never shut down: Call executor.shutdown(wait=False) at the end to avoid resource leaks.
faststack/tests/test_loupe_delete.py (1)

140-145: Prefer next(iter(...)) over single-element slice for dict keys.

Line 142 creates an intermediate list just to get the first key.

Proposed fix
-    job_id = list(mock_controller._pending_delete_jobs.keys())[0]
+    job_id = next(iter(mock_controller._pending_delete_jobs))
faststack/repro_daemon_bug.py (1)

1-26: Debug/reproduction script — consider removing or moving out of the package.

This script duplicates logic already covered by proper tests in test_executor_shutdown.py and test_prespawn_strategy.py. Shipping it in the faststack/ package means it gets installed with the application. If it's still needed for reference, consider moving it to a scripts/ or debug/ directory outside the package, or converting it to a proper pytest test.

Also, the time import (line 3) is unused.

faststack/tests/test_config_setters.py (1)

94-118: Clean up leftover developer reasoning comments.

Lines 98–105 contain stream-of-consciousness notes about mock behavior. These read like debugging notes rather than test documentation. Consider replacing with a concise one-liner explaining the expected default.

Proposed cleanup
     def test_set_auto_level_strength(self):
         self.mock_config.set.reset_mock()
         self.mock_config.save.reset_mock()
 
-        # Default was 1.0 in code, but our mock config.getfloat returns 0.1 (as per setUp)
-        # Wait, if config.getfloat returned 0.1 for threshold, did it return 0.1 for strength too?
-        # Yes, line 62 in original: mock_config_obj.getfloat.return_value = 0.1
-        # In setUp I set it to 0.1.
-        
-        # But wait, config.getfloat is called with default 1.0 for strength in app.py:
-        # self.auto_level_strength = config.getfloat("core", "auto_level_strength", 1.0)
-        # If I mock getfloat to always return 0.1, then it's 0.1.
-        
+        # mock_config.getfloat always returns 0.1 (setUp), so initial strength is 0.1
+
         new_val = 0.8
faststack/tests/test_jump_to_last_uploaded.py (1)

7-14: Duplicated qapp fixture — consider centralizing in conftest.py.

This session-scoped qapp fixture is defined identically in test_loupe_delete.py and here. Moving it to a shared conftest.py would avoid duplication and ensure consistent QApplication lifecycle across all tests.

faststack/io/utils.py (1)

8-21: normalize_path_key correctly handles absolute paths; document that it expects absolute inputs.

os.path.abspath resolves relative paths against the current working directory. However, all callers in the codebase pass absolute paths: os.scandir() returns absolute paths, and image_dir is always validated and absolute. No practical risk exists, but the function would be more defensive if it either documented the absolute-path precondition in its docstring or converted unexpected relative paths using Path().resolve() instead of os.path.abspath().

.gitignore (1)

11-12: Ignoring the entire docs/ directory is risky long-term.

The comment says "We don't have any good docs yet," but this will silently swallow any future documentation contributions. Consider tracking a TODO or using a more targeted pattern so this doesn't become a forgotten footgun.

faststack/imaging/cache.py (1)

194-211: Clean up leftover thinking/deliberation comments.

Lines 196–203 contain stream-of-consciousness developer notes ("We need size before removal…", "We want to log *how much*…", etc.). These should be removed or condensed to a single concise comment.

♻️ Suggested cleanup
             # 4. Remove keys
             removed_bytes = 0
             for k in keys_to_remove:
-                # We need size before removal to log correctly?
-                # LRUCache.pop returns value. We can ask getsizeof(value) but pop removes it anyway.
-                # ByteLRUCache tracks currsize. We can diff currsize.
-                # But simpler: just trust currsize updates.
-                # We want to log *how much* we removed.
-                # Accessing self.getsizeof(val) needs val.
-                # val = self.pop(k) would work.
-                # We want to log *how much* we removed.
                 if k in self:
-                    # Pop first to avoid updating LRU order with self[k]
                     val = self.pop(k)
                     try:
                         size = get_decoded_image_size(val)
faststack/util/executors.py (2)

124-143: Use log.exception instead of log.error for traceback visibility.

Line 137 uses log.error(...) which won't include the traceback. Since this catches a BaseException in a worker loop, the traceback is valuable for debugging.

♻️ Proposed fix
-                log.error("Error in PriorityExecutor worker: %s", e)
+                log.exception("Error in PriorityExecutor worker: %s", e)

194-199: 1-second join timeout may silently leave workers running.

If a worker is stuck on a long task, t.join(timeout=1.0) returns without the thread being joined, and there's no log indicating the thread is still alive. Since workers are daemon threads they'll die with the process, but logging a warning would help debugging slow shutdowns.

♻️ Proposed enhancement
         if wait:
             for t in self._workers:
                 try:
                     t.join(timeout=1.0)
+                    if t.is_alive():
+                        log.warning("Worker %s did not terminate within timeout", t.name)
                 except Exception:
                     pass
faststack/imaging/prefetch.py (3)

122-122: Defined _EXIF_ORIENTATION_TAG constant is unused; literal 274 is used instead.

Line 122 defines _EXIF_ORIENTATION_TAG = 274, but line 530 uses the literal 274. Use the constant for consistency and self-documentation.

♻️ Proposed fix at line 530
-                orientation = exif_obj.get(274, 1) if exif_obj else 1
+                orientation = exif_obj.get(_EXIF_ORIENTATION_TAG, 1) if exif_obj else 1

Also applies to: 526-530


425-432: Silent except Exception: pass swallows EXIF metadata errors without any logging.

Lines 431–432 and 500–501 catch all exceptions and silently pass when extracting EXIF data from mmap'd images. While this is defensive, it makes debugging metadata-related issues very difficult. A log.debug would preserve the resilience while aiding troubleshooting.

♻️ Proposed fix (example for lines 431-432; apply similar at 500-501)
-                                        except Exception:
-                                            pass
+                                        except Exception as e:
+                                            log.debug("Failed to extract metadata from mmap: %s", e)

Also applies to: 495-501


375-564: _decode_and_cache is ~190 lines with deeply nested try/except — consider extracting helpers.

The ICC path, non-ICC JPEG path, Pillow fallback, EXIF orientation, and saturation compensation could each be small helper methods. This would improve readability and make individual code paths easier to test. Not urgent, but would pay off as this code evolves.

faststack/tests/repro_futures_cleanup.py (2)

54-59: simulate_cleanup duplicates _cleanup_future logic — call the real method instead.

If _cleanup_future's implementation changes, this test won't detect regressions. Call prefetcher._cleanup_future(index, future_a) directly.

♻️ Proposed fix
-        def simulate_cleanup(prefetcher, idx, fut):
-            with prefetcher._futures_lock:
-                if prefetcher.futures.get(idx) is fut:
-                    del prefetcher.futures[idx]
-
-        simulate_cleanup(prefetcher, index, future_a)
+        prefetcher._cleanup_future(index, future_a)

And similarly for line 65:

-        simulate_cleanup(prefetcher, index, future_b)
+        prefetcher._cleanup_future(index, future_b)

9-11: sys.modules injection is fragile — prefer unittest.mock.patch or pytest monkeypatch.

Directly manipulating sys.modules before import is brittle and can leak across tests if module state isn't restored. Using @patch.dict(sys.modules, ...) or pytest's monkeypatch is safer and self-cleaning.

faststack/tests/test_executor_shutdown.py (1)

11-48: Drain test asserts presence but not LIFO order — comments are misleading.

Lines 40-44 discuss LIFO ordering but the assertions only verify all three values are present. If the intent is to verify LIFO ordering within the same priority, consider asserting the order of results. Otherwise, remove the LIFO discussion comments to avoid confusion.

faststack/deletion_types.py (1)

95-96: Bare list type annotations are inconsistent with the rest of the file.

perm_success and perm_fail use unparameterized list while every other collection field uses List[...] with explicit element types. Similarly, saved_batches on DeleteJob (line 46) uses Optional[list]. Consider using List[Tuple[int, Any]] (per the comments) for consistency and self-documentation.

Proposed fix
-    perm_success: list = field(default_factory=list)  # List[(idx, ImageFile)]
-    perm_fail: list = field(default_factory=list)  # List[(idx, ImageFile)]
+    perm_success: List[Tuple[int, Any]] = field(default_factory=list)
+    perm_fail: List[Tuple[int, Any]] = field(default_factory=list)
faststack/tests/test_exif_compat.py (1)

16-20: Remove commented-out code and verbose rationale comments.

Lines 16-18 read like internal instructions ("The instruction implies…") rather than code comments — they'll confuse future readers. Lines 19-20 are dead code. Since the intent is to stop mocking faststack.models, clean this up by removing all five lines entirely (or at most leave a one-line note explaining why the mock was removed).

Proposed cleanup
 # Pre-mock modules that might cause issues or aren't needed for this test
 sys.modules["cv2"] = MagicMock()
-# Mock faststack.models since it's used by editor.py
-# The instruction implies removing 'faststack.models' from a patch.dict,
-# but it's currently a direct assignment.
-# Assuming the intent is to remove the mocking of faststack.models entirely.
-# mock_models = MagicMock()
-# sys.modules["faststack.models"] = mock_models
+# faststack.models is no longer mocked; tests use real model definitions.
faststack/models.py (1)

72-72: Consider whether a favorite_date field is needed for consistency.

Every other boolean flag in EntryMetadata (stacked, uploaded, edited, restacked) has a companion *_date: Optional[str] field. If there's no need to track when an item was favorited (e.g., for sorting or display), this omission is fine — just worth a conscious note.

faststack/tests/test_editor_no_copy.py (1)

110-114: *args after keyword argument for_export is dead code (B026).

In spy_apply(arr, for_export=False, *args, **kwargs), *args can never capture any values because for_export already consumes the second positional argument. The real _apply_edits signature takes (self, source_arr, for_export=True), so just arr and for_export suffice.

Proposed fix
-    def spy_apply(arr, for_export=False, *args, **kwargs):
-        # We only care about the call for this specific test instance
-        if for_export and arr is ed.float_image:
-            seen["same_obj"] = True
-        return real_apply(arr, for_export=for_export, *args, **kwargs)
+    def spy_apply(arr, for_export=False, **kwargs):
+        if for_export and arr is ed.float_image:
+            seen["same_obj"] = True
+        return real_apply(arr, for_export=for_export, **kwargs)
faststack/thumbnail_view/provider.py (1)

225-226: Use %-style formatting in log.debug for lazy evaluation.

f-strings are always evaluated, even when the DEBUG level is disabled, which adds overhead in a method iterating over all model entries.

-        log.debug(f"PathResolver update took {dt*1000:.2f}ms for {model.rowCount()} items")
+        log.debug("PathResolver update took %.2fms for %d items", dt * 1000, model.rowCount())
faststack/tests/thumbnail_view/test_prefetcher_priority.py (1)

61-100: LIFO test has similar timing fragility.

The 10ms sleeps between job submissions (lines 81, 85, 87) serve as sequencing hints but the LIFO ordering is actually determined by the -seq counter in PriorityExecutor, not by submission timing. The sleeps can likely be removed without affecting correctness, which would also make the test faster.

faststack/tests/test_new_features.py (1)

22-28: Heuristic maxv <= 1.5 for detecting normalized floats can misclassify very dark uint8-scale images.

If an image in the [0, 255] range has all pixel values ≤ 1.5 (e.g., a nearly-black image), this would incorrectly scale by 255×. For the current synthetic test images this is fine, but consider checking arr.max() <= 1.0 or passing an explicit flag if this utility is reused elsewhere.

faststack/tests/test_refresh_optimization.py (1)

7-13: Session-scoped qapp fixture may conflict with other test modules.

If other test files also create a QApplication, the session scope means only one instance exists. This is correct behavior (Qt allows only one QApplication), but consider extracting this fixture to a shared conftest.py to avoid duplication across test files.

faststack/tests/test_raw_pipeline.py (1)

31-39: DummyImageFile behavior diverges from real ImageFile.

has_raw checks .exists() (real model only checks is not None), and raw_path raises AttributeError (real model returns None). This is fine for the tests in this file, but be aware that code exercised through these tests may behave differently in production if it relies on raw_path returning None or has_raw not touching the filesystem.

faststack/tests/test_handle_failures_isolated.py (1)

2-2: Unused import sys.

sys is imported but never used in this file.

faststack/tests/test_deletion_perf_structure.py (2)

16-42: Unused mock_app fixture.

This fixture is defined but no test function uses it — each test sets up its own patching inline. Either remove it or refactor the tests to use it.


78-81: Remove debug print statements.

Lines 78 and 81 contain print("DEBUG: ...") statements that should be removed or converted to logging before merge.

Proposed fix
-        print("DEBUG: Calling _delete_indices")
         # Act
         summary = app._delete_indices([0], "test")
-        print("DEBUG: Returned from _delete_indices")
faststack/qml/FilterDialog.qml (1)

143-145: Empty onAccepted handler can be removed.

Since flags and filter string are both collected live, this empty handler serves no purpose.

Proposed fix
-    onAccepted: {
-        // Flags are now collected live via onCheckedChanged
-    }
-
faststack/tests/test_deletion_unification.py (2)

376-389: Inconsistent indentation inside with block.

Lines 378–389 are indented with 12 spaces (3 levels) inside the with block, but the with statement at line 438 in a similar pattern uses 8 spaces. The extra indentation here doesn't break anything but is inconsistent with the rest of the file.


421-423: Use next(iter(...)) instead of list(...)[0] per Ruff RUF015.

list(mock_controller._pending_delete_jobs.keys())[0] materializes the entire dict view into a list just to get the first key.

Proposed fix
-    job_id = list(mock_controller._pending_delete_jobs.keys())[0]
+    job_id = next(iter(mock_controller._pending_delete_jobs))
faststack/tests/test_skip_linear.py (1)

16-20: Consider using np.random.default_rng(42) instead of np.random.seed(42).

np.random.seed / np.random.rand use the legacy global RNG. With numpy 2.0+, np.random.default_rng(42) is preferred. Not urgent since this is test-only code and the legacy API still works.

Proposed fix
     def setUp(self):
-        np.random.seed(42)
         self.editor = ImageEditor()
+        rng = np.random.default_rng(42)
         # Deterministic float32 image in [0.1, 0.9] — avoids clip boundaries
-        self.arr = (np.random.rand(100, 100, 3).astype(np.float32) * 0.8 + 0.1)
+        self.arr = (rng.random((100, 100, 3), dtype=np.float32) * 0.8 + 0.1)
faststack/thumbnail_view/prefetcher.py (3)

118-120: size: int = None should use explicit Optional or union type.

PEP 484 prohibits implicit Optional. Since None is a valid default, the type hint should reflect this.

Proposed fix
     def submit(
-        self, path: Path, mtime_ns: int, size: int = None, priority: int = PRIO_MED
+        self, path: Path, mtime_ns: int, size: Optional[int] = None, priority: int = PRIO_MED
     ) -> bool:

71-71: Same implicit Optional issue in __init__.

max_workers: int = None has the same PEP 484 issue.

Proposed fix
-        max_workers: int = None,
+        max_workers: Optional[int] = None,

370-378: _order.remove(key) in get() is O(n) — consider collections.OrderedDict for the LRU cache.

ThumbnailCache.get() calls self._order.remove(key) which scans the list linearly. With max_items=5000, this could become a hot-path bottleneck under heavy thumbnail scrolling. An OrderedDict (or move_to_end()) would give O(1) LRU promotion. This is existing code, so not blocking, but worth considering as a follow-up.

faststack/thumbnail_view/model.py (3)

239-246: Tight coupling: _get_loupe_index_for_entry reaches into parent's private _path_to_index.

This couples the model to the AppController's internal structure. If the parent changes its attribute name or type, this silently returns None for all entries, breaking IsInBatchRole and IsCurrentRole. Consider injecting a callback (like get_batch_indices_callback) instead of relying on hasattr.


749-754: _compute_path_hash is a trivial wrapper — consider removing.

This method only delegates to compute_path_hash(path). All other call sites in this file already use compute_path_hash directly (Lines 212, 276, 590). This wrapper adds no value and could be removed to reduce surface area.


526-575: Duplicated metadata extraction logic across _add_images_to_entries and refresh.

Lines 538-561 repeat the same metadata key extraction (stacked, uploaded, edited, restacked, favorite) as the filtering block in refresh (Lines 378-388). Extracting a shared helper (like the test model's _normalize_meta_flags) would reduce duplication and ensure consistency.

faststack/imaging/editor.py (2)

927-934: Skip-linear export path: _skip_linear is only active for for_export=True.

This ensures preview rendering always goes through the full linear pipeline (needed for UI highlight analysis), while export skips it when safe. The log.debug at Line 934 is useful for debugging but runs unconditionally during export — consider guarding it behind _debug for consistency with other timing logs.


2060-2062: log.exception already includes the traceback — passing e is redundant.

Ruff TRY401: log.exception() automatically appends the exception info from the current handler. The e in the format string is superfluous.

Proposed fix
-            log.exception("Failed to save %s: %s", self.current_filepath, e)
+            log.exception("Failed to save %s", self.current_filepath)

Comment on lines +1895 to +1902
def _ensure_float_image(self) -> None:
"""Ensure self.float_image exists. Needed when load_image(preview_only=True)."""
if self.float_image is not None:
return
if self.original_image is None:
raise RuntimeError("No image loaded")
rgb = self.original_image.convert("RGB")
self.float_image = np.array(rgb).astype(np.float32) / 255.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

_ensure_float_image is not protected by self._lock.

This method reads and writes self.float_image and reads self.original_image without acquiring self._lock. Since save_image (which calls this) can potentially race with the preview worker that reads float_image under the lock, consider wrapping the check-and-assign in the lock:

Proposed fix
     def _ensure_float_image(self) -> None:
         """Ensure self.float_image exists. Needed when load_image(preview_only=True)."""
-        if self.float_image is not None:
-            return
-        if self.original_image is None:
-            raise RuntimeError("No image loaded")
-        rgb = self.original_image.convert("RGB")
-        self.float_image = np.array(rgb).astype(np.float32) / 255.0
+        with self._lock:
+            if self.float_image is not None:
+                return
+            if self.original_image is None:
+                raise RuntimeError("No image loaded")
+            rgb = self.original_image.convert("RGB")
+            self.float_image = np.array(rgb).astype(np.float32) / 255.0
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 1900-1900: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
In `@faststack/imaging/editor.py` around lines 1895 - 1902, The method
_ensure_float_image accesses and mutates shared state (self.float_image and
reads self.original_image) without holding self._lock, causing races with the
preview worker and callers like save_image; fix this by acquiring self._lock
around the check-and-assign sequence in _ensure_float_image (i.e., wrap the
early-return check, the original_image None check/raise, and the
conversion/assignment to self.float_image inside a critical section using
self._lock) so all reads/writes of these attributes are protected.

Comment on lines +1930 to 1939

# 1. Apply Edits to Full Resolution
# Skip the expensive .copy() when safe — see _edits_can_share_input().
_safe_no_copy = self._edits_can_share_input(self.current_edits)
source_arr = self.float_image if _safe_no_copy else self.float_image.copy()
if _safe_no_copy:
log.debug("save_image: skipping float_image.copy() (safe no-copy path)")
final_float = self._apply_edits(
self.float_image.copy(), for_export=True
source_arr, for_export=True
) # (H,W,3) float32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all calls to save_image to understand usage patterns
rg -n "\.save_image\(" --type=py -B2 -A2

Repository: AlanRockefeller/faststack

Length of output: 6331


🏁 Script executed:

#!/bin/bash
# Check the thread assertion in refresh() at lines 354-356
sed -n '350,360p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 652


🏁 Script executed:

#!/bin/bash
# Look at the get_preview_data_cached implementation around line 1514
sed -n '1510,1525p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 620


🏁 Script executed:

#!/bin/bash
# Check the _edits_can_share_input method to understand the safe no-copy logic
rg -n "_edits_can_share_input" --type=py -B2 -A10

Repository: AlanRockefeller/faststack

Length of output: 14699


🏁 Script executed:

#!/bin/bash
# Check for any lock mechanisms or thread safety decorators in the class
rg -n "Lock\|lock\|thread\|@thread" --type=py faststack/imaging/editor.py | head -30

Repository: AlanRockefeller/faststack

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Check refresh() method for thread assertions
sed -n '340,370p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 1895


🏁 Script executed:

#!/bin/bash
# Get context around the background worker in app.py
sed -n '1190,1210p' faststack/app.py

Repository: AlanRockefeller/faststack

Length of output: 1031


🏁 Script executed:

#!/bin/bash
# Search for locks protecting image_editor in app.py
rg -n "Lock\|lock\|thread\|Thread\|Mutex" faststack/app.py | grep -i "image_editor\|edit" | head -20

Repository: AlanRockefeller/faststack

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Check if image_editor operations have locks/synchronization
rg -n "self\.image_editor" faststack/app.py | head -10

Repository: AlanRockefeller/faststack

Length of output: 679


🏁 Script executed:

#!/bin/bash
# Look for any mutex or lock protecting float_image or edits
rg -n "_lock\|_mutex\|with.*lock\|threading" faststack/imaging/editor.py | head -20

Repository: AlanRockefeller/faststack

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Find _lock definition in ImageEditor
rg -n "_lock" faststack/imaging/editor.py | head -20

Repository: AlanRockefeller/faststack

Length of output: 766


🏁 Script executed:

#!/bin/bash
# Check the complete save_image method implementation
sed -n '1900,1950p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 2106


🏁 Script executed:

#!/bin/bash
# Look for __init__ to see where _lock is initialized
rg -n "def __init__" faststack/imaging/editor.py -A20 | head -40

Repository: AlanRockefeller/faststack

Length of output: 967


🏁 Script executed:

#!/bin/bash
# Check if save_image has any lock usage
sed -n '1900,2000p' faststack/imaging/editor.py | grep -n "lock\|Lock\|with"

Repository: AlanRockefeller/faststack

Length of output: 115


🏁 Script executed:

#!/bin/bash
# Check the ENTIRE save_image method to see if it uses _lock
awk '/def save_image/,/^    def [a-z_]/ {print NR": "$0}' faststack/imaging/editor.py | head -150

Repository: AlanRockefeller/faststack

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Get exact line count of save_image method
rg -n "def save_image" faststack/imaging/editor.py -A200 | tail -5

Repository: AlanRockefeller/faststack

Length of output: 233


🏁 Script executed:

#!/bin/bash
# Get the ENTIRE save_image method (lines 1904-2063)
sed -n '1904,2063p' faststack/imaging/editor.py | head -160

Repository: AlanRockefeller/faststack

Length of output: 7078


🏁 Script executed:

#!/bin/bash
# Check if there's any synchronization around the save_image call in app.py
sed -n '1180,1220p' faststack/app.py

Repository: AlanRockefeller/faststack

Length of output: 1865


🏁 Script executed:

#!/bin/bash
# Check if get_preview_data_cached uses locks (as it's the competing reader)
sed -n '1500,1530p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 1244


🏁 Script executed:

#!/bin/bash
# Verify how other methods that access float_image/current_edits use locks
rg -n "def get_preview_data" faststack/imaging/editor.py -A15 | head -30

Repository: AlanRockefeller/faststack

Length of output: 1333


🏁 Script executed:

#!/bin/bash
# Verify if _apply_edits actually mutates the input array on the no-copy safe path
sed -n '1300,1350p' faststack/imaging/editor.py | head -60

Repository: AlanRockefeller/faststack

Length of output: 2117


🏁 Script executed:

#!/bin/bash
# Check if float_image is ever modified after initialization
rg -n "self\.float_image\s*=" faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 327


Add lock protection to save_image method.

save_image is called from both background threads (app.py:1198) and the main UI thread (app.py:5713, 5945, 6039, 6177) without acquiring _lock, while other methods like set_edit_param() (line 1566) and get_preview_data_cached() (line 1505) correctly protect access to self.current_edits and self.float_image with locks.

The no-copy path's safety check _edits_can_share_input(self.current_edits) at line 1933 reads edits without lock protection, creating a race condition where current_edits could change between the safety check and the call to _apply_edits. Additionally, _ensure_float_image() (called at line 1925) modifies self.float_image at line 1902 without lock protection.

Follow the pattern in get_preview_data_cached(): acquire _lock upfront, snapshot both current_edits and validate the no-copy safety check under the lock, then release the lock before the expensive _apply_edits call.

🤖 Prompt for AI Agents
In `@faststack/imaging/editor.py` around lines 1930 - 1939, The save_image method
must acquire the instance lock before reading or validating shared state: take
self._lock (following the pattern in get_preview_data_cached()) then call
_ensure_float_image(), snapshot self.current_edits and evaluate
_edits_can_share_input(self.current_edits) while holding the lock so the no-copy
decision is race-free; set a local variable for whether to copy and a local
reference to self.float_image (or a copy if needed), release the lock, and only
then call _apply_edits(final_source, for_export=True). Ensure you reference and
protect current_edits and float_image, and keep other methods like
set_edit_param unchanged.

Comment on lines +1 to +15
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0 -- C:\code\faststack\faststack\verify_venv\Scripts\python.exe
rootdir: C:\code\faststack
configfile: pyproject.toml
collecting ... collected 7 items

tests\test_reactive_delete.py::test_optimistic_ui_removal PASSED [ 14%]
tests\test_reactive_delete.py::test_undo_pending_delete_no_disk_ops PASSED [ 28%]
tests\test_reactive_delete.py::test_async_delete_completion PASSED [ 42%]
tests\test_reactive_delete.py::test_delete_rollback_on_cancel PASSED [ 57%]
tests\test_reactive_delete.py::test_debounced_refresh PASSED [ 71%]
tests\test_reactive_delete.py::test_cancel_midlight_with_real_files PASSED [ 85%]
tests\test_reactive_delete.py::test_undo_then_completion_no_bookkeeping PASSED [100%]

============================== 7 passed in 0.56s ==============================
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove test output file from version control.

Test output snapshots should not be committed to the repository. This file:

  • Contains environment-specific paths (C:\code\faststack\faststack\verify_venv\Scripts\python.exe)
  • Becomes stale immediately after each test run
  • Adds no lasting value to the codebase
  • Appears to be a debugging artifact

Consider:

  1. Remove this file from the PR
  2. Add *test_output.txt or similar patterns to .gitignore
  3. For tracking test results, use CI/CD reporting, coverage badges, or test result artifacts in your CI pipeline instead
🤖 Prompt for AI Agents
In `@faststack/reactive_test_output.txt` around lines 1 - 15, The committed test
output file faststack/reactive_test_output.txt should be removed from the PR and
excluded from future commits; delete the file from the branch (or revert its
addition) and add an ignore rule such as *test_output.txt (or
reactive_test_output.txt) to .gitignore so similar snapshot/debug artifacts are
not committed again, then run git rm --cached if you need to stop tracking an
already committed file before committing the updated .gitignore.

Comment on lines +1 to +54
import time
import threading
from faststack.util.executors import create_priority_executor as PriorityExecutor

def test_priority_executor():
print("Testing PriorityExecutor...")
executor = PriorityExecutor(max_workers=1, thread_name_prefix="Test")

results = []
def task(name, delay=0.1):
time.sleep(delay)
results.append(name)
return name

# Fill the worker and wait a bit to ensure it STARTS
executor.submit(task, "initial", delay=0.2)
time.sleep(0.05)

# Submit tasks with different priorities and see execution order
# Lower number = higher priority
# within same priority, higher sequence = higher priority (LIFO)
executor.submit(task, "p2_a", priority=2)
executor.submit(task, "p2_b", priority=2)
executor.submit(task, "p1", priority=1)

print("Tasks submitted, waiting for completion...")
# Expected order: "initial" (already running), "p1", "p2_b", "p2_a"

time.sleep(1.0)
print("Execution order:", results)

expected = ["initial", "p1", "p2_b", "p2_a"]
if results == expected:
print("SUCCESS: Priority and LIFO ordering correct.")
else:
print(f"FAILURE: Expected {expected}, got {results}")

print("\nTesting shutdown and cancellation...")
executor = PriorityExecutor(max_workers=1, thread_name_prefix="TestShutdown")
executor.submit(task, "long", delay=0.5)
f1 = executor.submit(task, "queued1")
f2 = executor.submit(task, "queued2")

executor.shutdown(wait=True, cancel_futures=True)
print(f"f1 cancelled: {f1.cancelled()}")
print(f"f2 cancelled: {f2.cancelled()}")

if f1.cancelled() and f2.cancelled():
print("SUCCESS: Futures cancelled on shutdown.")
else:
print("FAILURE: Futures not cancelled.")

if __name__ == "__main__":
test_priority_executor()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

This appears to be a debug/reproduction script, not a proper test.

Several issues:

  1. Misleading alias (line 3): create_priority_executor as PriorityExecutor — this is a factory function, not the class itself.
  2. No assertions: Uses print/if instead of assert, so pytest would mark this as passing even when results are wrong.
  3. Sleep-based synchronization (line 29): time.sleep(1.0) is inherently flaky.
  4. Location: Lives in faststack/ rather than faststack/tests/.
  5. Duplicate coverage: faststack/tests/test_executor_shutdown.py already covers priority ordering and cancellation with proper pytest patterns.

Consider removing this file (and adding **/test_executors.py to .gitignore if it's a local scratch file) or converting it to a proper pytest test in faststack/tests/.

🤖 Prompt for AI Agents
In `@faststack/test_executors.py` around lines 1 - 54, The file is a
debug/reproduction script (test_priority_executor) that misuses
create_priority_executor (aliasing the factory as PriorityExecutor), lacks
pytest assertions, uses flaky sleep-based synchronization, duplicates existing
tests, and lives in the wrong directory; fix by either removing this file
entirely (and add **/test_executors.py to .gitignore if it’s a local scratch
file) OR convert it into a proper pytest test under faststack/tests/ by calling
the factory create_priority_executor(...) correctly (don’t alias it as a class),
replace print/if checks with assert statements, replace time.sleep-based waits
with synchronization on returned Future objects (or use
executor.shutdown(wait=True) and future.result(timeout=...)) to
deterministically wait for completion, and ensure shutdown/cancel behavior is
asserted via Future.cancelled(); reference create_priority_executor,
test_priority_executor, executor.submit, and executor.shutdown when making
changes.

Comment on lines +12 to +41
class TestDebugError(unittest.TestCase):
def test_debug_save_image(self):
editor = ImageEditor()
editor.float_image = np.zeros((10, 10, 3), dtype=np.float32)
editor.current_filepath = Path("fake_path.jpg")

# Patch create_backup_file to succeed
with patch("faststack.imaging.editor.create_backup_file", return_value=Path("backup.jpg")):
# Patch Image.fromarray to return a mock that fails to save
mock_img = MagicMock()
mock_img.save.side_effect = PermissionError("Mocked save error")

print(f"DEBUG: Real Image.fromarray before patch: {Image.fromarray}")

with patch("PIL.Image.fromarray", return_value=mock_img) as mock_fromarray:
print(f"DEBUG: Image.fromarray is patched: {Image.fromarray}")
print(f"DEBUG: mock_fromarray: {mock_fromarray}")

# Verify that calling Image.fromarray returns our mock
img = Image.fromarray(np.zeros((10,10,3), dtype=np.uint8))
print(f"DEBUG: Returned img: {img}")
print(f"DEBUG: img.save side effect: {img.save.side_effect}")

try:
editor.save_image()
print("FAIL: save_image did NOT raise RuntimeError")
except RuntimeError as e:
print(f"PASS: Caught RuntimeError: {e}")
except Exception as e:
print(f"FAIL: Caught unexpected exception: {type(e)} {e}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Test is broken: no assertions and missing original_image setup.

Multiple issues make this test ineffective:

  1. No assertions — the test uses print("PASS")/print("FAIL") instead of self.assertRaises(RuntimeError, editor.save_image). The test will always pass regardless of behavior.
  2. editor.original_image is never setsave_image checks if self.current_filepath is None or self.original_image is None: return None (editor.py line 1903-1904), so it exits early before reaching any save logic.
  3. Wrong patch targetPIL.Image.fromarray should be patched where it's looked up: faststack.imaging.editor.Image.fromarray, not at the PIL module level (though this is moot given issue #2).
  4. Debug print statements should be removed for a committed test.
Proposed fix
 class TestDebugError(unittest.TestCase):
     def test_debug_save_image(self):
         editor = ImageEditor()
         editor.float_image = np.zeros((10, 10, 3), dtype=np.float32)
         editor.current_filepath = Path("fake_path.jpg")
+        editor.original_image = MagicMock()  # Required to pass early guard
 
-        # Patch create_backup_file to succeed
         with patch("faststack.imaging.editor.create_backup_file", return_value=Path("backup.jpg")):
-            # Patch Image.fromarray to return a mock that fails to save
             mock_img = MagicMock()
             mock_img.save.side_effect = PermissionError("Mocked save error")
-            
-            print(f"DEBUG: Real Image.fromarray before patch: {Image.fromarray}")
 
-            with patch("PIL.Image.fromarray", return_value=mock_img) as mock_fromarray:
-                print(f"DEBUG: Image.fromarray is patched: {Image.fromarray}")
-                print(f"DEBUG: mock_fromarray: {mock_fromarray}")
-                
-                # Verify that calling Image.fromarray returns our mock
-                img = Image.fromarray(np.zeros((10,10,3), dtype=np.uint8))
-                print(f"DEBUG: Returned img: {img}")
-                print(f"DEBUG: img.save side effect: {img.save.side_effect}")
-                
-                try:
-                    editor.save_image()
-                    print("FAIL: save_image did NOT raise RuntimeError")
-                except RuntimeError as e:
-                    print(f"PASS: Caught RuntimeError: {e}")
-                except Exception as e:
-                    print(f"FAIL: Caught unexpected exception: {type(e)} {e}")
+            with patch("faststack.imaging.editor.Image.fromarray", return_value=mock_img):
+                with self.assertRaises(RuntimeError):
+                    editor.save_image()
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 40-40: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@faststack/tests/debug_editor_error.py` around lines 12 - 41, The test
TestDebugError.test_debug_save_image is ineffective: set up
editor.original_image (e.g., assign a PIL Image or MagicMock) so save_image
proceeds past the early-return, patch Image.fromarray at the lookup site
faststack.imaging.editor.Image.fromarray (not PIL.Image.fromarray), keep the
create_backup_file patch and have the mocked image's save raise PermissionError,
remove all debug print statements, and replace prints with a proper assertion
using self.assertRaises(RuntimeError, editor.save_image) (or with
self.assertRaises in a context manager) to verify save_image raises the expected
RuntimeError.

Comment on lines +18 to +20
image_files = [MagicMock(path=Path(f"test_{i}.jpg")) for i in range(10)]
for img in image_files:
img.path.suffix = ".jpg"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Path.suffix is a read-only property — line 20 will raise AttributeError.

img.path is a real Path object (from MagicMock(path=Path(...))), and Path.suffix has no setter. This line will crash at test setup time. It's also redundant since Path("test_0.jpg").suffix is already ".jpg".

🐛 Proposed fix — remove the redundant line
         image_files = [MagicMock(path=Path(f"test_{i}.jpg")) for i in range(10)]
-        for img in image_files:
-            img.path.suffix = ".jpg"
🤖 Prompt for AI Agents
In `@faststack/tests/repro_futures_cleanup.py` around lines 18 - 20, The test
setup erroneously assigns to the read-only Path.suffix in the loop over
image_files (created via MagicMock(path=Path(...))), which will raise
AttributeError; remove the for-loop that does "for img in image_files:
img.path.suffix = '.jpg'" (or replace it by making img.path a MagicMock if you
really need assignable attributes), leaving image_files =
[MagicMock(path=Path(f"test_{i}.jpg")) for i in range(10)] as the correct setup
for tests that expect ".jpg" suffix.

Comment on lines +25 to +85
# COPIED LOGIC FROM app.py _handle_delete_failures
def _handle_delete_failures(self, result: DeleteResult, job: DeleteJob) -> None:
"""Handle items that failed to delete. Rollback UI or prompt for perm delete."""
if not result.failures:
return

# Identify which UI items failed (map back using paths)
# Note: We use the _key() mapping to ensure we match robustly
failed_keys = {self._key(f.jpg) for f in result.failures if f.jpg}

failed_indices_and_imgs = []
for idx, img in job.removed_items:
if self._key(img.path) in failed_keys:
failed_indices_and_imgs.append((idx, img))

if not failed_indices_and_imgs:
return

# Check if we should offer permanent delete (recycle bin error)
perm_candidates = [] # List of (idx, img)

# Helper to find if a specific failure code warrants perm delete
recycle_codes = {
DeletionErrorCodes.RECYCLE_FAILED.value,
DeletionErrorCodes.PERMISSION_DENIED.value,
DeletionErrorCodes.TRASH_FULL.value
}

# Map failure code by key for easy lookup
failure_map = {self._key(f.jpg): f for f in result.failures if f.jpg}

for idx, img in failed_indices_and_imgs:
f = failure_map.get(self._key(img.path))
if f and f.code in recycle_codes:
perm_candidates.append((idx, img))

if perm_candidates:
# Prompt user for permanent delete

# 1. Rollback non-candidates first
candidate_keys = {self._key(img.path) for _, img in perm_candidates}
to_rollback = [(i, img) for i, img in failed_indices_and_imgs if self._key(img.path) not in candidate_keys]

if to_rollback:
self._rollback_ui_items(to_rollback, job)

# 2. Ask user
# candidate_imgs = [img for _, img in perm_candidates]

# Using global mocks here instead of real imports
# confirmed = confirm_permanent_delete(...)

# For test purpose, we just assert that we identified candidates correctly
pass

else:
# Just rollback everything
self._rollback_ui_items(failed_indices_and_imgs, job)

self._rebuild_path_to_index()
self.sync_ui_state()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Copied production logic will silently drift from AppController._handle_delete_failures.

This duplicates ~60 lines from app.py. If the real method is refactored, this copy won't break but will test stale logic. Consider binding the real method onto MockController (as test_handle_failures.py does with __get__) instead of maintaining a separate copy.

Suggested approach
-    # COPIED LOGIC FROM app.py _handle_delete_failures
-    def _handle_delete_failures(self, result: DeleteResult, job: DeleteJob) -> None:
-        ...  # 60 lines of copied code
+    # Bind the real method
+    from faststack.app import AppController
+    _handle_delete_failures = AppController._handle_delete_failures

You would need to add a stub for confirm_permanent_delete / confirm_batch_permanent_delete via patching, similar to test_handle_failures.py.

🤖 Prompt for AI Agents
In `@faststack/tests/test_handle_failures_isolated.py` around lines 25 - 85, The
test copies production logic from AppController._handle_delete_failures into
MockController._handle_delete_failures causing duplication and potential drift;
instead bind the real method onto the mock like test_handle_failures.py does
using AppController._handle_delete_failures.__get__(mock, MockController) so the
test uses the production implementation, and patch any external UI prompts
(confirm_permanent_delete or confirm_batch_permanent_delete) via a stub/mock so
the method can run in tests without user interaction.

Comment on lines +6 to +19
@pytest.fixture
def model(tmp_path):
# Mocking dependencies that might trigger complex I/O or UI logic
with (
patch('faststack.thumbnail_view.model.count_images_in_folder', return_value=0),
patch('faststack.thumbnail_view.model.read_folder_stats', return_value=None),
patch('faststack.thumbnail_view.model.find_images', return_value=[]),
):
model = ThumbnailModel(tmp_path, tmp_path)
# Mock Qt-specific calls that need a running event loop or app
model.beginResetModel = Mock()
model.endResetModel = Mock()
model.selectionChanged = Mock()
return model
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the test file and explore the structure
find . -name "test_refresh_crash.py" -type f 2>/dev/null | head -5

Repository: AlanRockefeller/faststack

Length of output: 108


🏁 Script executed:

# Also locate the ThumbnailModel class
find . -path "*/thumbnail_view/model.py" -type f 2>/dev/null

Repository: AlanRockefeller/faststack

Length of output: 104


🏁 Script executed:

# Get a general view of the repository structure
git ls-files | grep -E "(thumbnail|test_refresh)" | head -20

Repository: AlanRockefeller/faststack

Length of output: 743


🏁 Script executed:

# Read the test file to understand what tests do
cat -n ./faststack/tests/test_refresh_crash.py

Repository: AlanRockefeller/faststack

Length of output: 1454


🏁 Script executed:

# Read the ThumbnailModel class to check refresh() implementation
wc -l ./faststack/thumbnail_view/model.py

Repository: AlanRockefeller/faststack

Length of output: 108


🏁 Script executed:

# Read the refresh() method implementation
sed -n '1,765p' ./faststack/thumbnail_view/model.py | grep -n "def refresh" -A 50 | head -80

Repository: AlanRockefeller/faststack

Length of output: 3493


🏁 Script executed:

# Also check the imports and beginning of the file
head -50 ./faststack/thumbnail_view/model.py

Repository: AlanRockefeller/faststack

Length of output: 1209


🏁 Script executed:

# Check if find_images can handle empty tmp_path gracefully
rg -n "def find_images" -A 20 ./faststack/io/indexer.py | head -40

Repository: AlanRockefeller/faststack

Length of output: 1043


🏁 Script executed:

# Check other test fixtures in the same test directory for comparison
grep -n "yield\|return" ./faststack/tests/test_refresh_optimization.py -B 5 -A 2 | head -50

Repository: AlanRockefeller/faststack

Length of output: 533


🏁 Script executed:

# Verify: Does find_images work with an empty directory?
# Read the full find_images function to understand error handling
sed -n '24,100p' ./faststack/io/indexer.py

Repository: AlanRockefeller/faststack

Length of output: 3202


🏁 Script executed:

# Check what actually happens in refresh() - does it use t0, t1, t2, t3?
sed -n '352,410p' ./faststack/thumbnail_view/model.py

Repository: AlanRockefeller/faststack

Length of output: 2305


🏁 Script executed:

# Also check _add_folders_to_entries to see if it calls patched functions
rg -n "_add_folders_to_entries" ./faststack/thumbnail_view/model.py -A 20 | head -40

Repository: AlanRockefeller/faststack

Length of output: 1791


🏁 Script executed:

# Check if _add_folders_to_entries calls count_images_in_folder or read_folder_stats
sed -n '306,350p' ./faststack/thumbnail_view/model.py

Repository: AlanRockefeller/faststack

Length of output: 1846


🏁 Script executed:

# Verify the full code path - check if count_images_in_folder is called anywhere in refresh
rg -n "count_images_in_folder\|read_folder_stats" ./faststack/thumbnail_view/model.py

Repository: AlanRockefeller/faststack

Length of output: 51


Patches are only active during model construction, not during test execution.

The with block exits before the fixture returns, so find_images, count_images_in_folder, and read_folder_stats are unpatched when tests call model.refresh() or model.refresh_from_controller(). Both methods invoke these functions—specifically, _add_folders_to_entries() calls read_folder_stats() and count_images_in_folder(), and refresh() calls find_images()—meaning tests execute against real implementations, not mocks, making the NameError regression check unreliable.

Use yield instead of return to keep patches active throughout test execution:

Proposed fix
 `@pytest.fixture`
 def model(tmp_path):
-    # Mocking dependencies that might trigger complex I/O or UI logic
     with (
         patch('faststack.thumbnail_view.model.count_images_in_folder', return_value=0),
         patch('faststack.thumbnail_view.model.read_folder_stats', return_value=None),
         patch('faststack.thumbnail_view.model.find_images', return_value=[]),
     ):
         model = ThumbnailModel(tmp_path, tmp_path)
-        # Mock Qt-specific calls that need a running event loop or app
         model.beginResetModel = Mock()
         model.endResetModel = Mock()
         model.selectionChanged = Mock()
-        return model
+        yield model
🤖 Prompt for AI Agents
In `@faststack/tests/test_refresh_crash.py` around lines 6 - 19, The patches in
the model fixture currently exit before tests run because the with-block ends
prior to returning the fixture; change the fixture so the patch context stays
active for the test run by yielding the model from inside the patch context (or
by creating patchers with patch(...) and starting them before constructing the
ThumbnailModel and stopping them after yielding). Specifically update the
fixture named model so the patch context that wraps
patch('faststack.thumbnail_view.model.find_images'),
patch('faststack.thumbnail_view.model.count_images_in_folder'), and
patch('faststack.thumbnail_view.model.read_folder_stats') remains active across
the yield, ensuring calls from ThumbnailModel.refresh(),
refresh_from_controller(), and _add_folders_to_entries() use the mocks during
test execution.

Comment on lines +12 to +35
@pytest.fixture
def thumbnail_model():
"""Create a ThumbnailModel with fake entries for testing."""
from faststack.thumbnail_view.model import ThumbnailModel, ThumbnailEntry

model = ThumbnailModel(
base_directory=Path("/fake/dir"),
current_directory=Path("/fake/dir"),
thumbnail_size=200,
)

# Manually add entries (bypass refresh which would scan disk)
entries = [
ThumbnailEntry(path=Path("/fake/dir/img001.jpg"), name="img001.jpg", is_folder=False, mtime_ns=1000),
ThumbnailEntry(path=Path("/fake/dir/img002.jpg"), name="img002.jpg", is_folder=False, mtime_ns=2000),
ThumbnailEntry(path=Path("/fake/dir/img003.jpg"), name="img003.jpg", is_folder=False, mtime_ns=3000),
]

model.beginResetModel()
model._entries = entries
model._rebuild_id_mapping()
model.endResetModel()

return model
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if ThumbnailModel or any pytest conftest sets up a QApplication automatically
echo "=== Check ThumbnailModel base class ==="
rg -n 'class ThumbnailModel' --type=py -A3

echo ""
echo "=== Check for qapp fixture or QCoreApplication in conftest ==="
fd conftest.py --exec cat {}

echo ""
echo "=== Check for pytest-qt in requirements ==="
fd requirements -e txt --exec grep -i 'pytest-qt' {}
fd pyproject.toml --exec grep -i 'pytest-qt' {}
fd setup.py --exec grep -i 'pytest-qt' {}
fd setup.cfg --exec grep -i 'pytest-qt' {}

Repository: AlanRockefeller/faststack

Length of output: 1452


🏁 Script executed:

#!/bin/bash
# Check the actual test file for qapp fixture or QCoreApplication setup
echo "=== Check test_thumbnail_ready_emits_datachanged.py for QCoreApplication ==="
cat faststack/tests/test_thumbnail_ready_emits_datachanged.py

echo ""
echo "=== Check other test files for qapp fixture pattern ==="
grep -r 'qapp\|QCoreApplication\|QApplication' faststack/tests/*.py | head -20

echo ""
echo "=== Check for pytest plugins ==="
fd pyproject.toml --exec cat {}
</parameter>

Repository: AlanRockefeller/faststack

Length of output: 6736


🏁 Script executed:

#!/bin/bash
# Check ThumbnailModel constructor to see if it requires QApplication
echo "=== Check ThumbnailModel constructor ==="
sed -n '87,150p' faststack/thumbnail_view/model.py

echo ""
echo "=== Check if test_recycle_bin_tracking.py has qapp setup ==="
grep -n 'QApplication\|qapp\|@pytest.fixture' faststack/tests/test_recycle_bin_tracking.py | head -20
</parameter>

Repository: AlanRockefeller/faststack

Length of output: 3167


🏁 Script executed:

#!/bin/bash
# Check if test_recycle_bin_tracking.py has qapp fixture
echo "=== Check test_recycle_bin_tracking.py for QApplication setup ==="
cat faststack/tests/test_recycle_bin_tracking.py | head -50

echo ""
echo "=== Check if any conftest sets up pytest fixtures ==="
cat faststack/tests/conftest.py

echo ""
echo "=== Search for qapp fixture in all test files ==="
grep -r '@pytest.fixture' faststack/tests/ --include='*.py' -A2 | grep -B2 'qapp'

Repository: AlanRockefeller/faststack

Length of output: 3769


Add QCoreApplication fixture to ensure Qt signal initialization works.

The test file creates a ThumbnailModel (which inherits from QAbstractListModel and connects signals in __init__) without setting up a QCoreApplication. While other test files in the suite (e.g., test_recycle_bin_tracking.py) explicitly create a QCoreApplication before instantiating Qt objects, this test does not.

Add a session-scoped qapp fixture (as shown in test_recycle_bin_tracking.py and other test files) to ensure Qt signals can be properly initialized:

`@pytest.fixture`(scope="session")
def qapp():
    """Ensure a QCoreApplication exists for Qt signal/slot connections."""
    from PySide6.QtCore import QCoreApplication
    app = QCoreApplication.instance()
    if not app:
        app = QCoreApplication([])
    return app

Then add qapp as a parameter to the thumbnail_model fixture (e.g., def thumbnail_model(qapp):).

🤖 Prompt for AI Agents
In `@faststack/tests/test_thumbnail_ready_emits_datachanged.py` around lines 12 -
35, The test creates a ThumbnailModel (which subclasses QAbstractListModel)
before a Qt application exists, so add a session-scoped qapp fixture that
ensures a QCoreApplication instance is available (create one if
QCoreApplication.instance() is None) and then accept qapp as a parameter to the
existing thumbnail_model fixture (change def thumbnail_model(): to def
thumbnail_model(qapp):) so ThumbnailModel initialization and its signal/slot
connections occur with a valid QCoreApplication; reference ThumbnailModel,
thumbnail_model fixture, and QCoreApplication/qapp when making the edits.

Comment on lines +378 to +388
if self._active_filter_flags and self._get_metadata:
flags = self._active_filter_flags
filtered = []
for img in images:
try:
meta = self._get_metadata(img.path.stem)
is_stacked = meta.get("stacked", False)
is_uploaded = meta.get("uploaded", False)
is_edited = meta.get("edited", False)
is_restacked = meta.get("restacked", False)
if all(meta.get(flag, False) for flag in flags):
filtered.append(img)
except Exception:
pass

self._entries.append(
ThumbnailEntry(
path=img.path,
name=img.path.name,
is_folder=False,
is_stacked=is_stacked,
is_uploaded=is_uploaded,
is_edited=is_edited,
is_restacked=is_restacked,
mtime_ns=mtime_ns,
)
)
pass # Skip images with metadata errors
images = filtered
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Flag filtering silently drops images when metadata callback returns non-dict objects.

meta.get(flag, False) at Line 384 assumes meta is a dict. If _get_metadata returns an EntryMetadata-like object (as the test model's _normalize_meta_flags accounts for), .get() will raise AttributeError, the bare except catches it, and the image is silently excluded. All flagged images would vanish from filtered results.

The same pattern occurs in refresh_from_controller (Lines 498-509) and _add_images_to_entries (Lines 553-561).

Consider normalizing metadata consistently (as the test model does with _normalize_meta_flags) or at minimum logging a warning:

Suggested improvement for the except block
                    except Exception:
-                        pass  # Skip images with metadata errors
+                        log.debug("Skipping image %s due to metadata error", img.path.stem, exc_info=True)
🧰 Tools
🪛 Ruff (0.15.0)

[error] 386-387: try-except-pass detected, consider logging the exception

(S110)


[warning] 386-386: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@faststack/thumbnail_view/model.py` around lines 378 - 388, The filtering
currently assumes the metadata returned by _get_metadata is a dict and uses
meta.get(...), which causes AttributeError and silently drops images; update the
filter logic in the block that uses self._active_filter_flags and
self._get_metadata to first normalize the metadata (reuse the existing
_normalize_meta_flags helper) or coerce it to a mapping-like dict before calling
.get, and replace the bare except: pass with a warning log (don’t swallow
errors) so images aren’t silently excluded; apply the same fix patterns to the
similar code paths in refresh_from_controller and _add_images_to_entries.

@AlanRockefeller AlanRockefeller merged commit dd990a1 into main Feb 16, 2026
1 check passed
@coderabbitai coderabbitai Bot mentioned this pull request Feb 16, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant