From 5a451fd95a989283702781f6a2ac345d5c109cdf Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 21 Nov 2025 16:34:47 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Release=20v1.0=20=E2=80=94=20new=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 2 + faststack/README.md | 9 +- faststack/faststack/app.py | 258 ++++++++++++++++++++------- faststack/faststack/qml/Main.qml | 2 +- faststack/faststack/ui/keystrokes.py | 7 +- 5 files changed, 206 insertions(+), 72 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 6beb6b3..f404bf2 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,5 +1,7 @@ # ChangeLog +Todo: Add image brightness control / cropping. Make batches a bit more intuitive. Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. + ## [1.0.0] - 2025-11-21 ### Major Features diff --git a/faststack/README.md b/faststack/README.md index ef981c0..154f7c5 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -19,7 +19,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. - **Theme Support:** Toggle between light and dark themes - **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) -- **Has Memory**:** Starts where you left off, tells you which images have been edited, stacked and uploaded +- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded - **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). - **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. - **Accurate Colors:** Uses monitor ICC profile to display colors correctly. @@ -40,8 +40,9 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `J` / `Right Arrow`: Next Image - `K` / `Left Arrow`: Previous Image -- `G`: Toggle Grid View (not implemented yet) -- `S` or 'X': Toggle selection of current image for stacking / drag & drop +- `G`: Go to image # +- `S`: Toggle selection of current image for stacking +- `B`: Toggle selection of current image for batch drag & drop - `[`: Begin new stack group - `]`: End current stack group - `{`: Begin new drag & drop batch @@ -51,7 +52,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - 'Ctrl+E': Toggle edited flag - 'Ctrl+S': Toggle stacked flag - `Enter`: Launch Helicon Focus with selected RAWs -- `E`: Edit in Photoshop (uses RAW file if available) +- `P`: Edit in Photoshop (uses RAW file if available) - `Delete` / `Backspace`: Move image to recycle bin - `Ctrl+Z`: Undo last delete - `Ctrl+C`: Copy image path to clipboard diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 11adf79..7a6800c 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -114,7 +114,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Stacking State -- self.stack_start_index: Optional[int] = None self.stacks: List[List[int]] = [] - self.selected_raws: set[Path] = set() + # -- Batch Selection State (for drag-and-drop) -- self.batch_start_index: Optional[int] = None @@ -599,7 +599,7 @@ def end_current_batch(self): count = end - start + 1 self.update_status_message(f"Batch defined: {count} images") else: - log.warning("No batch start marked. Press '{{' first.") + log.warning("No batch start marked. Press '{' first.") self.update_status_message("No batch start marked") def clear_all_batches(self): @@ -620,64 +620,72 @@ def remove_from_batch_or_stack(self): removed = False # Check and remove from batches - for i in range(len(self.batches)): - start, end = self.batches[i] - if start <= self.current_index <= end: - # Build new ranges excluding current_index - new_ranges = [] + new_batches = [] + batch_modified = False + for start, end in self.batches: + if not batch_modified and start <= self.current_index <= end: + # This is the batch to modify. + + # Single image batch - remove entirely by not adding anything. if start == end: - # Single image batch - remove entirely (don't add anything) pass + # Remove from beginning - shift start forward elif self.current_index == start: - # Remove from beginning - shift start forward - new_ranges.append([start + 1, end]) + new_batches.append([start + 1, end]) + # Remove from end - shift end backward elif self.current_index == end: - # Remove from end - shift end backward - new_ranges.append([start, end - 1]) + new_batches.append([start, end - 1]) + # Remove from middle - split into two ranges else: - # Remove from middle - split into two ranges - new_ranges.append([start, self.current_index - 1]) - new_ranges.append([self.current_index + 1, end]) - - # Replace the old range with new range(s) - self.batches[i:i+1] = new_ranges + new_batches.append([start, self.current_index - 1]) + new_batches.append([self.current_index + 1, end]) log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) self.update_status_message(f"Removed from batch") removed = True - break + batch_modified = True + else: + new_batches.append([start, end]) + + if batch_modified: + self.batches = new_batches # Check and remove from stacks if not removed: - for i in range(len(self.stacks)): - start, end = self.stacks[i] - if start <= self.current_index <= end: - # Build new ranges excluding current_index - new_ranges = [] + new_stacks = [] + stack_modified = False + for start, end in self.stacks: + if not stack_modified and start <= self.current_index <= end: + # This is the stack to modify. + + # Single image stack - remove entirely. if start == end: - # Single image stack - remove entirely (don't add anything) pass + # Remove from beginning elif self.current_index == start: - # Remove from beginning - shift start forward - new_ranges.append([start + 1, end]) + new_stacks.append([start + 1, end]) + # Remove from end elif self.current_index == end: - # Remove from end - shift end backward - new_ranges.append([start, end - 1]) + new_stacks.append([start, end - 1]) + # Remove from middle else: - # Remove from middle - split into two ranges - new_ranges.append([start, self.current_index - 1]) - new_ranges.append([self.current_index + 1, end]) + new_stacks.append([start, self.current_index - 1]) + new_stacks.append([self.current_index + 1, end]) - # Replace the old range with new range(s) - self.stacks[i:i+1] = new_ranges - - self.sidecar.data.stacks = self.stacks + self.sidecar.data.stacks = self.stacks # Update sidecar BEFORE self.stacks is replaced self.sidecar.save() log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) self.update_status_message(f"Removed from stack") removed = True - break - + stack_modified = True + else: + new_stacks.append([start, end]) + + if stack_modified: + self.stacks = new_stacks + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + if removed: self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -686,36 +694,157 @@ def remove_from_batch_or_stack(self): else: self.update_status_message("Not in any batch or stack") - def toggle_selection(self): - """Toggles the selection status of the current image's file (RAW if available, otherwise JPG).""" + def toggle_batch_membership(self): + """Toggles the current image's inclusion in a batch.""" if not self.image_files or self.current_index >= len(self.image_files): return - image_file = self.image_files[self.current_index] - # Use RAW if available, otherwise use JPG - file_to_select = image_file.raw_pair if image_file.raw_pair else image_file.path + index_to_toggle = self.current_index - if file_to_select in self.selected_raws: - self.selected_raws.remove(file_to_select) - log.info("Removed %s from selection.", file_to_select.name) + # Check if the image is already in a batch + in_batch = False + for start, end in self.batches: + if start <= index_to_toggle <= end: + in_batch = True + break + + new_batches = [] + if in_batch: + # Remove from batch + item_removed = False + for start, end in self.batches: + if not item_removed and start <= index_to_toggle <= end: + if start < index_to_toggle: + new_batches.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_batches.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_batches.append([start, end]) + self.batches = new_batches + self.update_status_message("Removed image from batch") + log.info("Removed index %d from a batch.", index_to_toggle) else: - self.selected_raws.add(file_to_select) - log.info("Added %s to selection.", file_to_select.name) + # Add to a new batch + self.batches.append([index_to_toggle, index_to_toggle]) + self.batches.sort() + self.update_status_message("Added image to batch") + log.info("Added index %d to a new batch.", index_to_toggle) + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + def toggle_stack_membership(self): + """Toggles the current image's inclusion in a stack.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a stack + stack_to_modify_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + stack_to_modify_idx = i + break - # In a real app, we'd update a selection indicator in the UI. - # For now, we just log and can use it for batch operations. - self.sync_ui_state() # This will trigger a UI refresh + if stack_to_modify_idx != -1: + # --- Remove from existing stack --- + new_stacks = [] + item_removed = False + for i, (start, end) in enumerate(self.stacks): + if not item_removed and i == stack_to_modify_idx: + if start < index_to_toggle: + new_stacks.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_stacks.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_stacks.append([start, end]) + self.stacks = new_stacks + self.update_status_message("Removed image from stack") + log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) + + else: + # --- Add to nearest stack --- + if not self.stacks: + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) + else: + # Find closest stack + dist_backward = float('inf') + stack_idx_backward = -1 + for i in range(index_to_toggle - 1, -1, -1): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_backward = index_to_toggle - i + stack_idx_backward = j + break + if stack_idx_backward != -1: + break + + dist_forward = float('inf') + stack_idx_forward = -1 + for i in range(index_to_toggle + 1, len(self.image_files)): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_forward = i - index_to_toggle + stack_idx_forward = j + break + if stack_idx_forward != -1: + break + + if stack_idx_backward == -1 and stack_idx_forward == -1: + # This case should be covered by `if not self.stacks`, but as a fallback. + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) + else: + if dist_backward <= dist_forward: + stack_to_join_idx = stack_idx_backward + else: + stack_to_join_idx = stack_idx_forward + + start, end = self.stacks[stack_to_join_idx] + self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] + + # Merge overlapping stacks + self.stacks.sort() + merged_stacks = [self.stacks[0]] if self.stacks else [] + for i in range(1, len(self.stacks)): + last_start, last_end = merged_stacks[-1] + current_start, current_end = self.stacks[i] + if current_start <= last_end + 1: + merged_stacks[-1] = [last_start, max(last_end, current_end)] + else: + merged_stacks.append([current_start, current_end]) + self.stacks = merged_stacks + + # Find the new stack index for the status message + new_stack_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + new_stack_idx = i + break + + self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") + log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + + def launch_helicon(self): """Launches Helicon Focus with selected files (RAW preferred, JPG fallback) or stacks.""" - if self.selected_raws: - log.info("Launching Helicon with %d selected files.", len(self.selected_raws)) - success = self._launch_helicon_with_files(sorted(list(self.selected_raws))) - if success: - self.selected_raws.clear() - - elif self.stacks: + if self.stacks: log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) any_success = False for start, end in self.stacks: @@ -1314,9 +1443,10 @@ def start_drag_current_image(self): if 0 <= idx < len(self.image_files): files_to_drag.add(idx) - # Convert to sorted list and get paths + # Convert to sorted list and get only existing paths file_indices = sorted(files_to_drag) - file_paths = [self.image_files[idx].path for idx in file_indices if self.image_files[idx].path.exists()] + existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] + file_paths = [self.image_files[idx].path for idx in existing_indices] if not file_paths: log.error("No valid files to drag") @@ -1355,8 +1485,8 @@ def start_drag_current_image(self): if result in (Qt.CopyAction, Qt.MoveAction): from datetime import datetime today = datetime.now().strftime("%Y-%m-%d") - - for idx in file_indices: + + for idx in existing_indices: stem = self.image_files[idx].path.stem meta = self.sidecar.get_metadata(stem) meta.uploaded = True @@ -1371,7 +1501,7 @@ def start_drag_current_image(self): self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() - log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(file_indices), today) + log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) def _get_stack_info(self, index: int) -> str: info = "" @@ -1393,7 +1523,7 @@ def _get_batch_info(self, index: int) -> str: if start <= index <= end: count_in_batch = end - start + 1 pos_in_batch = index - start + 1 - info = f"Batch {i+1} ({pos_in_batch}/{count_in_batch})" + info = "In Batch" break if not info and self.batch_start_index is not None and self.batch_start_index == index: info = "Batch Start Marked" diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index c14b1a5..0ac92e2 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -382,7 +382,7 @@ ApplicationWindow { "  Ctrl+Z: Undo last delete

" + "Actions:
" + "  Enter: Launch Helicon Focus
" + - "  E: Edit in Photoshop
" + + "  P: Edit in Photoshop
" + "  Ctrl+C: Copy image path to clipboard" padding: 10 wrapMode: Text.WordWrap diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 90cef44..84b4a25 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -26,15 +26,16 @@ def __init__(self, controller): # Stacking Qt.Key_BracketLeft: "begin_new_stack", Qt.Key_BracketRight: "end_current_stack", - + Qt.Key_S: "toggle_stack_membership", + # Batching Qt.Key_BraceLeft: "begin_new_batch", Qt.Key_BraceRight: "end_current_batch", Qt.Key_Backslash: "clear_all_batches", + Qt.Key_B: "toggle_batch_membership", # Remove from batch/stack Qt.Key_X: "remove_from_batch_or_stack", - Qt.Key_S: "remove_from_batch_or_stack", # Toggle flags Qt.Key_U: "toggle_uploaded", @@ -42,7 +43,7 @@ def __init__(self, controller): # Actions Qt.Key_Enter: "launch_helicon", Qt.Key_Return: "launch_helicon", - Qt.Key_E: "edit_in_photoshop", + Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", Qt.Key_Delete: "delete_current_image", Qt.Key_Backspace: "delete_current_image", From 83bf949b19103ff68e34858f9b1c184136504f80 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 22 Nov 2025 03:04:37 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Release=20v1.1=20=E2=80=94=20has=20an=20ima?= =?UTF-8?q?ge=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack.working-menus/ChangeLog.md | 172 + faststack.working-menus/LICENSE | 25 + faststack.working-menus/README.md | 60 + .../faststack.egg-info/PKG-INFO | 72 + .../faststack.egg-info/SOURCES.txt | 14 + .../faststack.egg-info/dependency_links.txt | 1 + .../faststack.egg-info/entry_points.txt | 2 + .../faststack.egg-info/requires.txt | 8 + .../faststack.egg-info/top_level.txt | 1 + faststack.working-menus/faststack/__init__.py | 0 faststack.working-menus/faststack/app.py | 1630 +++++++++ .../faststack/app.py.bak | 0 .../faststack/benchmark_decode.py | 20 + faststack.working-menus/faststack/config.py | 86 + .../faststack/imaging/cache.py | 45 + .../faststack/imaging/jpeg.py | 149 + .../faststack/imaging/prefetch.py | 487 +++ .../faststack/io/executable_validator.py | 112 + .../faststack/io/helicon.py | 91 + .../faststack/io/indexer.py | 84 + .../faststack/io/sidecar.py | 98 + .../faststack/io/watcher.py | 74 + .../faststack/logging_setup.py | 46 + faststack.working-menus/faststack/models.py | 44 + .../faststack/qml/Components.qml | 149 + .../faststack/qml/FilterDialog.qml | 78 + .../faststack/qml/JumpToImageDialog.qml | 79 + .../faststack/qml/Main.qml | 435 +++ .../faststack/qml/Main.qml.bak | 295 ++ .../faststack/qml/SettingsDialog.qml | 172 + .../faststack/tests/test_cache.py | 61 + .../tests/test_executable_validator.py | 131 + .../faststack/tests/test_pairing.py | 74 + .../faststack/tests/test_sidecar.py | 74 + .../faststack/ui/keystrokes.py | 109 + .../faststack/ui/keystrokes.py.bak | 45 + .../faststack/ui/provider.py | 343 ++ .../faststack/ui/provider.py.bak | 247 ++ faststack.working-menus/patch | 746 +++++ faststack.working-menus/pyproject.toml | 34 + faststack.working-menus/requirements.txt | 9 + faststack/ChangeLog.md | 29 +- faststack/README.md | 10 +- faststack/faststack.egg-info/PKG-INFO | 4 +- faststack/faststack/app.py | 488 ++- faststack/faststack/imaging/editor.py | 296 ++ faststack/faststack/qml/Components.qml | 2 + faststack/faststack/qml/ImageEditorDialog.qml | 230 ++ .../faststack/qml/ImageEditorDialog.qml.new | 219 ++ .../faststack/qml/ImageEditorDialog.qml.old | 196 ++ faststack/faststack/qml/Main.qml | 102 +- faststack/faststack/ui/keystrokes.py | 1 + faststack/faststack/ui/provider.py | 299 ++ faststack/patch | 746 +++++ faststack/pyproject.toml | 2 +- z | 2977 +++++++++++++++++ 56 files changed, 11916 insertions(+), 87 deletions(-) create mode 100644 faststack.working-menus/ChangeLog.md create mode 100644 faststack.working-menus/LICENSE create mode 100644 faststack.working-menus/README.md create mode 100644 faststack.working-menus/faststack.egg-info/PKG-INFO create mode 100644 faststack.working-menus/faststack.egg-info/SOURCES.txt create mode 100644 faststack.working-menus/faststack.egg-info/dependency_links.txt create mode 100644 faststack.working-menus/faststack.egg-info/entry_points.txt create mode 100644 faststack.working-menus/faststack.egg-info/requires.txt create mode 100644 faststack.working-menus/faststack.egg-info/top_level.txt create mode 100644 faststack.working-menus/faststack/__init__.py create mode 100644 faststack.working-menus/faststack/app.py rename {faststack => faststack.working-menus}/faststack/app.py.bak (100%) create mode 100644 faststack.working-menus/faststack/benchmark_decode.py create mode 100644 faststack.working-menus/faststack/config.py create mode 100644 faststack.working-menus/faststack/imaging/cache.py create mode 100644 faststack.working-menus/faststack/imaging/jpeg.py create mode 100644 faststack.working-menus/faststack/imaging/prefetch.py create mode 100644 faststack.working-menus/faststack/io/executable_validator.py create mode 100644 faststack.working-menus/faststack/io/helicon.py create mode 100644 faststack.working-menus/faststack/io/indexer.py create mode 100644 faststack.working-menus/faststack/io/sidecar.py create mode 100644 faststack.working-menus/faststack/io/watcher.py create mode 100644 faststack.working-menus/faststack/logging_setup.py create mode 100644 faststack.working-menus/faststack/models.py create mode 100644 faststack.working-menus/faststack/qml/Components.qml create mode 100644 faststack.working-menus/faststack/qml/FilterDialog.qml create mode 100644 faststack.working-menus/faststack/qml/JumpToImageDialog.qml create mode 100644 faststack.working-menus/faststack/qml/Main.qml create mode 100644 faststack.working-menus/faststack/qml/Main.qml.bak create mode 100644 faststack.working-menus/faststack/qml/SettingsDialog.qml create mode 100644 faststack.working-menus/faststack/tests/test_cache.py create mode 100644 faststack.working-menus/faststack/tests/test_executable_validator.py create mode 100644 faststack.working-menus/faststack/tests/test_pairing.py create mode 100644 faststack.working-menus/faststack/tests/test_sidecar.py create mode 100644 faststack.working-menus/faststack/ui/keystrokes.py create mode 100644 faststack.working-menus/faststack/ui/keystrokes.py.bak create mode 100644 faststack.working-menus/faststack/ui/provider.py create mode 100644 faststack.working-menus/faststack/ui/provider.py.bak create mode 100644 faststack.working-menus/patch create mode 100644 faststack.working-menus/pyproject.toml create mode 100644 faststack.working-menus/requirements.txt create mode 100644 faststack/faststack/imaging/editor.py create mode 100644 faststack/faststack/qml/ImageEditorDialog.qml create mode 100644 faststack/faststack/qml/ImageEditorDialog.qml.new create mode 100644 faststack/faststack/qml/ImageEditorDialog.qml.old create mode 100644 faststack/patch create mode 100644 z diff --git a/faststack.working-menus/ChangeLog.md b/faststack.working-menus/ChangeLog.md new file mode 100644 index 0000000..f404bf2 --- /dev/null +++ b/faststack.working-menus/ChangeLog.md @@ -0,0 +1,172 @@ +# ChangeLog + +Todo: Add image brightness control / cropping. Make batches a bit more intuitive. Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. + +## [1.0.0] - 2025-11-21 + +### Major Features +- **Batch Selection System:** New batch selection mode for drag-and-drop operations + - `{` to begin batch, `}` to end batch, `\` to clear all batches + - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) + - Batches automatically cleared after successful drag operation + - Multiple files can now be dragged to browsers and external applications simultaneously +- **Manual Flag Toggles:** Added keyboard shortcuts to manually control metadata flags + - `U` toggles uploaded flag + - `Ctrl+E` toggles edited flag + - `Ctrl+S` toggles stacked flag +- **Edited Flag Tracking:** New metadata flag for images edited in Photoshop + - Displays "Edited on [date]" in status bar (green) + - Can be manually toggled with `Ctrl+E` +- **Jump to Image Dialog:** Press `G` to jump directly to any image by number + - Dynamic input field sizing based on image count + - Proper keyboard event capture while dialog is open + +### UI/UX Improvements +- **Auto Zoom Reset:** Image view automatically resets to fit-window after drag operations +- **Smooth Window Dragging:** Fixed flickering when dragging title bar by using global coordinates +- **Status Bar Enhancements:** + - Added batch info display (green badge showing position/count) + - Added uploaded status display + - Added edited status display + +### Bug Fixes +- **Multi-file Drag:** Simplified drag implementation to work correctly with Chrome and other browsers + +## [0.9.0] - 2025-11-20 + +### Performance Improvements +- **Zero-Copy JPEG Read:** Eliminated memory copy by passing mmap directly to decoders, reducing I/O time by 25-60% for large JPEGs. +- **Filter Performance:** Cached image list in memory to eliminate disk scans on every filter keystroke (100-1000x faster for large directories). +- **Smart Cache Management:** Removed unnecessary cache clearing on resize/zoom - LRU naturally evicts old entries while allowing instant reuse. +- **Generation Thrashing Fix:** Navigation no longer increments generation counter, preventing cache invalidation on every keystroke. +- **Directional Prefetching:** Asymmetric prefetch now biases 70% ahead and 30% behind in travel direction for faster sequential browsing. +- **ICC Transform Caching:** Cached ICC color transforms to eliminate repeated transform builds during color-managed viewing. +- **TurboJPEG for ICC:** ICC color path now uses TurboJPEG for decode+resize, then Pillow only for color conversion. + +### Features +- **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. +- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. +- **Added a Jump to Photo feature that can be activated by pressing the G key + +## [0.8.0] - 2025-11-20 + +### Added +- Backspace key now deletes images (in addition to Delete key). Control-Z restores. +- Photoshop integration now automatically uses RAW files when available, falling back to JPG. +- We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. + +## [0.7.0] - 2025-11-20 + +### Added +- **High-DPI Display Support:** Images now render at full physical pixel resolution on 4K displays by accounting for `devicePixelRatio` in display size calculations. +- **Ctrl+0 Zoom Reset:** Added keyboard shortcut to reset zoom and pan to fit window (like Photoshop), with visual feedback. +- **Active Filter Indicator:** Footer now displays active filename filter in yellow bold text for better visibility. +- **Directory Path Display:** Title bar now shows the current working directory path, centered between menu and window controls. + +### Fixed +- **Property Name Mismatch:** Corrected `get_stack_summary` to `stackSummary` in UIState to match QML property naming conventions. +- **FilterDialog Theme Support:** Enhanced FilterDialog with proper Material theme support and background styling for consistent dark/light mode appearance. +- **Missing Signal Emissions:** Added `stackSummaryChanged` signal emission when stacks are created, cleared, or processed. + +### Changed +- **Improved Error Handling:** Replaced broad `Exception` catches with specific exception types (`OSError`, `subprocess.SubprocessError`, `FileNotFoundError`, `IOError`, `PermissionError`). +- **Better Logging:** Changed `log.error()` to `log.exception()` to include full tracebacks for debugging. +- **Argument Parsing:** Now uses `shlex.split()` with platform-aware parsing (Windows vs POSIX) for proper handling of quoted paths and special characters. + +### Testing +- **Executable Validator Tests:** Added comprehensive test suite for executable path validation with 8 test cases covering various security scenarios. + +## [0.6.0] - 2025-11-03 + +### Fixed +- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings. +- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary. +- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded. +- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in. +- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform. +- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI. + +## [0.5.0] - 2025-11-03 + +### Added +- Load full-resolution images when zooming in for maximum detail. +- Call Helicon Focus for each defined stack when multiple stacks are present. + +### Changed +- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. +- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. +- Stacks are now cleared automatically after being sent to Helicon Focus. + +### Fixed +- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. +- Resolved an `AttributeError` in the JPEG scaling factor calculation. +- Fixed an issue where panning the image was not working correctly. +- Addressed a bug where panning speed was incorrect at high zoom levels. +- Ensured that stale prefetcher futures are cancelled when the display size changes. + +### Performance +- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. +- Tuned the number of prefetcher thread pool workers based on system CPU cores. +- Replaced synchronous file reads with memory-mapped I/O for faster image loading. +- Optimized image resizing by using `BILINEAR` resampling for large downscales. +- Debounced display size change notifications to reduce redundant UI updates. + +## Version 0.4 + +### Todo + +Make it use the full res image when zooming in +When multiple stacks are selected, call Helicon multiple times +After Helicon is called, clear the stacks +Fix S key - I guess it should remove an image from the stack? Clarify what it does now. + +### New Features +- **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage. +- **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images. +- **Progress bar for preloading:** Introduced a visual progress bar in the footer to display the status of the "Preload All Images" operation. + +### Changes +- **Theming improvements:** Adjusted the Material theme to ensure the menubar background is black in dark mode, providing a more consistent user experience. +- **Window behavior:** Changed the application window to a borderless fullscreen mode, allowing for normal Alt-Tab behavior and better integration with the operating system. + +## Version 0.3 + +### New Features +- Implemented a "Settings" dialog with the following configurable options: + - Helicon Focus executable path (with validation). + - Image cache size (in GB). + - Image prefetch radius. + - Application theme (Dark/Light). + - Default image directory. + +## Version 0.2 + +### New Features +- Added an "Actions" menu with the following options: + - "Run Stacks": Launch Helicon Focus with selected files or all stacks. + - "Clear Stacks": Clear all defined stacks. + - "Show Stacks": Display a dialog with information about the defined stacks. +- Pressing the 'S' key now adds or removes a RAW file from the selection for processing. +- Implemented tracking for stacked images: + - `EntryMetadata` now includes `stacked` (boolean) and `stacked_date` (string) fields. + - `launch_helicon` records stacking status and date upon successful launch. + - The footer in `Main.qml` displays "Stacked: [date]" for previously stacked images. + +### Changes +- Pressing the 'Enter' key will now launch Helicon Focus with the selected RAW files. If no files are selected, it will launch with all defined stacks. +- Refactored the theme toggling logic in `Main.qml` to use a boolean `isDarkTheme` property for more robustness. + +### Bug Fixes +- Fixed an issue where both the main "Enter" key and the numeric keypad "Enter" key were not consistently recognized. +- The "Show Stacks" and "Key Bindings" dialogs now correctly follow the application's theme (light/dark mode). +- Fixed a bug that caused the "Show Stacks" dialog to be blank. +- Resolved a `NameError` caused by using `Optional` without importing it. +- Corrected an import error for `EntryMetadata` in the tests. +- Updated a test to assert the correct default version number. +- Fixed a `TypeError` in tests caused by a missing `stack_id` field in the `EntryMetadata` model. +- Resolved a QML issue where `anchors.fill` conflicted with manual positioning, preventing panning and zooming. +- Corrected the `launch_helicon` method to only clear the `selected_raws` set if Helicon Focus is launched successfully. +- Resolved `TypeError` and `Invalid property assignment` errors in QML related to settings dialog initialization and property bindings. +- Fixed QML warnings related to invalid anchor usage in `Main.qml`. +- Fixed missing minimize, maximize, and close buttons by correctly configuring the custom title bar. +- Resolved QML warnings about `mouse` parameter not being declared in `MouseArea` signal handlers. diff --git a/faststack.working-menus/LICENSE b/faststack.working-menus/LICENSE new file mode 100644 index 0000000..57713b7 --- /dev/null +++ b/faststack.working-menus/LICENSE @@ -0,0 +1,25 @@ +The MIT License (MIT) +===================== + +Copyright © 2025 Alan Rockefeller + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/faststack.working-menus/README.md b/faststack.working-menus/README.md new file mode 100644 index 0000000..154f7c5 --- /dev/null +++ b/faststack.working-menus/README.md @@ -0,0 +1,60 @@ +# FastStack + +# Version 1.0 - November 21, 2025 +# By Alan Rockefeller + +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. + +This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. + +## Features + +- **Instant Navigation:** Sub-10ms next/previous image switching, high peformance decoding via `PyTurboJPEG`. +- **Zoom & Pan:** Smooth zooming and panning. +- **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). +- **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). +- **Photoshop Integration:** Edit current image in Photoshop (E key) - uses RAW files when available +- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) +- **Image Filtering:** Filter images by filename +- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. +- **Theme Support:** Toggle between light and dark themes +- **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) +- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded +- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. +- **Accurate Colors:** Uses monitor ICC profile to display colors correctly. + +## Installation & Usage + +1. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the App:** + ```bash + python -m faststack.app "C:\path\to\your\images" + ``` + +## Keyboard Shortcuts + +- `J` / `Right Arrow`: Next Image +- `K` / `Left Arrow`: Previous Image +- `G`: Go to image # +- `S`: Toggle selection of current image for stacking +- `B`: Toggle selection of current image for batch drag & drop +- `[`: Begin new stack group +- `]`: End current stack group +- `{`: Begin new drag & drop batch +- `}`: End current drag & drop batch +- '\': Clear drag & drop batch +- 'U': Toggle uploaded flag +- 'Ctrl+E': Toggle edited flag +- 'Ctrl+S': Toggle stacked flag +- `Enter`: Launch Helicon Focus with selected RAWs +- `P`: Edit in Photoshop (uses RAW file if available) +- `Delete` / `Backspace`: Move image to recycle bin +- `Ctrl+Z`: Undo last delete +- `Ctrl+C`: Copy image path to clipboard +- `Ctrl+0`: Reset zoom and pan +- `C`: Clear all stacks diff --git a/faststack.working-menus/faststack.egg-info/PKG-INFO b/faststack.working-menus/faststack.egg-info/PKG-INFO new file mode 100644 index 0000000..d2ad528 --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/PKG-INFO @@ -0,0 +1,72 @@ +Metadata-Version: 2.4 +Name: faststack +Version: 1.0 +Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload +Author-email: Alan Rockefeller +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: Microsoft :: Windows +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: PySide6<7.0,>=6.0 +Requires-Dist: PyTurboJPEG<2.0,>=1.8 +Requires-Dist: numpy<3.0,>=2.0 +Requires-Dist: cachetools<6.0,>=5.0 +Requires-Dist: watchdog<5.0,>=4.0 +Requires-Dist: typer<1.0,>=0.12 +Requires-Dist: Pillow<11.0,>=10.0 +Requires-Dist: pytest<9.0,>=8.0 +Dynamic: license-file + +# FastStack + +# Version 1.0 - November 21, 2025 +# By Alan Rockefeller + +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. + +This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. + +## Features + +- **Instant Navigation:** Sub-10ms next/previous image switching on cache hits. +- **High-Performance Decoding:** Uses `PyTurboJPEG` for fast JPEG decoding, with a fallback to `Pillow`. +- **Zoom & Pan:** Smooth, mipmapped zooming and panning. +- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). +- **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). +- **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. +- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. +- **Photoshop Integration:** Edit current image in Photoshop (E key) +- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) +- **Image Filtering:** Filter images by filename +- **Drag & Drop:** Drag images to external applications +- **Theme Support:** Toggle between light and dark themes + +## Installation & Usage + +1. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the App:** + ```bash + python -m faststack.app "C:\path\to\your\images" + ``` + +## Keyboard Shortcuts + +- `J` / `Right Arrow`: Next Image +- `K` / `Left Arrow`: Previous Image +- `G`: Toggle Grid View +- `S`: Toggle selection of current image for stacking +- `[`: Begin new stack group +- `]`: End current stack group +- `Space`: Toggle Flag +- `X`: Toggle Reject +- `Enter`: Launch Helicon Focus with selected RAWs +- `E`: Edit in Photoshop +- `Ctrl+C`: Copy image path to clipboard +- `C`: Clear all stacks diff --git a/faststack.working-menus/faststack.egg-info/SOURCES.txt b/faststack.working-menus/faststack.egg-info/SOURCES.txt new file mode 100644 index 0000000..49773ce --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/SOURCES.txt @@ -0,0 +1,14 @@ +LICENSE +README.md +pyproject.toml +faststack/__init__.py +faststack/app.py +faststack/config.py +faststack/logging_setup.py +faststack/models.py +faststack.egg-info/PKG-INFO +faststack.egg-info/SOURCES.txt +faststack.egg-info/dependency_links.txt +faststack.egg-info/entry_points.txt +faststack.egg-info/requires.txt +faststack.egg-info/top_level.txt \ No newline at end of file diff --git a/faststack.working-menus/faststack.egg-info/dependency_links.txt b/faststack.working-menus/faststack.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/faststack.working-menus/faststack.egg-info/entry_points.txt b/faststack.working-menus/faststack.egg-info/entry_points.txt new file mode 100644 index 0000000..31dfe0b --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +faststack = faststack.app:main diff --git a/faststack.working-menus/faststack.egg-info/requires.txt b/faststack.working-menus/faststack.egg-info/requires.txt new file mode 100644 index 0000000..5694239 --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/requires.txt @@ -0,0 +1,8 @@ +PySide6<7.0,>=6.0 +PyTurboJPEG<2.0,>=1.8 +numpy<3.0,>=2.0 +cachetools<6.0,>=5.0 +watchdog<5.0,>=4.0 +typer<1.0,>=0.12 +Pillow<11.0,>=10.0 +pytest<9.0,>=8.0 diff --git a/faststack.working-menus/faststack.egg-info/top_level.txt b/faststack.working-menus/faststack.egg-info/top_level.txt new file mode 100644 index 0000000..81352aa --- /dev/null +++ b/faststack.working-menus/faststack.egg-info/top_level.txt @@ -0,0 +1 @@ +faststack diff --git a/faststack.working-menus/faststack/__init__.py b/faststack.working-menus/faststack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/faststack.working-menus/faststack/app.py b/faststack.working-menus/faststack/app.py new file mode 100644 index 0000000..7a6800c --- /dev/null +++ b/faststack.working-menus/faststack/app.py @@ -0,0 +1,1630 @@ +"""Main application entry point for FastStack.""" + +import logging +import sys +import struct +import shlex +import time +import argparse +from pathlib import Path +from typing import Optional, List, Dict +from datetime import date +import os +import concurrent.futures +import threading +import subprocess +from faststack.ui.provider import ImageProvider, UIState +from PySide6.QtGui import QDrag, QPixmap +from PySide6.QtCore import ( + QUrl, + QTimer, + QObject, + QEvent, + Signal, + Slot, + QMimeData, + Qt, + QPoint +) +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox +from PySide6.QtQml import QQmlApplicationEngine + +# ⬇️ these are the ones that went missing +from faststack.config import config +from faststack.logging_setup import setup_logging +from faststack.models import ImageFile, DecodedImage, EntryMetadata +from faststack.io.indexer import find_images +from faststack.io.sidecar import SidecarManager +from faststack.io.watcher import Watcher +from faststack.io.helicon import launch_helicon_focus +from faststack.io.executable_validator import validate_executable_path +from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size +from faststack.imaging.prefetch import Prefetcher, clear_icc_caches +from faststack.ui.provider import ImageProvider +from faststack.ui.keystrokes import Keybinder + +def make_hdrop(paths): + """ + Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. + paths: list[str] + """ + files_part = ("\0".join(paths) + "\0\0").encode("utf-16le") + + # DROPFILES header (20 bytes): bool: + # Don't handle key events when a dialog is open + if self._dialog_open: + return False + + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + handled = self.keybinder.handle_key_press(event) + if handled: + return True + return super().eventFilter(watched, event) + + def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): + """Helper to defer prefetch until display size is stable. + + Args: + index: The index to prefetch around + is_navigation: True if called from user navigation (arrow keys, etc.) + direction: 1 for forward, -1 for backward, None to use last direction + """ + # If navigation occurs during resize debounce, cancel timer and apply resize immediately + # to ensure prefetch uses correct dimensions + if is_navigation and self.resize_timer.isActive(): + self.resize_timer.stop() + self._handle_resize() + + if not self.display_ready: + log.debug("Display not ready, deferring prefetch for index %d", index) + self.pending_prefetch_index = index + return + self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) + + def load(self): + """Loads images, sidecar data, and starts services.""" + self.refresh_image_list() # Initial scan from disk + if not self.image_files: + self.current_index = 0 + else: + self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) + self.stacks = self.sidecar.data.stacks # Load stacks from sidecar + self.dataChanged.emit() # Emit after stacks are loaded + self.watcher.start() + self._do_prefetch(self.current_index) + + # Defer initial UI sync until after images are loaded + self.sync_ui_state() + + + def refresh_image_list(self): + """Rescans the directory for images from disk and updates cache. + + This does a full disk scan and should only be called when: + - Application starts (load()) + - Directory watcher detects file changes + - User explicitly refreshes + + For filtering, use _apply_filter_to_cached_list() instead. + """ + self._all_images = find_images(self.image_dir) + self._apply_filter_to_cached_list() + + def _apply_filter_to_cached_list(self): + """Applies current filter to cached image list without disk I/O.""" + if self._filter_enabled and self._filter_string: + needle = self._filter_string.lower() + self.image_files = [ + img for img in self._all_images + if needle in img.path.stem.lower() + ] + else: + self.image_files = self._all_images + + self.prefetcher.set_image_files(self.image_files) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.ui_state.imageCountChanged.emit() + + def get_decoded_image(self, index: int) -> Optional[DecodedImage]: + """Retrieves a decoded image, blocking until ready to ensure correct display. + + This blocks the UI thread on cache miss, but that's acceptable for an image viewer + where users expect to see the correct image immediately. The prefetcher minimizes + cache misses by decoding adjacent images in advance. + """ + if not self.image_files or index < 0 or index >= len(self.image_files): + log.warning("get_decoded_image called with empty image_files or out of bounds index.") + return None + + _, _, display_gen = self.get_display_info() + cache_key = f"{index}_{display_gen}" + + # Check cache first + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + return decoded + + # Cache miss: need to decode synchronously to ensure correct image displays + if _debug_mode: + decode_start = time.perf_counter() + log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) + + # Submit with priority=True to cancel pending prefetch tasks and free up workers + future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + if future: + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + if result: + decoded_index, decoded_display_gen = result + cache_key = f"{decoded_index}_{decoded_display_gen}" + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + if _debug_mode: + elapsed = time.perf_counter() - decode_start + log.info("Decoded image %d in %.3fs", index, elapsed) + return decoded + except concurrent.futures.TimeoutError: + log.exception("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.warning("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception as e: + log.exception("Error decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + with self._last_image_lock: + return self.last_displayed_image + + def sync_ui_state(self): + """Forces the UI to update by emitting all state change signals.""" + self.ui_refresh_generation += 1 + self._metadata_cache_index = (-1, -1) # Invalidate cache + + # tell QML that index and image changed + self.ui_state.currentIndexChanged.emit() + self.ui_state.currentImageSourceChanged.emit() + + # this is the one your footer needs + self.ui_state.metadataChanged.emit() + + log.debug( + "UI State Synced: Index=%d, Count=%d", + self.ui_state.currentIndex, + self.ui_state.imageCount + ) + log.debug( + "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", + self.ui_state.currentFilename, + self.ui_state.isUploaded, + self.ui_state.stackInfoText, + self.ui_state.batchInfoText + ) + + + # --- Actions --- + + def next_image(self): + if self.current_index < len(self.image_files) - 1: + self.current_index += 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=1) + self.sync_ui_state() + + def prev_image(self): + if self.current_index > 0: + self.current_index -= 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=-1) + self.sync_ui_state() + + @Slot(int) + def jump_to_image(self, index: int): + """Jump to a specific image by index (0-based).""" + if 0 <= index < len(self.image_files): + direction = 1 if index > self.current_index else -1 + self.current_index = index + self._do_prefetch(self.current_index, is_navigation=True, direction=direction) + self.sync_ui_state() + self.update_status_message(f"Jumped to image {index + 1}") + else: + log.warning("Invalid image index: %d", index) + self.update_status_message("Invalid image number") + + def show_jump_to_image_dialog(self): + """Shows the jump to image dialog (called from keybinder).""" + if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): + self.main_window.show_jump_to_image_dialog() + else: + log.warning("Cannot open jump to image dialog: main_window or function not available") + + @Slot() + def dialog_opened(self): + """Called when any dialog opens to disable global keybindings.""" + self._dialog_open = True + log.debug("Dialog opened, disabling global keybindings") + + @Slot() + def dialog_closed(self): + """Called when any dialog closes to re-enable global keybindings.""" + self._dialog_open = False + log.debug("Dialog closed, re-enabling global keybindings") + + def toggle_grid_view(self): + log.warning("Grid view not implemented yet.") + + def toggle_uploaded(self): + """Toggle uploaded flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.uploaded = not meta.uploaded + if meta.uploaded: + meta.uploaded_date = today + else: + meta.uploaded_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "uploaded" if meta.uploaded else "not uploaded" + self.update_status_message(f"Marked as {status}") + log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) + + def toggle_edited(self): + """Toggle edited flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.edited = not meta.edited + if meta.edited: + meta.edited_date = today + else: + meta.edited_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "edited" if meta.edited else "not edited" + self.update_status_message(f"Marked as {status}") + log.info("Toggled edited flag to %s for %s", meta.edited, stem) + + def toggle_stacked(self): + """Toggle stacked flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.stacked = not meta.stacked + if meta.stacked: + meta.stacked_date = today + else: + meta.stacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "stacked" if meta.stacked else "not stacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) + + def get_current_metadata(self) -> Dict: + if not self.image_files or self.current_index >= len(self.image_files): + if not self._logged_empty_metadata: + log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") + self._logged_empty_metadata = True + return {} + self._logged_empty_metadata = False + + # Cache hit check + cache_key = (self.current_index, self.ui_refresh_generation) + if cache_key == self._metadata_cache_index: + return self._metadata_cache + + # Compute and cache + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + stack_info = self._get_stack_info(self.current_index) + batch_info = self._get_batch_info(self.current_index) + + self._metadata_cache = { + "filename": self.image_files[self.current_index].path.name, + "stacked": meta.stacked, + "stacked_date": meta.stacked_date or "", + "uploaded": meta.uploaded, + "uploaded_date": meta.uploaded_date or "", + "edited": meta.edited, + "edited_date": meta.edited_date or "", + "stack_info_text": stack_info, + "batch_info_text": batch_info + } + self._metadata_cache_index = cache_key + return self._metadata_cache + + def begin_new_stack(self): + self.stack_start_index = self.current_index + log.info("Stack start marked at index %d", self.stack_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Update UI to show start marker + self.sync_ui_state() + + def end_current_stack(self): + log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) + if self.stack_start_index is not None: + start = min(self.stack_start_index, self.current_index) + end = max(self.stack_start_index, self.current_index) + self.stacks.append([start, end]) + self.stacks.sort() # Keep stacks sorted by start index + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + log.info("Defined new stack: [%d, %d]", start, end) + self.stack_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self.sync_ui_state() + else: + log.warning("No stack start marked. Press '[' first.") + + def begin_new_batch(self): + """Mark the start of a new batch for drag-and-drop.""" + self.batch_start_index = self.current_index + log.info("Batch start marked at index %d", self.batch_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("Batch start marked") + + def end_current_batch(self): + """End the current batch and save the range.""" + log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) + if self.batch_start_index is not None: + start = min(self.batch_start_index, self.current_index) + end = max(self.batch_start_index, self.current_index) + self.batches.append([start, end]) + self.batches.sort() # Keep batches sorted by start index + log.info("Defined new batch: [%d, %d]", start, end) + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + count = end - start + 1 + self.update_status_message(f"Batch defined: {count} images") + else: + log.warning("No batch start marked. Press '{' first.") + self.update_status_message("No batch start marked") + + def clear_all_batches(self): + """Clear all defined batches.""" + log.info("Clearing all defined batches.") + self.batches = [] + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("All batches cleared") + + def remove_from_batch_or_stack(self): + """Remove current image from any batch or stack it's in.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + removed = False + + # Check and remove from batches + new_batches = [] + batch_modified = False + for start, end in self.batches: + if not batch_modified and start <= self.current_index <= end: + # This is the batch to modify. + + # Single image batch - remove entirely by not adding anything. + if start == end: + pass + # Remove from beginning - shift start forward + elif self.current_index == start: + new_batches.append([start + 1, end]) + # Remove from end - shift end backward + elif self.current_index == end: + new_batches.append([start, end - 1]) + # Remove from middle - split into two ranges + else: + new_batches.append([start, self.current_index - 1]) + new_batches.append([self.current_index + 1, end]) + + log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from batch") + removed = True + batch_modified = True + else: + new_batches.append([start, end]) + + if batch_modified: + self.batches = new_batches + + # Check and remove from stacks + if not removed: + new_stacks = [] + stack_modified = False + for start, end in self.stacks: + if not stack_modified and start <= self.current_index <= end: + # This is the stack to modify. + + # Single image stack - remove entirely. + if start == end: + pass + # Remove from beginning + elif self.current_index == start: + new_stacks.append([start + 1, end]) + # Remove from end + elif self.current_index == end: + new_stacks.append([start, end - 1]) + # Remove from middle + else: + new_stacks.append([start, self.current_index - 1]) + new_stacks.append([self.current_index + 1, end]) + + self.sidecar.data.stacks = self.stacks # Update sidecar BEFORE self.stacks is replaced + self.sidecar.save() + log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from stack") + removed = True + stack_modified = True + else: + new_stacks.append([start, end]) + + if stack_modified: + self.stacks = new_stacks + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + + if removed: + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + else: + self.update_status_message("Not in any batch or stack") + + def toggle_batch_membership(self): + """Toggles the current image's inclusion in a batch.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a batch + in_batch = False + for start, end in self.batches: + if start <= index_to_toggle <= end: + in_batch = True + break + + new_batches = [] + if in_batch: + # Remove from batch + item_removed = False + for start, end in self.batches: + if not item_removed and start <= index_to_toggle <= end: + if start < index_to_toggle: + new_batches.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_batches.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_batches.append([start, end]) + self.batches = new_batches + self.update_status_message("Removed image from batch") + log.info("Removed index %d from a batch.", index_to_toggle) + else: + # Add to a new batch + self.batches.append([index_to_toggle, index_to_toggle]) + self.batches.sort() + self.update_status_message("Added image to batch") + log.info("Added index %d to a new batch.", index_to_toggle) + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + def toggle_stack_membership(self): + """Toggles the current image's inclusion in a stack.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a stack + stack_to_modify_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + stack_to_modify_idx = i + break + + if stack_to_modify_idx != -1: + # --- Remove from existing stack --- + new_stacks = [] + item_removed = False + for i, (start, end) in enumerate(self.stacks): + if not item_removed and i == stack_to_modify_idx: + if start < index_to_toggle: + new_stacks.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_stacks.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_stacks.append([start, end]) + self.stacks = new_stacks + self.update_status_message("Removed image from stack") + log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) + + else: + # --- Add to nearest stack --- + if not self.stacks: + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) + else: + # Find closest stack + dist_backward = float('inf') + stack_idx_backward = -1 + for i in range(index_to_toggle - 1, -1, -1): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_backward = index_to_toggle - i + stack_idx_backward = j + break + if stack_idx_backward != -1: + break + + dist_forward = float('inf') + stack_idx_forward = -1 + for i in range(index_to_toggle + 1, len(self.image_files)): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_forward = i - index_to_toggle + stack_idx_forward = j + break + if stack_idx_forward != -1: + break + + if stack_idx_backward == -1 and stack_idx_forward == -1: + # This case should be covered by `if not self.stacks`, but as a fallback. + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) + else: + if dist_backward <= dist_forward: + stack_to_join_idx = stack_idx_backward + else: + stack_to_join_idx = stack_idx_forward + + start, end = self.stacks[stack_to_join_idx] + self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] + + # Merge overlapping stacks + self.stacks.sort() + merged_stacks = [self.stacks[0]] if self.stacks else [] + for i in range(1, len(self.stacks)): + last_start, last_end = merged_stacks[-1] + current_start, current_end = self.stacks[i] + if current_start <= last_end + 1: + merged_stacks[-1] = [last_start, max(last_end, current_end)] + else: + merged_stacks.append([current_start, current_end]) + self.stacks = merged_stacks + + # Find the new stack index for the status message + new_stack_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + new_stack_idx = i + break + + self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") + log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + + + + + def launch_helicon(self): + """Launches Helicon Focus with selected files (RAW preferred, JPG fallback) or stacks.""" + if self.stacks: + log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) + any_success = False + for start, end in self.stacks: + files_to_process = [] + for idx in range(start, end + 1): + if idx < len(self.image_files): + img_file = self.image_files[idx] + # Use RAW if available, otherwise use JPG + file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path + files_to_process.append(file_to_use) + + if files_to_process: + success = self._launch_helicon_with_files(files_to_process) + if success: + any_success = True + else: + log.warning("No valid files found for stack [%d, %d].", start, end) + + # Only clear stacks if at least one launch succeeded + if any_success: + self.clear_all_stacks() + + else: + log.warning("No selection or stacks defined to launch Helicon Focus.") + return + + self.sync_ui_state() + + def _launch_helicon_with_files(self, files: List[Path]) -> bool: + """Helper to launch Helicon with a specific list of files (RAW or JPG). + + Returns: + True if Helicon was successfully launched, False otherwise. + """ + log.info("Launching Helicon Focus with %d files.", len(files)) + unique_files = sorted(list(set(files))) + success, tmp_path = launch_helicon_focus(unique_files) + if success and tmp_path: + # Schedule delayed deletion of the temporary file + QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + + # Record stacking metadata + today = date.today().isoformat() + for file_path in unique_files: + # Find the corresponding image file to get the stem + for img_file in self.image_files: + # Match by either RAW pair or JPG path + if img_file.raw_pair == file_path or img_file.path == file_path: + stem = img_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.stacked = True + meta.stacked_date = today + break + self.sidecar.save() + self._metadata_cache_index = (-1, -1) # Invalidate cache + + return success + + def _delete_temp_file(self, tmp_path: Path): + """Deletes the temporary file list passed to Helicon Focus.""" + if tmp_path.exists(): + try: + os.remove(tmp_path) + log.info("Deleted temporary file: %s", tmp_path) + except OSError as e: + log.error("Error deleting temporary file %s: %s", tmp_path, e) + + def clear_all_stacks(self): + log.info("Clearing all defined stacks and stack start marker.") + self.stacks = [] + self.stack_start_index = None # Clear the stack start marker too + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self.sync_ui_state() + + def get_helicon_path(self): + return config.get('helicon', 'exe') + + def set_helicon_path(self, path): + config.set('helicon', 'exe', path) + config.save() + + def get_photoshop_path(self): + return config.get('photoshop', 'exe') + + def set_photoshop_path(self, path): + config.set('photoshop', 'exe', path) + config.save() + + def open_file_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + dialog.setNameFilter("Executables (*.exe)") + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + def check_path_exists(self, path): + return os.path.exists(path) + + def get_cache_size(self): + return config.getfloat('core', 'cache_size_gb') + + def get_cache_usage_gb(self): + """Returns current cache usage in GB.""" + return self.image_cache.currsize / (1024**3) + + def set_cache_size(self, size): + config.set('core', 'cache_size_gb', size) + config.save() + + def get_prefetch_radius(self): + return config.getint('core', 'prefetch_radius') + + def set_prefetch_radius(self, radius): + config.set('core', 'prefetch_radius', radius) + config.save() + self.prefetcher.prefetch_radius = radius + self.prefetcher.update_prefetch(self.current_index) + + def get_theme(self): + return 0 if config.get('core', 'theme') == 'dark' else 1 + + def set_theme(self, theme_index): + # update Python-side state + self.ui_state.theme = theme_index + + # persist it + theme = 'dark' if theme_index == 0 else 'light' + config.set('core', 'theme', theme) + config.save() + + # tell QML it changed (once is enough) + self.ui_state.themeChanged.emit() + + @Slot(result=str) + def get_color_mode(self): + """Returns current color management mode: 'none', 'saturation', or 'icc'.""" + return config.get('color', 'mode', fallback='none') + + @Slot(str) + def set_color_mode(self, mode: str): + """Sets color management mode and clears cache to force re-decode.""" + mode = mode.lower() + if mode not in ['none', 'saturation', 'icc']: + log.error("Invalid color mode: %s", mode) + return + + log.info("Setting color mode to: %s", mode) + config.set('color', 'mode', mode) + config.save() + + # Clear ICC caches when color mode changes + clear_icc_caches() + + # Clear cache and restart prefetcher to apply new color mode + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML that color mode changed + self.ui_state.colorModeChanged.emit() + + # Update status message + mode_names = { + 'none': 'Original Colors', + 'saturation': 'Saturation Compensation', + 'icc': 'Full ICC Profile' + } + self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") + + @Slot(result=float) + def get_saturation_factor(self): + """Returns current saturation factor (0.0-1.0).""" + return config.getfloat('color', 'saturation_factor', fallback=0.85) + + @Slot(float) + def set_saturation_factor(self, factor: float): + """Sets saturation factor and refreshes images.""" + factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 + log.info("Setting saturation factor to: %.2f", factor) + config.set('color', 'saturation_factor', str(factor)) + config.save() + + # Only refresh if in saturation mode + if self.get_color_mode() == 'saturation': + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML + self.ui_state.saturationFactorChanged.emit() + + def get_default_directory(self): + return config.get('core', 'default_directory') + + def set_default_directory(self, path): + config.set('core', 'default_directory', path) + config.save() + + def open_directory_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.Directory) + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + def preload_all_images(self): + if self.ui_state.isPreloading: + log.info("Preloading is already in progress.") + return + + log.info("Starting to preload all images.") + self.ui_state.isPreloading = True + self.ui_state.preloadProgress = 0 + + self.reporter = self.ProgressReporter() + self.reporter.progress_updated.connect(self._update_preload_progress) + self.reporter.finished.connect(self._finish_preloading) + + # Use existing prefetch executor (better resource utilization) + total = len(self.image_files) + + if total == 0: + log.info("No images to preload.") + self.reporter.progress_updated.emit(100) # Or 0, depending on desired UX + self.reporter.finished.emit() + return + + completed = 0 + + def _on_done(_future): + nonlocal completed + completed += 1 + progress = int((completed / total) * 100) + self.reporter.progress_updated.emit(progress) + if completed == total: + self.reporter.finished.emit() + + for i in range(total): + future = self.prefetcher.submit_task(i, self.prefetcher.generation) + if future: + future.add_done_callback(_on_done) + + def _update_preload_progress(self, progress: int): + log.debug("Updating preload progress in UI: %d%%", progress) + self.ui_state.preloadProgress = progress + + def _finish_preloading(self): + self.ui_state.isPreloading = False + self.ui_state.preloadProgress = 0 + log.info("Finished preloading all images.") + + @Slot() + def delete_current_image(self): + """Moves current JPG and RAW to recycle bin.""" + if not self.image_files: + self.update_status_message("No image to delete.") + return + + image_file = self.image_files[self.current_index] + jpg_path = image_file.path + raw_path = image_file.raw_pair + + # Create recycle bin if it doesn't exist + try: + self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + self.update_status_message(f"Failed to create recycle bin: {e}") + log.error("Failed to create recycle bin directory: %s", e) + return + + # Move files to recycle bin + deleted_files = [] + try: + if jpg_path.exists(): + dest = self.recycle_bin_dir / jpg_path.name + jpg_path.rename(dest) + deleted_files.append(jpg_path.name) + log.info("Moved %s to recycle bin", jpg_path.name) + + if raw_path and raw_path.exists(): + dest = self.recycle_bin_dir / raw_path.name + raw_path.rename(dest) + deleted_files.append(raw_path.name) + log.info("Moved %s to recycle bin", raw_path.name) + + # Add to delete history only if at least one file was moved + if deleted_files: + self.delete_history.append((jpg_path, raw_path)) + + # Update status + if deleted_files: + files_str = ", ".join(deleted_files) + self.update_status_message(f"Deleted: {files_str}") + else: + self.update_status_message("No files to delete") + + # Refresh image list and move to next image + self.refresh_image_list() + if self.image_files: + # Stay at same index (which now shows the next image) + self.current_index = min(self.current_index, len(self.image_files) - 1) + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Delete failed: {e}") + log.exception("Failed to delete image") + + @Slot() + def undo_delete(self): + """Restores the last deleted image from recycle bin.""" + if not self.delete_history: + self.update_status_message("Nothing to undo.") + return + + jpg_path, raw_path = self.delete_history.pop() + + restored_files = [] + try: + # Restore JPG + jpg_in_bin = self.recycle_bin_dir / jpg_path.name + if jpg_in_bin.exists(): + jpg_in_bin.rename(jpg_path) + restored_files.append(jpg_path.name) + log.info("Restored %s from recycle bin", jpg_path.name) + + # Restore RAW + if raw_path: + raw_in_bin = self.recycle_bin_dir / raw_path.name + if raw_in_bin.exists(): + raw_in_bin.rename(raw_path) + restored_files.append(raw_path.name) + log.info("Restored %s from recycle bin", raw_path.name) + + # Update status + if restored_files: + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + else: + self.update_status_message("No files to restore") + + # Refresh image list + self.refresh_image_list() + + # Find and navigate to the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == jpg_path: + self.current_index = i + break + + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to restore image") + # Put it back in history if it failed + self.delete_history.append((jpg_path, raw_path)) + + def shutdown(self): + log.info("Application shutting down.") + + # Check if recycle bin has files and prompt to empty + if self.recycle_bin_dir.exists(): + files_in_bin = list(self.recycle_bin_dir.glob("*")) + if files_in_bin: + file_count = len(files_in_bin) + msg_box = QMessageBox() + msg_box.setWindowTitle("Recycle Bin") + msg_box.setText(f"There are {file_count} files in the recycle bin.") + msg_box.setInformativeText("What would you like to do?") + + # Add custom buttons + delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) + restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) + keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) + + msg_box.setDefaultButton(keep_btn) + msg_box.exec() + + clicked_button = msg_box.clickedButton() + if clicked_button == delete_btn: + self.empty_recycle_bin() + elif clicked_button == restore_btn: + self.restore_all_from_recycle_bin() + + # Clear QML context property to prevent TypeErrors during shutdown + if self.engine: + log.info("Clearing uiState context property in QML.") + del self.engine # Explicitly delete the engine + + self.watcher.stop() + self.prefetcher.shutdown() + self.sidecar.set_last_index(self.current_index) + self.sidecar.save() + + def empty_recycle_bin(self): + """Permanently deletes all files in the recycle bin.""" + if not self.recycle_bin_dir.exists(): + return + + try: + import shutil + shutil.rmtree(self.recycle_bin_dir) + self.delete_history.clear() + log.info("Emptied recycle bin and cleared delete history") + except OSError: + log.exception("Failed to empty recycle bin") + + def restore_all_from_recycle_bin(self): + """Restores all files from recycle bin to working directory.""" + if not self.recycle_bin_dir.exists(): + return + + try: + files_in_bin = list(self.recycle_bin_dir.glob("*")) + restored_count = 0 + + for file_in_bin in files_in_bin: + # Restore to original location (working directory) + dest_path = self.image_dir / file_in_bin.name + + # If file already exists, skip (don't overwrite) + if dest_path.exists(): + log.warning("File already exists, skipping: %s", dest_path) + continue + + try: + file_in_bin.rename(dest_path) + restored_count += 1 + log.info("Restored %s from recycle bin", file_in_bin.name) + except OSError as e: + log.error("Failed to restore %s: %s", file_in_bin.name, e) + + # Clear delete history since we restored everything + self.delete_history.clear() + + log.info("Restored %d files from recycle bin", restored_count) + + except OSError: + log.exception("Failed to restore files from recycle bin") + + @Slot() + def edit_in_photoshop(self): + if not self.image_files: + self.update_status_message("No image to edit.") + return + + # Prefer RAW file if it exists, otherwise use JPG + image_file = self.image_files[self.current_index] + raw_path = image_file.raw_pair + + if raw_path and raw_path.exists(): + current_image_path = raw_path + log.info("Using RAW file for Photoshop: %s", raw_path) + else: + current_image_path = image_file.path + log.info("Using JPG file for Photoshop: %s", current_image_path) + + photoshop_exe = config.get('photoshop', 'exe') + photoshop_args = config.get('photoshop', 'args') + + # Validate executable path securely + is_valid, error_msg = validate_executable_path( + photoshop_exe, + app_type="photoshop", + allow_custom_paths=True + ) + + if not is_valid: + self.update_status_message(f"Photoshop validation failed: {error_msg}") + log.error("Photoshop executable validation failed: %s", error_msg) + return + + # Validate that the file path exists and is a file + if not current_image_path.exists() or not current_image_path.is_file(): + self.update_status_message(f"Image file not found: {current_image_path.name}") + log.error("Image file not found or not a file: %s", current_image_path) + return + + try: + # Build command list safely + command = [photoshop_exe] + + # Parse additional args safely using shlex (handles quotes and escapes properly) + if photoshop_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) + command.extend(parsed_args) + except ValueError as e: + log.error("Invalid photoshop_args format: %s", e) + self.update_status_message("Invalid Photoshop arguments configured") + return + + # Add the file path as the last argument + # Convert to string but keep it as a list element (not shell-interpolated) + command.append(str(current_image_path.resolve())) + + # SECURITY: Explicitly disable shell execution + subprocess.Popen( + command, + shell=False, # CRITICAL: Never use shell=True with user input + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True # Close unused file descriptors + ) + + # Mark as edited on successful launch + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = image_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.edited = True + meta.edited_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") + log.info("Launched Photoshop with: %s", command) + except FileNotFoundError as e: + self.update_status_message(f"Photoshop executable not found: {e}") + log.exception("Photoshop executable not found") + # Don't mark as edited if launch failed + return + except (OSError, subprocess.SubprocessError) as e: + self.update_status_message(f"Failed to open in Photoshop: {e}") + log.exception("Error launching Photoshop") + # Don't mark as edited if launch failed + return + + @Slot() + def copy_path_to_clipboard(self): + if not self.image_files: + self.update_status_message("No image path to copy.") + return + + current_image_path = str(self.image_files[self.current_index].path) + QApplication.clipboard().setText(current_image_path) + self.update_status_message(f"Copied: {current_image_path}") + log.info("Copied path to clipboard: %s", current_image_path) + + @Slot() + def reset_zoom_pan(self): + """Resets zoom and pan to fit the image in the window (like Ctrl+0 in Photoshop).""" + log.info("Resetting zoom and pan to fit window") + self.ui_state.resetZoomPan() + self.update_status_message("Reset zoom and pan") + + def update_status_message(self, message: str, timeout: int = 3000): + """ + Updates the UI status message and clears it after a timeout. + """ + def clear_message(): + if self.ui_state.statusMessage == message: + self.ui_state.statusMessage = "" + + self.ui_state.statusMessage = message + QTimer.singleShot(timeout, clear_message) + + + + @Slot() + def start_drag_current_image(self): + if not self.image_files or self.current_index >= len(self.image_files): + return + + # Collect all files: current + any in defined batches + files_to_drag = set() + files_to_drag.add(self.current_index) + + # Add all files from defined batches + for start, end in self.batches: + for idx in range(start, end + 1): + if 0 <= idx < len(self.image_files): + files_to_drag.add(idx) + + # Convert to sorted list and get only existing paths + file_indices = sorted(files_to_drag) + existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] + file_paths = [self.image_files[idx].path for idx in existing_indices] + + if not file_paths: + log.error("No valid files to drag") + return + + if self.main_window is None: + return + + drag = QDrag(self.main_window) + mime_data = QMimeData() + + # Use Qt's standard setUrls - it handles both browser and native app compatibility + urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] + mime_data.setUrls(urls) + + drag.setMimeData(mime_data) + + # --- thumbnail / drag preview --- + pix = QPixmap(str(file_paths[0])) + if not pix.isNull(): + # scale it down so it's not huge + scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) + drag.setPixmap(scaled) + # hotspot = center of image + drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) + + log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) + # Support both Copy and Move actions for browser compatibility + result = drag.exec(Qt.CopyAction | Qt.MoveAction) + log.info("Drag completed with result: %s", result) + + # Reset zoom/pan after drag completes (drag can cause unwanted panning) + self.ui_state.resetZoomPan() + + # Mark all dragged files as uploaded if drag was successful + if result in (Qt.CopyAction, Qt.MoveAction): + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + for idx in existing_indices: + stem = self.image_files[idx].path.stem + meta = self.sidecar.get_metadata(stem) + meta.uploaded = True + meta.uploaded_date = today + + self.sidecar.save() + + # Clear all batches after successful drag (like pressing \) + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) + + def _get_stack_info(self, index: int) -> str: + info = "" + for i, (start, end) in enumerate(self.stacks): + if start <= index <= end: + count_in_stack = end - start + 1 + pos_in_stack = index - start + 1 + info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" + break + if not info and self.stack_start_index is not None and self.stack_start_index == index: + info = "Stack Start Marked" + log.debug("_get_stack_info for index %d: %s", index, info) + return info + + def _get_batch_info(self, index: int) -> str: + """Get batch info for the given index.""" + info = "" + for i, (start, end) in enumerate(self.batches): + if start <= index <= end: + count_in_batch = end - start + 1 + pos_in_batch = index - start + 1 + info = "In Batch" + break + if not info and self.batch_start_index is not None and self.batch_start_index == index: + info = "Batch Start Marked" + log.debug("_get_batch_info for index %d: %s", index, info) + return info + + def get_stack_summary(self) -> str: + if not self.stacks: + return "No stacks defined." + summary = [] + for i, (start, end) in enumerate(self.stacks): + summary.append(f"Stack {i+1}: {start}-{end}") + return "; ".join(summary) + + def is_stacked(self) -> bool: + if not self.image_files or self.current_index >= len(self.image_files): + return False + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + return meta.stacked + +def main(image_dir: str = "", debug: bool = False): + """FastStack Application Entry Point""" + global _debug_mode + _debug_mode = debug + + t0 = time.perf_counter() + setup_logging(debug) + if debug: + log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) + log.info("Starting FastStack") + + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + + app = QApplication(sys.argv) # Moved here + if debug: + log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) + + if not image_dir: + image_dir_str = config.get('core', 'default_directory') + if not image_dir_str: + log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") + selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") + if not selected_dir: + log.error("No image directory selected. Exiting.") + sys.exit(1) + image_dir_str = selected_dir + image_dir_path = Path(image_dir_str) + else: + image_dir_path = Path(image_dir) + + if not image_dir_path.is_dir(): + log.error("Image directory not found: %s", image_dir_path) + sys.exit(1) + app.setOrganizationName("FastStack") + app.setOrganizationDomain("faststack.dev") + app.setApplicationName("FastStack") + + engine = QQmlApplicationEngine() + controller = AppController(image_dir_path, engine) + if debug: + log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) + image_provider = ImageProvider(controller) + engine.addImageProvider("provider", image_provider) + + # Expose controller and UI state to QML + context = engine.rootContext() + context.setContextProperty("uiState", controller.ui_state) + context.setContextProperty("controller", controller) + + qml_file = Path(__file__).parent / "qml" / "Main.qml" + engine.load(QUrl.fromLocalFile(str(qml_file))) + if debug: + log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) + + if not engine.rootObjects(): + log.error("Failed to load QML.") + sys.exit(-1) + + # Connect key events from the main window + main_window = engine.rootObjects()[0] + controller.main_window = main_window + main_window.installEventFilter(controller) + + # Load data and start services + controller.load() + if debug: + log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) + + # Graceful shutdown + app.aboutToQuit.connect(controller.shutdown) + + sys.exit(app.exec()) + +def cli(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") + parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") + parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") + args = parser.parse_args() + main(image_dir=args.image_dir, debug=args.debug) + +if __name__ == "__main__": + cli() diff --git a/faststack/faststack/app.py.bak b/faststack.working-menus/faststack/app.py.bak similarity index 100% rename from faststack/faststack/app.py.bak rename to faststack.working-menus/faststack/app.py.bak diff --git a/faststack.working-menus/faststack/benchmark_decode.py b/faststack.working-menus/faststack/benchmark_decode.py new file mode 100644 index 0000000..6a5b858 --- /dev/null +++ b/faststack.working-menus/faststack/benchmark_decode.py @@ -0,0 +1,20 @@ +import mmap +import time +from pathlib import Path +from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE + +print(f"TurboJPEG available: {TURBO_AVAILABLE}") + +test_image = Path(r"C:\Users\alanr\Pictures\Lightroom\2025\2025-11-14\20251114-PB140001-2.JPG") + +# Match the real code path with mmap +iterations = 20 +start = time.perf_counter() +for _ in range(iterations): + with open(test_image, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] + decode_jpeg_resized(jpeg_bytes, 1920, 1080) +elapsed = time.perf_counter() - start + +print(f"Average time (with mmap): {elapsed/iterations*1000:.1f}ms") diff --git a/faststack.working-menus/faststack/config.py b/faststack.working-menus/faststack/config.py new file mode 100644 index 0000000..095be0e --- /dev/null +++ b/faststack.working-menus/faststack/config.py @@ -0,0 +1,86 @@ +"""Manages application configuration via an INI file.""" + +import configparser +import logging +from pathlib import Path + +from faststack.logging_setup import get_app_data_dir + +log = logging.getLogger(__name__) + +DEFAULT_CONFIG = { + "core": { + "cache_size_gb": "1.5", + "prefetch_radius": "4", + "theme": "dark", + "default_directory": "", + }, + "helicon": { + "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", + "args": "", + }, + "photoshop": { + "exe": "C:\\Program Files\\Adobe\\Adobe Photoshop 2026\\Photoshop.exe", + "args": "", + }, + "color": { + "mode": "none", # Options: "none", "saturation", "icc" + "saturation_factor": "0.85", # Option A: 0.0-1.0, lower = less saturated + "monitor_icc_path": "", # Option C: path to monitor ICC profile + }, +} + +class AppConfig: + def __init__(self): + self.config_path = get_app_data_dir() / "faststack.ini" + self.config = configparser.ConfigParser() + self.load() + + def load(self): + """Loads the config, creating it with defaults if it doesn't exist.""" + if not self.config_path.exists(): + log.info(f"Creating default config at {self.config_path}") + self.config.read_dict(DEFAULT_CONFIG) + self.save() + else: + log.info(f"Loading config from {self.config_path}") + self.config.read(self.config_path) + # Ensure all sections and keys exist + for section, keys in DEFAULT_CONFIG.items(): + if not self.config.has_section(section): + self.config.add_section(section) + for key, value in keys.items(): + if not self.config.has_option(section, key): + self.config.set(section, key, value) + self.save() # Save to add any missing keys + + + def save(self): + """Saves the current configuration to the INI file.""" + try: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + self.config.write(f) + log.info(f"Saved config to {self.config_path}") + except IOError as e: + log.error(f"Failed to save config to {self.config_path}: {e}") + + def get(self, section, key, fallback=None): + return self.config.get(section, key, fallback=fallback) + + def getint(self, section, key, fallback=None): + return self.config.getint(section, key, fallback=fallback) + + def getfloat(self, section, key, fallback=None): + return self.config.getfloat(section, key, fallback=fallback) + + def getboolean(self, section, key, fallback=None): + return self.config.getboolean(section, key, fallback=fallback) + + def set(self, section, key, value): + if not self.config.has_section(section): + self.config.add_section(section) + self.config.set(section, key, str(value)) + +# Global config instance +config = AppConfig() diff --git a/faststack.working-menus/faststack/imaging/cache.py b/faststack.working-menus/faststack/imaging/cache.py new file mode 100644 index 0000000..d0d4262 --- /dev/null +++ b/faststack.working-menus/faststack/imaging/cache.py @@ -0,0 +1,45 @@ +"""Byte-aware LRU cache for storing decoded image data (CPU and GPU).""" + +import logging +from typing import Any, Callable + +from cachetools import LRUCache + +log = logging.getLogger(__name__) + +class ByteLRUCache(LRUCache): + """An LRU Cache that respects the size of its items in bytes.""" + def __init__(self, max_bytes: int, size_of: Callable[[Any], int] = len): + super().__init__(maxsize=max_bytes, getsizeof=size_of) + log.info(f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity.") + + def __setitem__(self, key, value): + # Before adding a new item, we might need to evict others + # This is handled by the parent class, which will call popitem if needed + super().__setitem__(key, value) + log.debug(f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB") + + def popitem(self): + """Extend popitem to log eviction.""" + key, value = super().popitem() + log.debug(f"Evicted item '{key}' to free up space. Cache size: {self.currsize / 1024**2:.2f} MB") + # In a real Qt app, `value` would be a tuple like (numpy_buffer, qtexture_id) + # and we would explicitly free the GPU texture here. + return key, value + +# Example usage: +def get_decoded_image_size(item) -> int: + """Calculates the size of a decoded image tuple (buffer, qimage).""" + # In this simplified example, we only store the buffer. + # In the full app, this would also account for the QImage/QTexture. + from faststack.models import DecodedImage + if isinstance(item, DecodedImage): + # Handle both numpy arrays and memoryview buffers + if hasattr(item.buffer, 'nbytes'): + return item.buffer.nbytes + elif hasattr(item.buffer, '__len__'): + return len(item.buffer) + else: + # Fallback: compute from dimensions + return item.width * item.height * 3 + return 1 # Should not happen diff --git a/faststack.working-menus/faststack/imaging/jpeg.py b/faststack.working-menus/faststack/imaging/jpeg.py new file mode 100644 index 0000000..9672749 --- /dev/null +++ b/faststack.working-menus/faststack/imaging/jpeg.py @@ -0,0 +1,149 @@ +"""High-performance JPEG decoding using PyTurboJPEG with a Pillow fallback.""" + +import logging +from typing import Optional, Tuple + +import numpy as np +from PIL import Image + +log = logging.getLogger(__name__) + +# Attempt to import PyTurboJPEG +try: + from turbojpeg import TurboJPEG, TJPF_RGB + jpeg_decoder = TurboJPEG() + TURBO_AVAILABLE = True + log.info("PyTurboJPEG is available. Using for JPEG decoding.") +except ImportError: + jpeg_decoder = None + TURBO_AVAILABLE = False + log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") + +def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: + """Decodes JPEG bytes into an RGB numpy array.""" + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Decode with proper color space handling (no TJFLAG_FASTDCT) + # This ensures proper YCbCr->RGB conversion with correct gamma + return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=0) + except Exception as e: + log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") + # Fall through to Pillow fallback + + # Fallback to Pillow + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") + return np.array(img) + except Exception as e: + log.exception(f"Pillow also failed to decode image: {e}") + return None + +def decode_jpeg_thumb_rgb( + jpeg_bytes: bytes, + max_dim: int = 256 +) -> Optional[np.ndarray]: + """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + width, height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Find the best scaling factor + scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim) + + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scaling_factor, + pixel_format=TJPF_RGB, + flags=0, # Proper color space handling + ) + if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: + img = Image.fromarray(decoded) + img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS) + return np.array(img) + return decoded + except Exception as e: + log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") + + # Fallback to Pillow + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)) + img.thumbnail((max_dim, max_dim)) + return np.array(img.convert("RGB")) + except Exception as e: + log.exception(f"Pillow also failed to decode thumbnail: {e}") + return None + +def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Optional[Tuple[int, int]]: + """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" + if not TURBO_AVAILABLE or not jpeg_decoder: + return None + + # PyTurboJPEG provides a set of supported scaling factors + supported_factors = sorted( + jpeg_decoder.scaling_factors, + key=lambda x: x[0] / x[1], + reverse=True, + ) + + for num, den in supported_factors: + if (width * num / den) <= max_dim and (height * num / den) <= max_dim: + return (num, den) + + # If no suitable factor is found, return the smallest one + return supported_factors[-1] if supported_factors else None + + +def decode_jpeg_resized( + jpeg_bytes: bytes, width: int, height: int +) -> Optional[np.ndarray]: + """Decodes and resizes a JPEG to fit within the given dimensions.""" + if width == 0 or height == 0: + return decode_jpeg_rgb(jpeg_bytes) + + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Calculate best scaling factor for TurboJPEG (supports 1/8, 1/4, 1/2, etc.) + scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max(width, height)) + + if scale_factor: + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scale_factor, + pixel_format=TJPF_RGB, + flags=0 # Proper color space handling + ) + + # Only use Pillow for final resize if needed + if decoded.shape[0] > height or decoded.shape[1] > width: + from io import BytesIO + img = Image.fromarray(decoded) + img.thumbnail((width, height), Image.Resampling.LANCZOS) + return np.array(img) + return decoded + except Exception as e: + log.exception(f"PyTurboJPEG failed: {e}") + + # Fallback to Pillow (existing code) + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)) + + scale_factor_ratio = min(img.width / width, img.height / height) + + # Use faster BILINEAR for large downscales, LANCZOS for smaller + if scale_factor_ratio > 4: + resampling = Image.Resampling.BILINEAR # Much faster + else: + resampling = Image.Resampling.LANCZOS # Higher quality + + img.thumbnail((width, height), resampling) + return np.array(img.convert("RGB")) + except Exception as e: + log.exception(f"Pillow failed to decode and resize image: {e}") + return None diff --git a/faststack.working-menus/faststack/imaging/prefetch.py b/faststack.working-menus/faststack/imaging/prefetch.py new file mode 100644 index 0000000..ab2f394 --- /dev/null +++ b/faststack.working-menus/faststack/imaging/prefetch.py @@ -0,0 +1,487 @@ +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" + +import logging +import os +import io +import hashlib +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Dict, Optional, Callable +import mmap + +import numpy as np +from PIL import Image as PILImage, ImageCms + +from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE +from faststack.config import config + +log = logging.getLogger(__name__) + +# ---- Option C: ICC Color Management Setup ---- +SRGB_PROFILE = ImageCms.createProfile("sRGB") + +# Cache for monitor ICC profile to avoid reloading on every decode +_monitor_profile_cache: Dict[str, Optional[ImageCms.ImageCmsProfile]] = {} +_monitor_profile_warning_logged = False + +# Cache for ICC transforms to avoid rebuilding on every image +_icc_transform_cache: Dict[tuple, ImageCms.ImageCmsTransform] = {} + +def get_icc_transform(src_profile: ImageCms.ImageCmsProfile, monitor_profile: ImageCms.ImageCmsProfile, + src_profile_key: str, monitor_profile_path: str): + """Get or create a cached ICC transform. + + Building transforms is expensive, so we cache them by stable keys: + - src_profile_key: SHA-256 digest of the embedded ICC bytes + - monitor_profile_path: file path to the monitor ICC profile + """ + key = (src_profile_key, monitor_profile_path) + if key not in _icc_transform_cache: + _icc_transform_cache[key] = ImageCms.buildTransform( + src_profile, monitor_profile, "RGB", "RGB" + ) + log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) + return _icc_transform_cache[key] + +def clear_icc_caches(): + """Clear all ICC-related caches (profiles and transforms).""" + global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged + _monitor_profile_cache.clear() + _icc_transform_cache.clear() + _monitor_profile_warning_logged = False + log.info("Cleared ICC profile and transform caches") + +def get_monitor_profile(): + """Dynamically load monitor ICC profile based on current config. + + Caches the profile by path to reduce overhead and log spam. + """ + global _monitor_profile_warning_logged + + monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() + + # Check cache first + if monitor_icc_path in _monitor_profile_cache: + return _monitor_profile_cache[monitor_icc_path] + + # Handle empty path case + if not monitor_icc_path: + if not _monitor_profile_warning_logged: + log.warning("ICC mode enabled but no monitor_icc_path configured") + _monitor_profile_warning_logged = True + _monitor_profile_cache[monitor_icc_path] = None + return None + + # Load and cache the profile + try: + profile = ImageCms.ImageCmsProfile(monitor_icc_path) + log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) + _monitor_profile_cache[monitor_icc_path] = profile + except (OSError, ImageCms.PyCMSError) as e: + log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) + _monitor_profile_cache[monitor_icc_path] = None + + return _monitor_profile_cache[monitor_icc_path] + + +def apply_saturation_compensation( + arr: np.ndarray, + width: int, + height: int, + bytes_per_line: int, + factor: float, +): + """ + In-place saturation scale in RGB space (Option A). + + arr: 1D uint8 array of length height * bytes_per_line + width, height, bytes_per_line: dimensions of the image stored in arr + factor: 0.0-1.0 range, where 1.0 = no change, <1.0 = less saturated + + Note: While the algorithm supports values >1.0 for increased saturation, + the UI constrains the factor to [0.0, 1.0] for saturation reduction only. + """ + if factor == 1.0: + return + + # Treat the buffer as [height, bytes_per_line] + assert arr.size == height * bytes_per_line, ( + f"Unexpected buffer size for saturation compensation: " + f"{arr.size} != {height} * {bytes_per_line}" + ) + buf2d = arr.reshape((height, bytes_per_line)) + + # Only the first width*3 bytes per row are actual RGB pixels + rgb_region = buf2d[:, : width * 3] + + # Interpret as H x W x 3 + rgb = rgb_region.reshape((height, width, 3)).astype(np.float32) + + # Simple saturation scaling: move each channel toward its per-pixel average + gray = rgb.mean(axis=2, keepdims=True) + rgb = gray + factor * (rgb - gray) + + np.clip(rgb, 0, 255, out=rgb) + + # Write back into the same memory + rgb_region[:] = rgb.reshape(height, width * 3).astype(np.uint8) + +class Prefetcher: + def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable, debug: bool = False): + self.image_files = image_files + self.cache_put = cache_put + self.prefetch_radius = prefetch_radius + self.get_display_info = get_display_info + self.debug = debug + # Use CPU count for I/O-bound JPEG decoding + # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound + optimal_workers = min((os.cpu_count() or 1) * 2, 4) # Cap at 4 + + self.executor = ThreadPoolExecutor( + max_workers=optimal_workers, + thread_name_prefix="Prefetcher" + ) + self.futures: Dict[int, Future] = {} + self.generation = 0 + self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices + + # Adaptive prefetch: start with smaller radius, expand after user navigates + self._initial_radius = 2 # Small radius at startup to reduce cache thrash + self._navigation_count = 0 # Track how many times user has navigated + self._radius_expanded = False + + # Directional prefetching + self._last_navigation_direction: int = 1 # 1 = forward, -1 = backward + self._direction_bias: float = 0.7 # 70% of radius in travel direction + + def set_image_files(self, image_files: List[ImageFile]): + if self.image_files != image_files: + self.image_files = image_files + self.cancel_all() + + def update_prefetch(self, current_index: int, is_navigation: bool = False, direction: Optional[int] = None): + """Updates the prefetching queue based on the current image index. + + Args: + current_index: The index to prefetch around + is_navigation: True if this is from user navigation (arrow keys, etc.) + direction: 1 for forward, -1 for backward, None to use last direction + """ + # NOTE: Generation is NOT incremented here. It only changes when display size, + # zoom state, or color mode changes - events that actually invalidate cached images. + # Navigation just shifts which indices to prefetch. + + # Clean up old generation entries to prevent memory leak + old_generations = [g for g in self._scheduled if g < self.generation] + for g in old_generations: + del self._scheduled[g] + + # Track navigation direction + if direction is not None: + self._last_navigation_direction = direction + + # Track navigation to expand radius after user starts moving + if is_navigation: + self._navigation_count += 1 + if not self._radius_expanded and self._navigation_count >= 2: + self._radius_expanded = True + log.info("Expanding prefetch radius from %d to %d after user navigation", self._initial_radius, self.prefetch_radius) + + # Use smaller radius initially to reduce cache thrash before display size is stable + effective_radius = self._initial_radius if not self._radius_expanded else self.prefetch_radius + + if self.debug: + log.info("Prefetch radius: initial=%d, configured=%d, effective=%d", + self._initial_radius, self.prefetch_radius, effective_radius) + + # Calculate asymmetric range based on direction + if self._last_navigation_direction > 0: # Moving forward + behind = max(1, int(effective_radius * (1 - self._direction_bias))) + ahead = effective_radius - behind + 1 + else: # Moving backward + ahead = max(1, int(effective_radius * (1 - self._direction_bias))) + behind = effective_radius - ahead + 1 + + start = max(0, current_index - behind) + end = min(len(self.image_files), current_index + ahead + 1) + + log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", + start, end, current_index, self._last_navigation_direction, behind, ahead) + + # Get scheduled set for current generation + scheduled = self._scheduled.setdefault(self.generation, set()) + + # Cancel stale futures and remove from scheduled + stale_keys = [] + for index, future in list(self.futures.items()): + if index < start or index >= end: + if future.cancel(): + stale_keys.append(index) + scheduled.discard(index) # Remove from scheduled set + for key in stale_keys: + del self.futures[key] + + # Submit new tasks - prioritize current image and direction of travel + + # Build priority order: current first, then in direction of travel + priority_order = [current_index] + if self._last_navigation_direction > 0: + priority_order.extend(range(current_index + 1, end)) + priority_order.extend(range(current_index - 1, start - 1, -1)) + else: + priority_order.extend(range(current_index - 1, start - 1, -1)) + priority_order.extend(range(current_index + 1, end)) + + for i in priority_order: + if i < 0 or i >= len(self.image_files): + continue + if i not in scheduled and i not in self.futures: + self.submit_task(i, self.generation) + scheduled.add(i) + + def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]: + """Submits a decoding task for a given index. + + Args: + index: Image index to decode + generation: Generation number for cache invalidation + priority: If True, cancels lower-priority pending tasks to free up workers + """ + if index in self.futures and not self.futures[index].done(): + return self.futures[index] # Already submitted + + # For high-priority tasks (current image), cancel pending prefetch tasks + # to free up worker threads and reduce blocking time + if priority: + cancelled_count = 0 + for task_index, future in list(self.futures.items()): + if task_index != index and not future.done() and future.cancel(): + cancelled_count += 1 + del self.futures[task_index] + if cancelled_count > 0: + log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) + + image_file = self.image_files[index] + display_width, display_height, display_generation = self.get_display_info() + + future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) + self.futures[index] = future + log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index) + return future + + def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]: + """The actual work done by the thread pool.""" + import time + + t_start = time.perf_counter() + + # Early check: if generation has already advanced since this task was submitted, skip it + if generation != self.generation: + log.debug("Skipping stale task for index %d (submitted gen %d != current gen %d)", index, generation, self.generation) + return None + + try: + # Get current color management mode + color_mode = config.get('color', 'mode', fallback="none").lower() + + # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion + if color_mode == "icc": + monitor_profile = get_monitor_profile() + monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() + + if monitor_profile is not None: + # FAST: Use TurboJPEG for decode + resize + t_before_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! Decoders accept bytes-like objects + buffer = decode_jpeg_resized(mmapped, display_width, display_height) + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + # Convert numpy array to PIL Image for ICC conversion + img = PILImage.fromarray(buffer) + t_after_array_to_pil = time.perf_counter() + + # Extract ICC profile from original file (need to read header only) + t_before_profile_read = time.perf_counter() + with PILImage.open(image_file.path) as orig: + icc_bytes = orig.info.get("icc_profile") + t_after_profile_read = time.perf_counter() + + src_profile = None + src_profile_key = None + if icc_bytes: + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + # Compute stable key: SHA-256 digest of ICC bytes + src_profile_key = hashlib.sha256(icc_bytes).hexdigest() + log.debug("Using embedded ICC profile from %s", image_file.path) + except (OSError, ImageCms.PyCMSError, ValueError) as e: + log.warning("Failed to parse ICC profile from %s: %s", image_file.path, e) + + if src_profile is None: + src_profile = SRGB_PROFILE + # Use a constant key for sRGB since it's always the same + src_profile_key = "srgb_builtin" + log.debug("No embedded profile, assuming sRGB for %s", image_file.path) + + # Convert from source profile to monitor profile using cached transform + try: + log.debug("Converting image from source to monitor profile") + t_before_icc = time.perf_counter() + transform = get_icc_transform(src_profile, monitor_profile, src_profile_key, monitor_icc_path) + # Alan 11-20-25 - Add inPlace=True to speed up copy, shouldn't have many negative effects + ImageCms.applyTransform(img, transform, inPlace=True) + t_after_icc = time.perf_counter() + + rgb = np.array(img, dtype=np.uint8) + h, w, _ = rgb.shape + bytes_per_line = w * 3 + arr = rgb.reshape(-1).copy() + t_after_copy = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("ICC decode timing for index %d (%s): read=%.3fs, decode=%.3fs, array_to_pil=%.3fs, profile_read=%.3fs, icc=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_array_to_pil - t_after_decode, t_after_profile_read - t_before_profile_read, + t_after_icc - t_before_icc, t_after_copy - t_after_icc, + t_after_copy - t_start, w, h) + except (OSError, ImageCms.PyCMSError, ValueError) as e: + # ICC conversion failed, fall back to standard decode + log.warning("ICC profile conversion failed for %s: %s, falling back to standard decode", image_file.path, e) + t_before_fallback_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! + buffer = decode_jpeg_resized(mmapped, display_width, display_height) + t_after_fallback_read = time.perf_counter() + if buffer is None: + return None + t_after_fallback_decode = time.perf_counter() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_fallback_read - t_before_fallback_read, + t_after_fallback_decode - t_after_fallback_read, + t_after_fallback_decode - t_start, w, h) + else: + # Fall back to standard decode if ICC profile not available + log.warning("ICC mode selected but no monitor profile available, using standard decode") + t_before_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! + buffer = decode_jpeg_resized(mmapped, display_width, display_height) + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_decode - t_start, w, h) + + else: + # Standard decode path (Option A or no color management) + t_before_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! Decoders accept bytes-like objects + buffer = decode_jpeg_resized(mmapped, display_width, display_height) + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + t_after_copy = time.perf_counter() + + # Option A: Saturation compensation + if color_mode == "saturation": + try: + t_before_saturation = time.perf_counter() + factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) + t_after_saturation = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, t_after_saturation - t_before_saturation, + t_after_saturation - t_start, w, h) + except (ValueError, AssertionError) as e: + log.warning("Failed to apply saturation compensation: %s", e) + else: + # No color management - log standard timing + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, t_after_copy - t_start, w, h) + + # Re-check generation before caching (in case it changed during decode) + if self.generation != generation: + log.debug("Generation changed for index %d before caching (current gen %d != submitted gen %d). Skipping cache_put.", index, self.generation, generation) + return None + + decoded_image = DecodedImage( + buffer=arr.data, + width=w, + height=h, + bytes_per_line=bytes_per_line, + format=None # Placeholder for QImage.Format.Format_RGB888 + ) + cache_key = f"{index}_{display_generation}" + self.cache_put(cache_key, decoded_image) + log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) + return index, display_generation + + except Exception: + log.exception("Error decoding image %s at index %d", image_file.path, index) + + return None + + def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional[int] = None) -> bool: + """Checks if an index is within the current prefetch window. + + Args: + index: The index to check + current_index: The center of the prefetch window + radius: Optional custom radius; if None, uses self.prefetch_radius + """ + if radius is None: + radius = self.prefetch_radius + return abs(index - current_index) <= radius + + def cancel_all(self): + """Cancels all pending prefetch tasks.""" + log.info("Cancelling all prefetch tasks.") + self.generation += 1 + for future in self.futures.values(): + future.cancel() + self.futures.clear() + self._scheduled.clear() # Clear scheduled indices when bumping generation + + def shutdown(self): + """Shuts down the thread pool executor.""" + log.info("Shutting down prefetcher thread pool.") + self.cancel_all() + self.executor.shutdown(wait=False) diff --git a/faststack.working-menus/faststack/io/executable_validator.py b/faststack.working-menus/faststack/io/executable_validator.py new file mode 100644 index 0000000..501c7f0 --- /dev/null +++ b/faststack.working-menus/faststack/io/executable_validator.py @@ -0,0 +1,112 @@ +"""Secure validation of executable paths before execution.""" + +import logging +import os +from pathlib import Path +from typing import Optional, List + +log = logging.getLogger(__name__) + +# Known safe installation directories for common applications on Windows +KNOWN_SAFE_PATHS = [ + r"C:\Program Files", + r"C:\Program Files (x86)", +] + +# Known executable names that are safe to run +KNOWN_SAFE_EXECUTABLES = { + "photoshop": ["Photoshop.exe"], + "helicon": ["HeliconFocus.exe"], +} + + +def validate_executable_path( + exe_path: str, + app_type: Optional[str] = None, + allow_custom_paths: bool = True +) -> tuple[bool, Optional[str]]: + """ + Validates an executable path before execution. + + Args: + exe_path: Path to the executable to validate + app_type: Type of application (e.g., 'photoshop', 'helicon') for additional checks + allow_custom_paths: Whether to allow executables outside known safe paths + + Returns: + Tuple of (is_valid, error_message) + If valid, error_message is None + If invalid, error_message contains reason + """ + if not exe_path: + return False, "Executable path is empty" + + try: + path = Path(exe_path).resolve() + except (ValueError, OSError) as e: + log.exception(f"Invalid path format: {exe_path}") + return False, f"Invalid path format: {e}" + + # Check if file exists + if not path.exists(): + return False, f"Executable not found: {exe_path}" + + if not path.is_file(): + return False, f"Path is not a file: {exe_path}" + + # Check if it's actually an executable + if not _is_executable(path): + return False, f"File is not executable: {exe_path}" + + # Check if the executable name matches expected names for the app type + if app_type and app_type in KNOWN_SAFE_EXECUTABLES: + expected_names = KNOWN_SAFE_EXECUTABLES[app_type] + if path.name not in expected_names: + log.warning( + f"Executable name '{path.name}' does not match expected names " + f"for {app_type}: {expected_names}" + ) + # This is a warning, not a hard failure, but log it + + # Check if in known safe directory + in_safe_path = any( + _is_subpath(path, Path(safe_path)) + for safe_path in KNOWN_SAFE_PATHS + ) + + if not in_safe_path: + if not allow_custom_paths: + return False, f"Executable not in allowed directory: {exe_path}" + else: + log.warning( + f"Executable '{exe_path}' is not in a known safe directory. " + f"Proceeding with caution." + ) + + # Check for suspicious paths (potential directory traversal, etc.) + try: + normalized = os.path.normpath(exe_path) + if ".." in normalized or normalized != str(path): + log.warning(f"Suspicious path detected: {exe_path}") + except (ValueError, OSError) as e: + log.exception("Error normalizing path") + return False, f"Path validation error: {e}" + + return True, None + + +def _is_executable(path: Path) -> bool: + """Check if a file is executable (has .exe extension on Windows).""" + if os.name == 'nt': # Windows + return path.suffix.lower() == '.exe' + else: # Unix-like + return os.access(path, os.X_OK) + + +def _is_subpath(path: Path, parent: Path) -> bool: + """Check if path is a subpath of parent.""" + try: + path.resolve().relative_to(parent.resolve()) + return True + except (ValueError, RuntimeError): + return False diff --git a/faststack.working-menus/faststack/io/helicon.py b/faststack.working-menus/faststack/io/helicon.py new file mode 100644 index 0000000..08bd032 --- /dev/null +++ b/faststack.working-menus/faststack/io/helicon.py @@ -0,0 +1,91 @@ +"""Handles launching Helicon Focus with a list of RAW files.""" + +import logging +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple + +from faststack.config import config +from faststack.io.executable_validator import validate_executable_path + +log = logging.getLogger(__name__) + +def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: + """Launches Helicon Focus with the provided list of RAW files. + + Args: + raw_files: A list of absolute paths to RAW files. + + Returns: + True if the process was launched successfully, False otherwise. + """ + helicon_exe = config.get("helicon", "exe") + if not helicon_exe or not isinstance(helicon_exe, str): + log.error("Helicon Focus executable path not configured or invalid.") + return False, None + + # Validate executable path securely + is_valid, error_msg = validate_executable_path( + helicon_exe, + app_type="helicon", + allow_custom_paths=True + ) + + if not is_valid: + log.error(f"Helicon Focus executable validation failed: {error_msg}") + return False, None + + if not raw_files: + log.warning("No RAW files selected to open in Helicon Focus.") + return False, None + + try: + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt", encoding='utf-8') as tmp: + for f in raw_files: + # Ensure file path is resolved and exists + if not f.exists(): + log.warning(f"RAW file does not exist, skipping: {f}") + continue + tmp.write(f"{f.resolve()}\n") + tmp_path = Path(tmp.name) + + log.info(f"Temporary file for Helicon Focus: {tmp_path}") + log.info(f"Input files: {[str(f) for f in raw_files]}") + + # Build command list safely + args = [helicon_exe, "-i", str(tmp_path.resolve())] + + # Parse additional args safely using shlex (handles quotes and escapes properly) + extra_args = config.get("helicon", "args") + if extra_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(extra_args, posix=(os.name != 'nt')) + args.extend(parsed_args) + except ValueError as e: + log.exception(f"Invalid helicon args format: {e}") + return False, None + + log.info(f"Launching Helicon Focus with {len(raw_files)} files") + log.info(f"Command: {' '.join(args)}") + + # SECURITY: Explicitly disable shell execution + subprocess.Popen( + args, + shell=False, # CRITICAL: Never use shell=True with user input + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True # Close unused file descriptors + ) + return True, tmp_path + except (OSError, subprocess.SubprocessError) as e: + log.exception(f"Failed to launch Helicon Focus: {e}") + return False, None + except (IOError, PermissionError) as e: + log.exception(f"Failed to create temporary file for Helicon Focus: {e}") + return False, None diff --git a/faststack.working-menus/faststack/io/indexer.py b/faststack.working-menus/faststack/io/indexer.py new file mode 100644 index 0000000..8fe9dbe --- /dev/null +++ b/faststack.working-menus/faststack/io/indexer.py @@ -0,0 +1,84 @@ +"""Scans directories for JPGs and pairs them with corresponding RAW files.""" + +import logging +import os +import time +from pathlib import Path +from typing import List, Dict, Tuple + +from faststack.models import ImageFile + +log = logging.getLogger(__name__) + +RAW_EXTENSIONS = { + ".ORF", ".RW2", ".CR2", ".CR3", ".ARW", ".NEF", ".RAF", ".DNG", + ".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng", +} + +JPG_EXTENSIONS = { ".JPG", ".JPEG", ".jpg", ".jpeg" } + +def find_images(directory: Path) -> List[ImageFile]: + """Finds all JPGs in a directory and pairs them with RAW files.""" + t_start = time.perf_counter() + log.info("Scanning directory for images: %s", directory) + jpgs: List[Tuple[Path, os.stat_result]] = [] + raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} + + try: + for entry in os.scandir(directory): + if entry.is_file(): + p = Path(entry.path) + ext = p.suffix + if ext in JPG_EXTENSIONS: + jpgs.append((p, entry.stat())) + elif ext in RAW_EXTENSIONS: + stem = p.stem + if stem not in raws: + raws[stem] = [] + raws[stem].append((p, entry.stat())) + except OSError as e: + log.exception("Error scanning directory %s", directory) + return [] + + # Sort JPGs by filename + jpgs.sort(key=lambda x: x[0].name) + + image_files: List[ImageFile] = [] + for jpg_path, jpg_stat in jpgs: + raw_pair = _find_raw_pair(jpg_path, jpg_stat, raws.get(jpg_path.stem, [])) + image_files.append(ImageFile( + path=jpg_path, + raw_pair=raw_pair, + timestamp=jpg_stat.st_mtime, + )) + + elapsed = time.perf_counter() - t_start + paired_count = sum(1 for im in image_files if im.raw_pair) + + if log.isEnabledFor(logging.DEBUG): + log.info("Found %d JPG files and paired %d with RAWs in %.3fs", len(image_files), paired_count, elapsed) + else: + log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count) + return image_files + +def _find_raw_pair( + jpg_path: Path, + jpg_stat: os.stat_result, + potential_raws: List[Tuple[Path, os.stat_result]] +) -> Path | None: + """Finds the best RAW pair for a JPG from a list of candidates.""" + if not potential_raws: + return None + + # Find the RAW file with the closest modification time within a 2-second window + best_match: Path | None = None + min_dt = 2.0 # seconds + + for raw_path, raw_stat in potential_raws: + dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime) + if dt <= min_dt: + min_dt = dt + best_match = raw_path + + # Removed per-pair debug logging to reduce noise - summary is logged at end of find_images() + return best_match diff --git a/faststack.working-menus/faststack/io/sidecar.py b/faststack.working-menus/faststack/io/sidecar.py new file mode 100644 index 0000000..46128cc --- /dev/null +++ b/faststack.working-menus/faststack/io/sidecar.py @@ -0,0 +1,98 @@ +"""Manages reading and writing the faststack.json sidecar file.""" + +import json +import logging +import time +from pathlib import Path +from typing import Optional + +from faststack.models import Sidecar, EntryMetadata + +log = logging.getLogger(__name__) + +class SidecarManager: + def __init__(self, directory: Path, watcher, debug: bool = False): + self.path = directory / "faststack.json" + self.watcher = watcher + self.debug = debug + self.data = self.load() + + def stop_watcher(self): + if self.watcher: + self.watcher.stop() + + def start_watcher(self): + if self.watcher: + self.watcher.start() + + def load(self) -> Sidecar: + """Loads sidecar data from disk if it exists, otherwise returns a new object.""" + if not self.path.exists(): + log.info(f"No sidecar file found at {self.path}. Creating new one.") + return Sidecar() + try: + t_start = time.perf_counter() + with self.path.open("r") as f: + data = json.load(f) + json_load_time = time.perf_counter() - t_start + + if self.debug: + log.info(f"SidecarManager.load: json.load() took {json_load_time:.3f}s") + + if data.get("version") != 2: + log.warning("Old sidecar format detected. Starting fresh.") + return Sidecar() + + # Reconstruct nested objects + entries = { + stem: EntryMetadata(**meta) + for stem, meta in data.get("entries", {}).items() + } + return Sidecar( + version=data.get("version", 2), + last_index=data.get("last_index", 0), + entries=entries, + stacks=data.get("stacks", []), + ) + except (json.JSONDecodeError, TypeError) as e: + log.error(f"Failed to load or parse sidecar file {self.path}: {e}") + # Consider backing up the corrupted file here + return Sidecar() + + def save(self): + """Saves the sidecar data to disk atomically.""" + temp_path = self.path.with_suffix(".tmp") + was_watcher_running = False + try: + if self.watcher and hasattr(self.watcher, 'is_alive') and self.watcher.is_alive(): + self.stop_watcher() + was_watcher_running = True + with temp_path.open("w") as f: + # Convert to a dict that json.dump can handle + serializable_data = { + "version": self.data.version, + "last_index": self.data.last_index, + "entries": { + stem: meta.__dict__ + for stem, meta in self.data.entries.items() + }, + "stacks": self.data.stacks, + } + json.dump(serializable_data, f, indent=2) + + # Atomic rename + temp_path.replace(self.path) + log.debug(f"Saved sidecar file to {self.path}") + + except (IOError, TypeError) as e: + log.error(f"Failed to save sidecar file {self.path}: {e}") + finally: + if was_watcher_running: + self.start_watcher() + + def get_metadata(self, image_stem: str) -> EntryMetadata: + """Gets metadata for an image, creating it if it doesn't exist.""" + return self.data.entries.setdefault(image_stem, EntryMetadata()) + + def set_last_index(self, index: int): + self.data.last_index = index diff --git a/faststack.working-menus/faststack/io/watcher.py b/faststack.working-menus/faststack/io/watcher.py new file mode 100644 index 0000000..938c169 --- /dev/null +++ b/faststack.working-menus/faststack/io/watcher.py @@ -0,0 +1,74 @@ +"""Filesystem watcher to detect changes in the image directory.""" + +import logging +from pathlib import Path +from typing import Optional + +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +log = logging.getLogger(__name__) + +class ImageDirectoryEventHandler(FileSystemEventHandler): + """Handles filesystem events for the image directory.""" + def __init__(self, callback): + super().__init__() + self.callback = callback + + def on_created(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file creation: {event}. Triggering refresh.") + self.callback() + + def on_deleted(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file deletion: {event}. Triggering refresh.") + self.callback() + + def on_moved(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file move: {event}. Triggering refresh.") + self.callback() + + def on_modified(self, event): + # This is a no-op to prevent spurious refreshes from file modifications + # that don't change the content (e.g., antivirus scans). + pass + +class Watcher: + """Manages the filesystem observer.""" + def __init__(self, directory: Path, callback): + self.observer: Optional[Observer] = None # Initialize to None + self.event_handler = ImageDirectoryEventHandler(callback) + self.directory = directory + self.callback = callback # Store callback for new observer + + def start(self): + """Starts watching the directory.""" + if not self.directory.is_dir(): + log.warning(f"Cannot watch non-existent directory: {self.directory}") + return + + if self.observer and self.observer.is_alive(): + return # Already running + + # Create a new observer instance every time, as it cannot be restarted + self.observer = Observer() + self.observer.schedule(self.event_handler, str(self.directory), recursive=False) + self.observer.start() + log.info(f"Started watching directory: {self.directory}") + + def stop(self): + """Stops watching the directory.""" + if self.observer and self.observer.is_alive(): + self.observer.stop() + self.observer.join() + log.info("Stopped watching directory.") + self.observer = None # Clear instance after stopping + + def is_alive(self) -> bool: + """Checks if the watcher thread is alive.""" + return self.observer and self.observer.is_alive() diff --git a/faststack.working-menus/faststack/logging_setup.py b/faststack.working-menus/faststack/logging_setup.py new file mode 100644 index 0000000..73737b6 --- /dev/null +++ b/faststack.working-menus/faststack/logging_setup.py @@ -0,0 +1,46 @@ +"""Configures application-wide logging.""" + +import logging +import logging.handlers +import os +from pathlib import Path + +def get_app_data_dir() -> Path: + """Returns the application data directory.""" + app_data = os.getenv("APPDATA") + if app_data: + return Path(app_data) / "faststack" + return Path.home() / ".faststack" + +def setup_logging(debug: bool = False): + """Sets up logging to a rotating file in the app data directory. + + Args: + debug: If True, sets log level to DEBUG. Otherwise, sets to INFO to reduce noise. + """ + log_dir = get_app_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "app.log" + + handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5 + ) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + # Set log level based on debug flag + root_logger.setLevel(logging.DEBUG if debug else logging.INFO) + root_logger.addHandler(handler) + + # Configure logging for key modules + if debug: + logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) + logging.getLogger("faststack.imaging.prefetch").setLevel(logging.DEBUG) + else: + # In non-debug mode, only log errors from these noisy modules + logging.getLogger("faststack.imaging.cache").setLevel(logging.ERROR) + logging.getLogger("faststack.imaging.prefetch").setLevel(logging.ERROR) + logging.getLogger("PIL").setLevel(logging.INFO) diff --git a/faststack.working-menus/faststack/models.py b/faststack.working-menus/faststack/models.py new file mode 100644 index 0000000..094aaa3 --- /dev/null +++ b/faststack.working-menus/faststack/models.py @@ -0,0 +1,44 @@ +"""Core data types and enumerations for FastStack.""" + +import dataclasses +from pathlib import Path +from typing import Optional, Dict, List + +@dataclasses.dataclass +class ImageFile: + """Represents a single image file on disk.""" + path: Path + raw_pair: Optional[Path] = None + timestamp: float = 0.0 + +@dataclasses.dataclass +class EntryMetadata: + """Sidecar metadata for a single image entry.""" + stack_id: Optional[int] = None + stacked: bool = False + stacked_date: Optional[str] = None + uploaded: bool = False + uploaded_date: Optional[str] = None + edited: bool = False + edited_date: Optional[str] = None + + +@dataclasses.dataclass +class Sidecar: + """Represents the entire sidecar JSON file.""" + version: int = 2 + last_index: int = 0 + entries: Dict[str, EntryMetadata] = dataclasses.field(default_factory=dict) + stacks: List[List[int]] = dataclasses.field(default_factory=list) + +@dataclasses.dataclass +class DecodedImage: + """A decoded image buffer ready for display.""" + buffer: memoryview + width: int + height: int + bytes_per_line: int + format: object # QImage.Format + + def __sizeof__(self) -> int: + return self.buffer.nbytes diff --git a/faststack.working-menus/faststack/qml/Components.qml b/faststack.working-menus/faststack/qml/Components.qml new file mode 100644 index 0000000..2b60b4a --- /dev/null +++ b/faststack.working-menus/faststack/qml/Components.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtQuick.Window + +// This file is intended to hold QML components like the main image view. +// For simplicity, we'll start with just the main image view. + +Item { + id: loupeView + anchors.fill: parent + + // Connection to handle zoom/pan reset signal from Python + Connections { + target: uiState + function onResetZoomPanRequested() { + scaleTransform.xScale = 1.0 + scaleTransform.yScale = 1.0 + panTransform.x = 0 + panTransform.y = 0 + } + } + + // The main image display + Image { + id: mainImage + anchors.fill: parent + source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + fillMode: Image.PreserveAspectFit + cache: false // We do our own caching in Python + + Component.onCompleted: { + if (width > 0 && height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged(Math.round(width * dpr), Math.round(height * dpr)) + } + } + + onWidthChanged: { + if (width > 0 && height > 0) { + resizeDebounceTimer.restart() + } + } + + onHeightChanged: { + if (width > 0 && height > 0) { + resizeDebounceTimer.restart() + } + } + + function updateZoomState() { + if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) { + uiState.setZoomed(true); + } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { + uiState.setZoomed(false); + } + } + + transform: [ + Scale { + id: scaleTransform + origin.x: mainImage.width / 2 + origin.y: mainImage.height / 2 + onXScaleChanged: mainImage.updateZoomState() + onYScaleChanged: mainImage.updateZoomState() + }, + Translate { + id: panTransform + } + ] + } + + // Zoom and Pan logic would go here + // For example, using PinchArea or MouseArea + Timer { + id: resizeDebounceTimer + interval: 100 // milliseconds + running: false + onTriggered: { + if (mainImage.width > 0 && mainImage.height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged(Math.round(mainImage.width * dpr), Math.round(mainImage.height * dpr)) + } + running = false + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + + // Drag-to-pan with drag-and-drop when dragging outside window + property real lastX: 0 + property real lastY: 0 + property real startX: 0 + property real startY: 0 + property bool isDraggingOutside: false + property int dragThreshold: 10 // Minimum distance before checking for outside drag + + onPressed: function(mouse) { + lastX = mouse.x + lastY = mouse.y + startX = mouse.x + startY = mouse.y + isDraggingOutside = false + } + + onPositionChanged: function(mouse) { + if (pressed && !isDraggingOutside) { + // Check if we've moved beyond the threshold + var dx = mouse.x - startX + var dy = mouse.y - startY + var distance = Math.sqrt(dx*dx + dy*dy) + + if (distance > dragThreshold) { + // Check if mouse is outside the window bounds + var globalPos = mapToItem(null, mouse.x, mouse.y) + + if (globalPos.x < 0 || globalPos.y < 0 || + globalPos.x > loupeView.width || globalPos.y > loupeView.height) { + // Mouse is outside window - initiate drag-and-drop + isDraggingOutside = true + controller.start_drag_current_image() + return + } + } + + // Normal pan behavior + panTransform.x += (mouse.x - lastX) + panTransform.y += (mouse.y - lastY) + lastX = mouse.x + lastY = mouse.y + } + } + + onReleased: function(mouse) { + isDraggingOutside = false + } + + // Wheel for zoom + onWheel: function(wheel) { + // A real implementation would be more complex, zooming + // into the cursor position. + var scaleFactor = wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2; + scaleTransform.xScale *= scaleFactor; + scaleTransform.yScale *= scaleFactor; + } + } + + +} diff --git a/faststack.working-menus/faststack/qml/FilterDialog.qml b/faststack.working-menus/faststack/qml/FilterDialog.qml new file mode 100644 index 0000000..95926db --- /dev/null +++ b/faststack.working-menus/faststack/qml/FilterDialog.qml @@ -0,0 +1,78 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 + +Dialog { + id: filterDialog + title: "Filter Images" + modal: true + standardButtons: Dialog.Ok | Dialog.Cancel + closePolicy: Popup.CloseOnEscape + width: 500 + height: 250 + + property string filterString: "" + + // Match the app's theme dynamically + Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light + + background: Rectangle { + color: Material.theme === Material.Dark ? "#1e1e1e" : "white" + border.color: Material.theme === Material.Dark ? "#404040" : "#c0c0c0" + border.width: 1 + radius: 4 + } + + contentItem: Column { + spacing: 16 + padding: 20 + + Label { + text: "Show only images whose filename contains:" + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + } + + TextField { + id: filterField + placeholderText: "Enter text to filter (e.g., 'stacked', 'IMG_001')..." + width: parent.width - parent.padding * 2 + height: 50 + selectByMouse: true + focus: true + font.pixelSize: 16 + verticalAlignment: TextInput.AlignVCenter + + onTextChanged: { + filterDialog.filterString = text + } + + Keys.onReturnPressed: filterDialog.accept() + Keys.onEnterPressed: filterDialog.accept() + } + + Label { + text: "Leave empty to show all images." + font.italic: true + opacity: 0.7 + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + } + } + + onOpened: { + // Load current filter string from controller + var current = controller.get_filter_string ? controller.get_filter_string() : "" + filterDialog.filterString = current || "" + filterField.text = filterDialog.filterString + filterField.forceActiveFocus() + filterField.selectAll() + // Notify Python that a dialog is open + controller.dialog_opened() + } + + onClosed: { + // Notify Python that dialog is closed + controller.dialog_closed() + } +} diff --git a/faststack.working-menus/faststack/qml/JumpToImageDialog.qml b/faststack.working-menus/faststack/qml/JumpToImageDialog.qml new file mode 100644 index 0000000..0b9fe4e --- /dev/null +++ b/faststack.working-menus/faststack/qml/JumpToImageDialog.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: jumpDialog + title: "Jump to Image" + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + closePolicy: Popup.CloseOnEscape + width: 400 + + property int maxImageCount: 0 + + // Inherit Material theme from parent + Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + onOpened: { + imageNumberField.text = "" + imageNumberField.forceActiveFocus() + // Notify Python that a dialog is open + controller.dialog_opened() + } + + onClosed: { + // Notify Python that dialog is closed + controller.dialog_closed() + } + + onAccepted: { + var num = parseInt(imageNumberField.text) + if (!isNaN(num) && num >= 1 && num <= maxImageCount) { + controller.jump_to_image(num - 1) // Convert 1-based to 0-based index + } + } + + contentItem: Item { + implicitWidth: 400 + implicitHeight: 100 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 0 + spacing: 20 + + Label { + text: "Enter image number (1-" + jumpDialog.maxImageCount + "):" + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + TextField { + id: imageNumberField + Layout.preferredWidth: 100 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignLeft + placeholderText: "Number" + font.pixelSize: 16 + horizontalAlignment: TextInput.AlignHCenter + maximumLength: Math.max(1, Math.ceil(Math.log10(jumpDialog.maxImageCount + 1))) + selectByMouse: true + focus: true + validator: IntValidator { + bottom: 1 + top: jumpDialog.maxImageCount + } + + Keys.onReturnPressed: jumpDialog.accept() + Keys.onEnterPressed: jumpDialog.accept() + } + + Item { + Layout.fillHeight: true + } + } + } +} diff --git a/faststack.working-menus/faststack/qml/Main.qml b/faststack.working-menus/faststack/qml/Main.qml new file mode 100644 index 0000000..0ac92e2 --- /dev/null +++ b/faststack.working-menus/faststack/qml/Main.qml @@ -0,0 +1,435 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "." + +ApplicationWindow { + id: root + visible: true + width: 1200 + height: 800 + minimumWidth: 800 + minimumHeight: 500 + flags: Qt.FramelessWindowHint | Qt.Window + title: "FastStack" + + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + property bool isDarkTheme: uiState.theme === 0 + property color currentBackgroundColor: isDarkTheme ? "#000000" : "white" + property color currentTextColor: isDarkTheme ? "white" : "black" + + background: Rectangle { color: root.currentBackgroundColor } + + function toggleTheme() { + uiState.theme = (uiState.theme === 0 ? 1 : 0) // 0 for dark, 1 for light + } + + Connections { + target: uiState + function onThemeChanged() { + root.isDarkTheme = uiState.theme === 0 + } + } + + // Expose the Python UIState object to QML + // This is set from Python via setContextProperty("uiState", ...) + + // Main view: either the loupe viewer or the grid + Loader { + id: mainViewLoader + anchors.fill: parent + anchors.topMargin: titleBar.height + source: "Components.qml" + } + + // Keyboard focus and event handling + + // Status bar + footer: Rectangle { + id: footerRect + implicitHeight: footerRow.implicitHeight + 10 // Add some padding + anchors.left: parent.left + anchors.right: parent.right + color: "#80000000" // Semi-transparent black + + RowLayout { + id: footerRow + spacing: 10 + Label { + Layout.leftMargin: 10 + text: `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` + color: root.currentTextColor + } + Label { + text: uiState.imageCount > 0 ? ` | File: ${uiState.currentFilename || 'N/A'}` : " | File: N/A" + color: root.currentTextColor + } + Label { + text: ` | Stacked: ${uiState.stackedDate}` + color: "lightgreen" + visible: uiState.imageCount > 0 && uiState.isStacked + } + Label { + text: ` | Uploaded on ${uiState.uploadedDate}` + color: "lightgreen" + visible: uiState.imageCount > 0 && uiState.isUploaded + } + Label { + text: ` | Edited on ${uiState.editedDate}` + color: "lightgreen" + visible: uiState.imageCount > 0 && uiState.isEdited + } + Label { + text: ` | Filter: "${uiState.filterString}"` + color: "yellow" + font.bold: true + visible: uiState.filterString !== "" + } + Rectangle { + visible: uiState.isPreloading + Layout.preferredWidth: 200 + height: 10 // give it some height + color: "gray" + border.color: "red" + border.width: 1 + + Rectangle { + color: "lightblue" + width: parent.width * (uiState.preloadProgress / 100) + height: parent.height + } + } + Rectangle { + color: (uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" + radius: 3 + implicitWidth: stackInfoLabel.implicitWidth + 10 + implicitHeight: stackInfoLabel.implicitHeight + 5 + visible: uiState.imageCount > 0 && uiState.stackInfoText + Label { + id: stackInfoLabel + anchors.centerIn: parent + text: `Stack: ${uiState.stackInfoText}` + color: "black" + font.bold: true + font.pixelSize: 16 + } + } + Rectangle { + color: (uiState.imageCount > 0 && uiState.batchInfoText) ? "#4fb360" : "transparent" + radius: 3 + implicitWidth: batchInfoLabel.implicitWidth + 10 + implicitHeight: batchInfoLabel.implicitHeight + 5 + visible: uiState.imageCount > 0 && uiState.batchInfoText + Label { + id: batchInfoLabel + anchors.centerIn: parent + text: `Batch: ${uiState.batchInfoText}` + color: "white" + font.bold: true + font.pixelSize: 16 + } + } + Rectangle { + Layout.fillWidth: true + color: "transparent" + } + + // Saturation slider (only visible in saturation mode) + Row { + visible: uiState.colorMode === "saturation" + spacing: 5 + Layout.rightMargin: 10 + + Label { + text: "Saturation:" + color: root.currentTextColor + anchors.verticalCenter: parent.verticalCenter + } + + Slider { + id: saturationSlider + from: 0.0 + to: 1.0 + value: uiState.saturationFactor + stepSize: 0.01 + width: 150 + + onMoved: { + controller.set_saturation_factor(value) + } + } + + Label { + text: Math.round(saturationSlider.value * 100) + "%" + color: root.currentTextColor + anchors.verticalCenter: parent.verticalCenter + Layout.preferredWidth: 40 + } + } + + Label { + id: statusMessageLabel + text: uiState.statusMessage + color: root.currentTextColor + visible: uiState.statusMessage !== "" + Layout.rightMargin: 10 + } + } + } + + header: Rectangle { + id: titleBar + height: 30 + color: root.currentBackgroundColor + + MouseArea { + anchors.fill: parent + property point lastGlobalPos: Qt.point(0, 0) + onPressed: function(mouse) { + lastGlobalPos = Qt.point(root.x + mouse.x, root.y + mouse.y) + } + onPositionChanged: function(mouse) { + var currentGlobalPos = Qt.point(root.x + mouse.x, root.y + mouse.y) + var delta = Qt.point(currentGlobalPos.x - lastGlobalPos.x, currentGlobalPos.y - lastGlobalPos.y) + root.x += delta.x + root.y += delta.y + lastGlobalPos = currentGlobalPos + } + } + + RowLayout { + + id: menuAndControls + + anchors.fill: parent + + + + MenuBar { + id: menuBar + Layout.preferredWidth: 300 // Give it some width + background: Rectangle { + color: root.currentBackgroundColor + } + palette.buttonText: root.currentTextColor + palette.button: root.currentBackgroundColor + palette.window: root.currentBackgroundColor + palette.text: root.currentTextColor + + Menu { + title: "&File" + Action { text: "&Open Folder..." } + Action { + text: "&Settings..." + onTriggered: { + settingsDialog.heliconPath = uiState.get_helicon_path() + settingsDialog.photoshopPath = uiState.get_photoshop_path() + settingsDialog.cacheSize = uiState.get_cache_size() + settingsDialog.prefetchRadius = uiState.get_prefetch_radius() + settingsDialog.theme = uiState.theme + settingsDialog.defaultDirectory = uiState.get_default_directory() + settingsDialog.open() + } + } + Action { text: "&Exit"; onTriggered: Qt.quit() } + } + Menu { + title: "&View" + Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } + MenuSeparator {} + + ActionGroup { + id: colorModeGroup + exclusive: true + } + + Action { + text: "Color: None (Original)" + checkable: true + checked: uiState.colorMode === "none" + onTriggered: controller.set_color_mode("none") + ActionGroup.group: colorModeGroup + } + Action { + text: "Color: Saturation Compensation" + checkable: true + checked: uiState.colorMode === "saturation" + onTriggered: controller.set_color_mode("saturation") + ActionGroup.group: colorModeGroup + } + Action { + text: "Color: Full ICC Profile" + checkable: true + checked: uiState.colorMode === "icc" + onTriggered: controller.set_color_mode("icc") + ActionGroup.group: colorModeGroup + } + } + Menu { + title: "&Actions" + Action { text: "Run Stacks"; onTriggered: uiState.launch_helicon() } + Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } + Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } + Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } + Action { text: "Filter Images..."; onTriggered: filterDialog.open() } + Action { text: "Clear Filename Filter"; onTriggered: controller.clear_filter() } + } + Menu { + title: "&Help" + Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } + } + } + + + + Item { Layout.fillWidth: true } // Left spacer + + Label { + text: uiState.currentDirectory + color: root.currentTextColor + font.pixelSize: 12 + elide: Text.ElideMiddle + Layout.maximumWidth: 600 + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + Layout.topMargin: 5 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.fillWidth: true } // Right spacer + + Row { + + // Removed anchors + + spacing: 10 + + + + Button { + + text: "-" + + onClicked: root.showMinimized() + + } + + Button { + + text: "[]" + + onClicked: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() + + } + + Button { + + text: "X" + + onClicked: Qt.quit() + + } + + } + + } + } + + Dialog { + id: aboutDialog + title: "Key Bindings" + standardButtons: Dialog.Ok + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 600 + height: 750 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: ScrollView { + clip: true + Text { + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image
" + + "  G: Jump to Image Number

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  Ctrl+0: Reset zoom and pan to fit window

" + + "Stacking:
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "Batch Selection (for drag-and-drop):
" + + "  {: Begin new batch
" + + "  }: End current batch
" + + "  \\: Clear all batches
" + + "  X or S: Remove current image from batch/stack

" + + "Flag Toggles:
" + + "  U: Toggle uploaded flag
" + + "  Ctrl+E: Toggle edited flag
" + + "  Ctrl+S: Toggle stacked flag

" + + "File Management:
" + + "  Delete: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last delete

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  P: Edit in Photoshop
" + + "  Ctrl+C: Copy image path to clipboard" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + } + + Dialog { + id: showStacksDialog + title: "Stack Information" + standardButtons: Dialog.Ok + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 400 + height: 300 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: Text { + text: uiState.stackSummary || "No stacks defined." + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + + SettingsDialog { + id: settingsDialog + } + + FilterDialog { + id: filterDialog + onAccepted: { + controller.apply_filter(filterString) + } + } + + JumpToImageDialog { + id: jumpToImageDialog + maxImageCount: uiState.imageCount + } + + function show_jump_to_image_dialog() { + jumpToImageDialog.open() + } +} diff --git a/faststack.working-menus/faststack/qml/Main.qml.bak b/faststack.working-menus/faststack/qml/Main.qml.bak new file mode 100644 index 0000000..3c9ca9f --- /dev/null +++ b/faststack.working-menus/faststack/qml/Main.qml.bak @@ -0,0 +1,295 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "." + +ApplicationWindow { + id: root + visible: true + width: 1200 + height: 800 + minimumWidth: 800 + minimumHeight: 500 + flags: Qt.FramelessWindowHint | Qt.Window + title: "FastStack" + + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + + property bool isDarkTheme: uiState.theme === 0 + property color currentBackgroundColor: isDarkTheme ? "#000000" : "white" + property color currentTextColor: isDarkTheme ? "white" : "black" + + background: Rectangle { color: root.currentBackgroundColor } + + function toggleTheme() { + uiState.theme = (uiState.theme === 0 ? 1 : 0) // 0 for dark, 1 for light + } + + Connections { + target: uiState + function onThemeChanged() { + root.isDarkTheme = uiState.theme === 0 + } + } + + // Expose the Python UIState object to QML + // This is set from Python via setContextProperty("uiState", ...) + + // Main view: either the loupe viewer or the grid + Loader { + id: mainViewLoader + anchors.fill: parent + anchors.topMargin: titleBar.height + source: "Components.qml" + } + + // Keyboard focus and event handling + + // Status bar + footer: Rectangle { + id: footerRect + implicitHeight: footerRow.implicitHeight + 10 // Add some padding + anchors.left: parent.left + anchors.right: parent.right + color: "#80000000" // Semi-transparent black + + RowLayout { + id: footerRow + spacing: 10 + Label { + Layout.leftMargin: 10 + text: `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` + color: root.currentTextColor + } + Label { + text: ` | File: ${uiState.currentFilename || 'N/A'}` + color: root.currentTextColor + } + Label { + text: ` | Flag: ${uiState.isFlagged}` + color: uiState.isFlagged ? "lightgreen" : root.currentTextColor + } + Label { + text: ` | Rejected: ${uiState.isRejected}` + color: uiState.isRejected ? "red" : root.currentTextColor + } + Label { + text: ` | Stacked: ${uiState.stackedDate}` + color: "lightgreen" + visible: uiState.isStacked + } + Rectangle { + visible: uiState.isPreloading + Layout.preferredWidth: 200 + height: 10 // give it some height + color: "gray" + border.color: "red" + border.width: 1 + + Rectangle { + color: "lightblue" + width: parent.width * (uiState.preloadProgress / 100) + height: parent.height + } + } + Rectangle { + Layout.fillWidth: true + color: uiState.stackInfoText ? "orange" : "transparent" // Brighter background + radius: 3 + implicitWidth: stackInfoLabel.implicitWidth + 10 + implicitHeight: stackInfoLabel.implicitHeight + 5 + Label { + id: stackInfoLabel + anchors.centerIn: parent + text: `Stack: ${uiState.stackInfoText || 'N/A'}` + color: "black" // Black text for contrast on orange + font.bold: true + font.pixelSize: 16 + } + } + } + } + + header: Rectangle { + id: titleBar + height: 30 + color: root.currentBackgroundColor + + MouseArea { + anchors.fill: parent + property point lastMousePos: Qt.point(0, 0) + onPressed: function(mouse) { + lastMousePos = Qt.point(mouse.x, mouse.y) + } + onPositionChanged: function(mouse) { + var delta = Qt.point(mouse.x - lastMousePos.x, mouse.y - lastMousePos.y) + root.x += delta.x + root.y += delta.y + } + } + + RowLayout { + + id: menuAndControls + + anchors.fill: parent + + + + MenuBar { + id: menuBar + Layout.preferredWidth: 300 // Give it some width + background: Rectangle { + color: root.currentBackgroundColor + } + palette.buttonText: root.currentTextColor + palette.button: root.currentBackgroundColor + palette.window: root.currentBackgroundColor + palette.text: root.currentTextColor + + Menu { + title: "&File" + Action { text: "&Open Folder..." } + Action { + text: "&Settings..." + onTriggered: { + settingsDialog.heliconPath = uiState.get_helicon_path() + settingsDialog.cacheSize = uiState.get_cache_size() + settingsDialog.prefetchRadius = uiState.get_prefetch_radius() + settingsDialog.theme = uiState.theme + settingsDialog.defaultDirectory = uiState.get_default_directory() + settingsDialog.open() + } + } + Action { text: "&Exit"; onTriggered: Qt.quit() } + } + Menu { + title: "&View" + Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } + } + Menu { + title: "&Actions" + Action { text: "Run Stacks"; onTriggered: uiState.launch_helicon() } + Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } + Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } + Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } + Action { text: "Filter Images..."; onTriggered: filterDialog.open() } + } + Menu { + title: "&Help" + Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } + } + } + + + + Item { Layout.fillWidth: true } // Spacer + + + + Row { + + // Removed anchors + + spacing: 10 + + + + Button { + + text: "-" + + onClicked: root.showMinimized() + + } + + Button { + + text: "[]" + + onClicked: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() + + } + + Button { + + text: "X" + + onClicked: Qt.quit() + + } + + } + + } + } + + Dialog { + id: aboutDialog + title: "Key Bindings" + standardButtons: Dialog.Ok + modal: true + width: 400 + height: 400 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: Text { + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  G: Toggle Grid View (not implemented)

" + + "Rating & Stacking:
" + + "  Space: Toggle Flag
" + + "  X: Toggle Reject
" + + "  S: Add to selection for Helicon
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "Actions:
" + + "  Enter: Launch Helicon Focus" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + + Dialog { + id: showStacksDialog + title: "Stack Information" + standardButtons: Dialog.Ok + modal: true + width: 400 + height: 300 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: Text { + text: uiState.stackSummary || "No stacks defined." + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + + SettingsDialog { + id: settingsDialog + } + + FilterDialog { + id: filterDialog + onAccepted: { + uiState.applyFilter(filterString) + } + } +} \ No newline at end of file diff --git a/faststack.working-menus/faststack/qml/SettingsDialog.qml b/faststack.working-menus/faststack/qml/SettingsDialog.qml new file mode 100644 index 0000000..65e3792 --- /dev/null +++ b/faststack.working-menus/faststack/qml/SettingsDialog.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: settingsDialog + title: "Settings" + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 600 + height: 600 + + // Live cache usage value (updated by timer) + property real cacheUsage: 0.0 + + onVisibleChanged: { + cacheUsageTimer.running = visible + if (visible) { + controller.dialog_opened() + } else { + controller.dialog_closed() + } + } + + onOpened: { + // Refresh text field when dialog opens with current value + cacheSizeField.text = settingsDialog.cacheSize.toFixed(1) + } + + property string heliconPath: "" + property double cacheSize: 1.5 + property int prefetchRadius: 4 + property int theme: 0 + property string defaultDirectory: "" + property string photoshopPath: "" + + onAccepted: { + uiState.set_helicon_path(heliconPath) + uiState.set_photoshop_path(photoshopPath) + uiState.set_cache_size(cacheSize) + uiState.set_prefetch_radius(prefetchRadius) + uiState.set_theme(theme) + uiState.set_default_directory(defaultDirectory) + } + + contentItem: GridLayout { + columns: 3 + + // Helicon Path + Label { text: "Helicon Focus Path:" } + TextField { + id: heliconPathField + Layout.fillWidth: true + text: settingsDialog.heliconPath + onTextChanged: settingsDialog.heliconPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) heliconPathField.text = path + } + } + Label { + id: checkMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(heliconPathField.text) + } + } + + // Photoshop Path + Label { text: "Photoshop Path:" } + TextField { + id: photoshopPathField + Layout.fillWidth: true + text: settingsDialog.photoshopPath + onTextChanged: settingsDialog.photoshopPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) photoshopPathField.text = path + } + } + Label { + id: photoshopCheckMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(photoshopPathField.text) + } + } + + // Cache Size + Label { text: "Cache Size (GB):" } + TextField { + id: cacheSizeField + Layout.fillWidth: true + + Component.onCompleted: { + text = settingsDialog.cacheSize.toFixed(1) + } + + onEditingFinished: { + var value = parseFloat(text) + if (!isNaN(value) && value >= 0.5 && value <= 16) { + settingsDialog.cacheSize = value + text = value.toFixed(1) // Format it + } else { + // Invalid input, reset to current value + text = settingsDialog.cacheSize.toFixed(1) + } + } + } + Label { + id: cacheUsageLabel + text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" + color: "#1013e6" + } + + // Prefetch Radius + Label { text: "Prefetch Radius:" } + SpinBox { + id: prefetchRadiusSpinBox + from: 1 + to: 20 + value: settingsDialog.prefetchRadius + onValueChanged: settingsDialog.prefetchRadius = value + } + Label {} // Placeholder + + // Theme + Label { text: "Theme:" } + ComboBox { + id: themeComboBox + model: ["Dark", "Light"] + currentIndex: settingsDialog.theme + onCurrentIndexChanged: settingsDialog.theme = currentIndex + } + Label {} // Placeholder + + // Default Directory + Label { text: "Default Image Directory:" } + TextField { + id: defaultDirectoryField + Layout.fillWidth: true + text: settingsDialog.defaultDirectory + onTextChanged: settingsDialog.defaultDirectory = text + } + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_directory_dialog() + if (path) defaultDirectoryField.text = path + } + } + } + + // Poll cache usage periodically while the dialog is open + Timer { + id: cacheUsageTimer + interval: 1000 + repeat: true + running: false + onTriggered: settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + } +} diff --git a/faststack.working-menus/faststack/tests/test_cache.py b/faststack.working-menus/faststack/tests/test_cache.py new file mode 100644 index 0000000..e3f90a1 --- /dev/null +++ b/faststack.working-menus/faststack/tests/test_cache.py @@ -0,0 +1,61 @@ +"""Tests for the byte-aware LRU cache.""" + +import pytest + +from faststack.imaging.cache import ByteLRUCache + +class MockItem: + """A mock object with a settable size.""" + def __init__(self, size: int): + self._size = size + + def __sizeof__(self) -> int: + return self._size + +def test_cache_init(): + """Tests cache initialization.""" + cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) + assert cache.maxsize == 1000 + assert cache.currsize == 0 + +def test_cache_add_items(): + """Tests adding items and tracking size.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(20) + assert cache.currsize == 20 + cache["b"] = MockItem(30) + assert cache.currsize == 50 + assert "a" in cache + assert "b" in cache + +def test_cache_eviction(): + """Tests that the least recently used item is evicted when full.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(50) # a is oldest + cache["b"] = MockItem(40) + cache["c"] = MockItem(30) # This should evict 'a' + + assert "a" not in cache + assert "b" in cache + assert "c" in cache + assert cache.currsize == 70 # 40 + 30 + + cache["d"] = MockItem(50) # This should evict 'b' + assert "b" not in cache + assert "c" in cache + assert "d" in cache + assert cache.currsize == 80 # 30 + 50 + +def test_cache_update_item(): + """Tests that updating an item adjusts the cache size.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(20) + assert cache.currsize == 20 + + # Replace with a larger item + cache["a"] = MockItem(50) + assert cache.currsize == 50 + + # Replace with a smaller item + cache["a"] = MockItem(10) + assert cache.currsize == 10 diff --git a/faststack.working-menus/faststack/tests/test_executable_validator.py b/faststack.working-menus/faststack/tests/test_executable_validator.py new file mode 100644 index 0000000..2a80a0d --- /dev/null +++ b/faststack.working-menus/faststack/tests/test_executable_validator.py @@ -0,0 +1,131 @@ +"""Tests for executable path validation.""" + +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from faststack.io.executable_validator import ( + validate_executable_path, + _is_executable, + _is_subpath, +) + + +def test_empty_path(): + """Test that empty path is rejected.""" + is_valid, error = validate_executable_path("") + assert not is_valid + assert "empty" in error.lower() + + +def test_nonexistent_file(): + """Test that nonexistent file is rejected.""" + is_valid, error = validate_executable_path("C:\\nonexistent\\fake.exe") + assert not is_valid + assert "not found" in error.lower() + + +def test_valid_photoshop_path(): + """Test validation of a valid Photoshop path.""" + photoshop_path = r"C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe" + + # Mock the path checks + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "Photoshop.exe" + mock_path_instance.__str__ = lambda self: photoshop_path + + with patch('faststack.io.executable_validator._is_subpath', return_value=True): + is_valid, error = validate_executable_path( + photoshop_path, + app_type="photoshop" + ) + assert is_valid + assert error is None + + +def test_suspicious_path_with_traversal(): + """Test that paths with directory traversal are flagged.""" + suspicious_path = r"C:\Program Files\..\Windows\System32\malware.exe" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "malware.exe" + mock_path_instance.__str__ = lambda self: r"C:\Windows\System32\malware.exe" + + # The normalized path will differ from input, triggering warning + with patch('faststack.io.executable_validator._is_subpath', return_value=False): + is_valid, error = validate_executable_path(suspicious_path) + # Warning is logged for suspicious path, but doesn't fail with allow_custom_paths=True + assert is_valid # Default allow_custom_paths=True means it passes with warning + + +def test_non_exe_file(): + """Test that non-executable files are rejected on Windows.""" + txt_file = r"C:\Program Files\test.txt" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.txt' + + is_valid, error = validate_executable_path(txt_file) + assert not is_valid + assert "not executable" in error.lower() + + +def test_is_executable_windows(): + """Test _is_executable on Windows.""" + with patch('os.name', 'nt'): + exe_path = MagicMock() + exe_path.suffix.lower.return_value = '.exe' + assert _is_executable(exe_path) + + txt_path = MagicMock() + txt_path.suffix.lower.return_value = '.txt' + assert not _is_executable(txt_path) + + +def test_is_subpath(): + """Test _is_subpath logic.""" + # This is hard to test without real paths, so we'll test the logic + parent = Path(r"C:\Program Files") + child = Path(r"C:\Program Files\Adobe\Photoshop.exe") + + # Mock the relative_to to simulate success + with patch.object(Path, 'resolve') as mock_resolve: + mock_resolve.return_value.relative_to = MagicMock() + result = _is_subpath(child, parent) + assert result + + +def test_wrong_executable_name_for_type(): + """Test that wrong executable names generate warnings but don't fail.""" + wrong_exe = r"C:\Program Files\Adobe\NotPhotoshop.exe" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "NotPhotoshop.exe" + mock_path_instance.__str__ = lambda self: wrong_exe + + with patch('faststack.io.executable_validator._is_subpath', return_value=True): + # Should still pass, but with a warning logged + is_valid, error = validate_executable_path( + wrong_exe, + app_type="photoshop" + ) + assert is_valid # Name mismatch is warning, not failure diff --git a/faststack.working-menus/faststack/tests/test_pairing.py b/faststack.working-menus/faststack/tests/test_pairing.py new file mode 100644 index 0000000..bfe2bb1 --- /dev/null +++ b/faststack.working-menus/faststack/tests/test_pairing.py @@ -0,0 +1,74 @@ +"""Tests for the RAW-JPG pairing logic.""" + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from faststack.io.indexer import find_images, _find_raw_pair + +@pytest.fixture +def mock_image_dir(tmp_path: Path): + """Creates a temporary directory with mock image files.""" + # JPGs + (tmp_path / "IMG_0001.JPG").touch() + time.sleep(0.01) + (tmp_path / "IMG_0002.jpg").touch() + time.sleep(0.01) + (tmp_path / "IMG_0003.jpeg").touch() + time.sleep(0.01) + + # Raws (CR3) + (tmp_path / "IMG_0001.CR3").touch() # Perfect match + # Match for 0002, but with a slight time diff + two_cr3 = (tmp_path / "IMG_0002.CR3") + two_cr3.touch() + # Change timestamp slightly + os.utime(two_cr3, (two_cr3.stat().st_atime, two_cr3.stat().st_mtime + 0.5)) + + # A raw with no JPG + (tmp_path / "IMG_0004.CR3").touch() + + return tmp_path + +def test_find_images(mock_image_dir: Path): + """Tests the main find_images function.""" + images = find_images(mock_image_dir) + + assert len(images) == 3 + assert images[0].path.name == "IMG_0001.JPG" + assert images[0].raw_pair is not None + assert images[0].raw_pair.name == "IMG_0001.CR3" + + assert images[1].path.name == "IMG_0002.jpg" + assert images[1].raw_pair is not None + assert images[1].raw_pair.name == "IMG_0002.CR3" + + assert images[2].path.name == "IMG_0003.jpeg" + assert images[2].raw_pair is None + +def test_raw_pairing_logic(): + """Unit tests the _find_raw_pair function specifically.""" + jpg_path = Path("IMG_01.JPG") + jpg_stat = MagicMock(); jpg_stat.st_mtime = 1000.0 + + # Case 1: Perfect match + raw1_path = Path("IMG_01.CR3"); raw1_stat = MagicMock(); raw1_stat.st_mtime = 1000.1 + potentials = [(raw1_path, raw1_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path + + # Case 2: No match (time delta too large) + raw2_path = Path("IMG_01.CR3"); raw2_stat = MagicMock(); raw2_stat.st_mtime = 1003.0 + potentials = [(raw2_path, raw2_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None + + # Case 3: Closest match is chosen + raw3_path = Path("IMG_01_A.CR3"); raw3_stat = MagicMock(); raw3_stat.st_mtime = 1000.5 + raw4_path = Path("IMG_01_B.CR3"); raw4_stat = MagicMock(); raw4_stat.st_mtime = 1001.8 + potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path + + # Case 4: No potential RAWs + assert _find_raw_pair(jpg_path, jpg_stat, []) is None diff --git a/faststack.working-menus/faststack/tests/test_sidecar.py b/faststack.working-menus/faststack/tests/test_sidecar.py new file mode 100644 index 0000000..99f0ec3 --- /dev/null +++ b/faststack.working-menus/faststack/tests/test_sidecar.py @@ -0,0 +1,74 @@ +"""Tests for the SidecarManager.""" + +import json +from pathlib import Path + +import pytest + +from faststack.io.sidecar import SidecarManager +from faststack.models import EntryMetadata + +@pytest.fixture +def mock_sidecar_dir(tmp_path: Path): + """Creates a temp dir and can pre-populate a sidecar file.""" + def _create(content: dict = None): + if content: + (tmp_path / "faststack.json").write_text(json.dumps(content)) + return tmp_path + return _create + +def test_sidecar_load_non_existent(mock_sidecar_dir): + """Tests loading when no sidecar file exists.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + assert sm.data.version == 2 + assert sm.data.last_index == 0 + assert not sm.data.entries + +def test_sidecar_load_existing(mock_sidecar_dir): + """Tests loading a valid, existing sidecar file.""" + content = { + "version": 1, + "last_index": 42, + "entries": { + "IMG_0001": { "flag": True, "reject": False, "stack_id": 1 }, + "IMG_0002": { "flag": False, "reject": True, "stack_id": None }, + } + } + d = mock_sidecar_dir(content) + sm = SidecarManager(d) + + assert sm.data.last_index == 42 + assert len(sm.data.entries) == 2 + assert sm.data.entries["IMG_0001"].flag is True + assert sm.data.entries["IMG_0001"].stack_id == 1 + assert sm.data.entries["IMG_0002"].reject is True + +def test_sidecar_save(mock_sidecar_dir): + """Tests saving data back to the JSON file.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + + # Modify data + sm.set_last_index(10) + meta = sm.get_metadata("IMG_TEST") + meta.flag = True + meta.stack_id = 5 + + # Save + sm.save() + + # Verify file content + saved_data = json.loads((d / "faststack.json").read_text()) + assert saved_data["last_index"] == 10 + assert saved_data["entries"]["IMG_TEST"]["flag"] is True + assert saved_data["entries"]["IMG_TEST"]["stack_id"] == 5 + +def test_sidecar_get_metadata_creates_new(mock_sidecar_dir): + """Tests that get_metadata creates a new entry if one doesn't exist.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + assert "NEW_IMG" not in sm.data.entries + meta = sm.get_metadata("NEW_IMG") + assert isinstance(meta, EntryMetadata) + assert "NEW_IMG" in sm.data.entries diff --git a/faststack.working-menus/faststack/ui/keystrokes.py b/faststack.working-menus/faststack/ui/keystrokes.py new file mode 100644 index 0000000..84b4a25 --- /dev/null +++ b/faststack.working-menus/faststack/ui/keystrokes.py @@ -0,0 +1,109 @@ +# faststack/ui/keystrokes.py +import logging +from PySide6.QtCore import Qt + +log = logging.getLogger(__name__) + +class Keybinder: + def __init__(self, controller): + """ + controller is your AppController. + We will call controller.() by default, + but if controller.main_window has a QML method of the same name, + we'll call that instead so the footer/UI stays in sync. + """ + self.controller = controller + + # map keys → method names (not callables) + self.key_map = { + # Navigation + Qt.Key_J: "next_image", + Qt.Key_Right: "next_image", + Qt.Key_K: "prev_image", + Qt.Key_Left: "prev_image", + Qt.Key_G: "show_jump_to_image_dialog", + + # Stacking + Qt.Key_BracketLeft: "begin_new_stack", + Qt.Key_BracketRight: "end_current_stack", + Qt.Key_S: "toggle_stack_membership", + + # Batching + Qt.Key_BraceLeft: "begin_new_batch", + Qt.Key_BraceRight: "end_current_batch", + Qt.Key_Backslash: "clear_all_batches", + Qt.Key_B: "toggle_batch_membership", + + # Remove from batch/stack + Qt.Key_X: "remove_from_batch_or_stack", + + # Toggle flags + Qt.Key_U: "toggle_uploaded", + + # Actions + Qt.Key_Enter: "launch_helicon", + Qt.Key_Return: "launch_helicon", + Qt.Key_P: "edit_in_photoshop", + Qt.Key_C: "clear_all_stacks", + Qt.Key_Delete: "delete_current_image", + Qt.Key_Backspace: "delete_current_image", + } + + self.modifier_key_map = { + (Qt.Key_C, Qt.ControlModifier): "copy_path_to_clipboard", + (Qt.Key_0, Qt.ControlModifier): "reset_zoom_pan", + (Qt.Key_Z, Qt.ControlModifier): "undo_delete", + (Qt.Key_E, Qt.ControlModifier): "toggle_edited", + (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", + } + + def _call(self, method_name: str): + """ + Try QML root first (to keep footer/UI happy), then controller. + """ + mw = getattr(self.controller, "main_window", None) + if mw is not None and hasattr(mw, method_name): + getattr(mw, method_name)() + return + + if hasattr(self.controller, method_name): + getattr(self.controller, method_name)() + return + + log.warning(f"Keybinder: neither main_window nor controller has '{method_name}'") + + def handle_key_press(self, event): + key = event.key() + text = event.text() + log.debug(f"Key pressed: {key} ({text!r}) with modifiers {event.modifiers()}") + + # Check for modifier + key combinations + for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): + if key == mapped_key and event.modifiers() & mapped_modifier: + self._call(method_name) + return True + + # Check for single key presses + method_name = self.key_map.get(key) + if method_name: + self._call(method_name) + return True + + # extra safety for layouts where bracket keycodes are odd + if text == "[": + self._call("begin_new_stack") + return True + if text == "]": + self._call("end_current_stack") + return True + if text == "{": + self._call("begin_new_batch") + return True + if text == "}": + self._call("end_current_batch") + return True + if text == "\\": + self._call("clear_all_batches") + return True + + return False diff --git a/faststack.working-menus/faststack/ui/keystrokes.py.bak b/faststack.working-menus/faststack/ui/keystrokes.py.bak new file mode 100644 index 0000000..812a764 --- /dev/null +++ b/faststack.working-menus/faststack/ui/keystrokes.py.bak @@ -0,0 +1,45 @@ +"""Maps Qt Key events to application actions.""" + +import logging +from PySide6.QtCore import Qt + +log = logging.getLogger(__name__) + +class Keybinder: + def __init__(self, main_window): + self.main_window = main_window + self.key_map = { + # Navigation + Qt.Key.Key_J: self.main_window.next_image, + Qt.Key.Key_Right: self.main_window.next_image, + Qt.Key.Key_K: self.main_window.prev_image, + Qt.Key.Key_Left: self.main_window.prev_image, + + # View Mode + Qt.Key.Key_G: self.main_window.toggle_grid_view, + + # Metadata + Qt.Key.Key_Space: self.main_window.toggle_current_flag, + Qt.Key.Key_X: self.main_window.toggle_current_reject, + + # Stacking + Qt.Key.Key_BracketLeft: self.main_window.begin_new_stack, + Qt.Key.Key_BracketRight: self.main_window.end_current_stack, + + # Actions + Qt.Key.Key_S: self.main_window.toggle_selection, + Qt.Key.Key_Enter: self.main_window.launch_helicon, + Qt.Key.Key_Return: self.main_window.launch_helicon, + + # Stack Management + Qt.Key.Key_C: self.main_window.clear_all_stacks, + } + + def handle_key_press(self, event): + """Handles a key press event from the main window.""" + log.info(f"Key pressed: {event.key()}") + action = self.key_map.get(event.key()) + if action: + action() + return True + return False diff --git a/faststack.working-menus/faststack/ui/provider.py b/faststack.working-menus/faststack/ui/provider.py new file mode 100644 index 0000000..93e9881 --- /dev/null +++ b/faststack.working-menus/faststack/ui/provider.py @@ -0,0 +1,343 @@ +"""QML Image Provider and application state bridge.""" + +import logging +from PySide6.QtCore import QObject, Signal, Property, Slot, Qt +from PySide6.QtGui import QImage +from PySide6.QtQuick import QQuickImageProvider + +from faststack.models import DecodedImage +from faststack.config import config + +# Try to import QColorSpace if available (Qt 6+) +try: + from PySide6.QtGui import QColorSpace + HAS_COLOR_SPACE = True +except ImportError: + HAS_COLOR_SPACE = False + +log = logging.getLogger(__name__) + + +class ImageProvider(QQuickImageProvider): + def __init__(self, app_controller): + super().__init__(QQuickImageProvider.ImageType.Image) + self.app_controller = app_controller + self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888) + self.placeholder.fill(Qt.GlobalColor.darkGray) + + def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: + """Handles image requests from QML.""" + if not id: + return self.placeholder + + try: + image_index_str = id.split('/')[0] + index = int(image_index_str) + image_data = self.app_controller.get_decoded_image(index) + + if image_data: + qimg = QImage( + image_data.buffer, + image_data.width, + image_data.height, + image_data.bytes_per_line, + QImage.Format.Format_RGB888 + ) + # Set sRGB color space for proper color management (if available) + # Skip this when using ICC mode - pixels are already in monitor space + color_mode = config.get('color', 'mode', fallback="none").lower() + if HAS_COLOR_SPACE and color_mode != "icc": + try: + # Create sRGB color space using constructor with NamedColorSpace enum + cs = QColorSpace(QColorSpace.NamedColorSpace.SRgb) + qimg.setColorSpace(cs) + log.debug("Applied sRGB color space to image") + except (RuntimeError, ValueError) as e: + log.warning(f"Failed to set color space: {e}") + elif color_mode == "icc": + log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)") + # keep buffer alive + qimg.original_buffer = image_data.buffer + return qimg + + except (ValueError, IndexError) as e: + log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") + + return self.placeholder + + +class UIState(QObject): + """Manages the state exposed to the QML user interface.""" + + # Signals + currentIndexChanged = Signal() + imageCountChanged = Signal() + currentImageSourceChanged = Signal() + metadataChanged = Signal() + themeChanged = Signal() + preloadingStateChanged = Signal() + preloadProgressChanged = Signal() + isZoomedChanged = Signal() + statusMessageChanged = Signal() # New signal for status messages + resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan + stackSummaryChanged = Signal() # Signal for stack summary updates + filterStringChanged = Signal() # Signal for filter string updates + colorModeChanged = Signal() # Signal for color mode updates + saturationFactorChanged = Signal() # Signal for saturation factor updates + + def __init__(self, app_controller): + super().__init__() + self.app_controller = app_controller + self._is_preloading = False + self._preload_progress = 0 + # 1 = light, 0 = dark (controller will overwrite this on startup) + self._theme = 1 + self._status_message = "" # New private variable for status message + + # ---- THEME PROPERTY ---- + @Property(int, notify=themeChanged) + def theme(self): + return self._theme + + @theme.setter + def theme(self, value: int): + value = int(value) + if value == self._theme: + return + self._theme = value + self.themeChanged.emit() + + # ---- ZOOM ---- + @Property(bool, notify=isZoomedChanged) + def isZoomed(self): + return self.app_controller.is_zoomed + + @Slot(bool) + def setZoomed(self, zoomed: bool): + self.app_controller.set_zoomed(zoomed) + + # ---- PRELOADING ---- + @Property(bool, notify=preloadingStateChanged) + def isPreloading(self): + return self._is_preloading + + @isPreloading.setter + def isPreloading(self, value): + if self._is_preloading != value: + self._is_preloading = value + self.preloadingStateChanged.emit() + + @Property(int, notify=preloadProgressChanged) + def preloadProgress(self): + return self._preload_progress + + @preloadProgress.setter + def preloadProgress(self, value): + if self._preload_progress != value: + self._preload_progress = value + self.preloadProgressChanged.emit() + + # ---- IMAGE / METADATA ---- + @Property(int, notify=currentIndexChanged) + def currentIndex(self): + return self.app_controller.current_index + + @Property(int, notify=imageCountChanged) + def imageCount(self): + return len(self.app_controller.image_files) + + @Property(str, notify=currentImageSourceChanged) + def currentImageSource(self): + return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" + + @Property(str, notify=metadataChanged) + def currentFilename(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("filename", "") + + @Property(bool, notify=metadataChanged) + def isStacked(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("stacked", False) + + @Property(str, notify=metadataChanged) + def stackedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("stacked_date", "") + + @Property(str, notify=metadataChanged) + def stackInfoText(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("stack_info_text", "") + + @Property(bool, notify=metadataChanged) + def isUploaded(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("uploaded", False) + + @Property(str, notify=metadataChanged) + def uploadedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("uploaded_date", "") + + @Property(str, notify=metadataChanged) + def batchInfoText(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("batch_info_text", "") + + @Property(bool, notify=metadataChanged) + def isEdited(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("edited", False) + + @Property(str, notify=metadataChanged) + def editedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("edited_date", "") + + @Property(str, notify=stackSummaryChanged) + def stackSummary(self): + if not self.app_controller.stacks: + return "No stacks defined." + summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" + for i, (start, end) in enumerate(self.app_controller.stacks): + count = end - start + 1 + summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" + return summary + + @Property(str, notify=statusMessageChanged) + def statusMessage(self): + return self._status_message + + @statusMessage.setter + def statusMessage(self, value: str): + if self._status_message != value: + self._status_message = value + self.statusMessageChanged.emit() + + @Property(str, notify=filterStringChanged) + def filterString(self): + """Returns the current filter string (empty if no filter active).""" + return self.app_controller.get_filter_string() + + @Property(str, notify=colorModeChanged) + def colorMode(self): + """Returns the current color mode.""" + return self.app_controller.get_color_mode() + + @Property(float, notify=saturationFactorChanged) + def saturationFactor(self): + """Returns the current saturation factor.""" + return self.app_controller.get_saturation_factor() + + @Property(str, constant=True) + def currentDirectory(self): + """Returns the path of the current working directory.""" + return str(self.app_controller.image_dir) + + # --- Slots for QML to call --- + @Slot() + def nextImage(self): + self.app_controller.next_image() + + @Slot() + def prevImage(self): + self.app_controller.prev_image() + + + @Slot() + def launch_helicon(self): + self.app_controller.launch_helicon() + + @Slot() + def clear_all_stacks(self): + self.app_controller.clear_all_stacks() + + @Slot(result=str) + def get_helicon_path(self): + return self.app_controller.get_helicon_path() + + @Slot(str) + def set_helicon_path(self, path): + self.app_controller.set_helicon_path(path) + + @Slot(result=str) + def get_photoshop_path(self): + return self.app_controller.get_photoshop_path() + + @Slot(str) + def set_photoshop_path(self, path): + self.app_controller.set_photoshop_path(path) + + @Slot(result=str) + def open_file_dialog(self): + return self.app_controller.open_file_dialog() + + @Slot(str, result=bool) + def check_path_exists(self, path): + return self.app_controller.check_path_exists(path) + + @Slot(result=float) + def get_cache_size(self): + return self.app_controller.get_cache_size() + + @Slot(result=float) + def get_cache_usage_gb(self): + return self.app_controller.get_cache_usage_gb() + + @Slot(float) + def set_cache_size(self, size): + self.app_controller.set_cache_size(size) + + @Slot(result=int) + def get_prefetch_radius(self): + return self.app_controller.get_prefetch_radius() + + @Slot(int) + def set_prefetch_radius(self, radius): + self.app_controller.set_prefetch_radius(radius) + + @Slot(result=int) + def get_theme(self): + # this lets QML ask the controller, but the real binding is uiState.theme + return self.app_controller.get_theme() + + @Slot(int) + def set_theme(self, theme_index): + # delegate to controller so it can save to config + self.app_controller.set_theme(theme_index) + + @Slot(result=str) + def get_default_directory(self): + return self.app_controller.get_default_directory() + + @Slot(str) + def set_default_directory(self, path): + self.app_controller.set_default_directory(path) + + @Slot(result=str) + def open_directory_dialog(self): + return self.app_controller.open_directory_dialog() + + @Slot() + def preloadAllImages(self): + self.app_controller.preload_all_images() + + @Slot(int, int) + def onDisplaySizeChanged(self, width: int, height: int): + self.app_controller.on_display_size_changed(width, height) + + @Slot() + def resetZoomPan(self): + """Triggers a reset of zoom and pan in QML.""" + self.resetZoomPanRequested.emit() + diff --git a/faststack.working-menus/faststack/ui/provider.py.bak b/faststack.working-menus/faststack/ui/provider.py.bak new file mode 100644 index 0000000..fad115f --- /dev/null +++ b/faststack.working-menus/faststack/ui/provider.py.bak @@ -0,0 +1,247 @@ +"""QML Image Provider and application state bridge.""" + +import logging +from typing import Optional + +import numpy as np +from PySide6.QtCore import QObject, Signal, Property, QUrl, Slot, Qt +from PySide6.QtGui import QImage +from PySide6.QtQuick import QQuickImageProvider + +from faststack.models import DecodedImage + +log = logging.getLogger(__name__) + +class ImageProvider(QQuickImageProvider): + def __init__(self, app_controller): + super().__init__(QQuickImageProvider.ImageType.Image) + self.app_controller = app_controller + self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888) + self.placeholder.fill(Qt.GlobalColor.darkGray) + + def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: + """Handles image requests from QML.""" + if not id: + return self.placeholder + + # The ID is expected to be the image index + try: + image_index_str = id.split('/')[0] + index = int(image_index_str) + image_data = self.app_controller.get_decoded_image(index) + + if image_data: + # Zero-copy QImage from numpy buffer + qimg = QImage( + image_data.buffer, + image_data.width, + image_data.height, + image_data.bytes_per_line, + QImage.Format.Format_RGB888 + ) + # Keep a reference to the original buffer to prevent garbage collection + qimg.original_buffer = image_data.buffer + return qimg + + except (ValueError, IndexError) as e: + log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") + + return self.placeholder + +class UIState(QObject): + """Manages the state exposed to the QML user interface.""" + + # Signals + currentIndexChanged = Signal() + imageCountChanged = Signal() + currentImageSourceChanged = Signal() + metadataChanged = Signal() + themeChanged = Signal() + preloadingStateChanged = Signal() + preloadProgressChanged = Signal() + isZoomedChanged = Signal() + + def __init__(self, app_controller): + super().__init__() + self.app_controller = app_controller + self._is_preloading = False + self._preload_progress = 0 + self._theme = 1 + @Property(int, notify=themeChanged) + + def theme(self): + return self._theme + + @theme.setter + def theme(self, value: int): + value = int(value) + if value == self._theme: + return + self._theme = value + self.themeChanged.emit() + + + @Property(bool, notify=isZoomedChanged) + def isZoomed(self): + return self.app_controller.is_zoomed + + @Slot(bool) + def setZoomed(self, zoomed: bool): + self.app_controller.set_zoomed(zoomed) + + @Property(bool, notify=preloadingStateChanged) + def isPreloading(self): + return self._is_preloading + + @isPreloading.setter + def isPreloading(self, value): + if self._is_preloading != value: + self._is_preloading = value + self.preloadingStateChanged.emit() + + @Property(int, notify=preloadProgressChanged) + def preloadProgress(self): + return self._preload_progress + + @preloadProgress.setter + def preloadProgress(self, value): + if self._preload_progress != value: + self._preload_progress = value + self.preloadProgressChanged.emit() + + @Property(int, notify=currentIndexChanged) + def currentIndex(self): + return self.app_controller.current_index + + @Property(int, notify=imageCountChanged) + def imageCount(self): + return len(self.app_controller.image_files) + + @Property(str, notify=currentImageSourceChanged) + def currentImageSource(self): + # The source is the provider ID, which we tie to the index and a generation counter + # to force QML to request a new image even if the index is the same. + return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" + + # --- Metadata Properties --- + @Property(str, notify=metadataChanged) + def currentFilename(self): + return self.app_controller.get_current_metadata().get("filename", "") + + @Property(bool, notify=metadataChanged) + def isFlagged(self): + return self.app_controller.get_current_metadata().get("flag", False) + + @Property(bool, notify=metadataChanged) + def isRejected(self): + return self.app_controller.get_current_metadata().get("reject", False) + + @Property(bool, notify=metadataChanged) + def isStacked(self): + return self.app_controller.get_current_metadata().get("stacked", False) + + @Property(str, notify=metadataChanged) + def stackedDate(self): + return self.app_controller.get_current_metadata().get("stacked_date", "") + + @Property(str, notify=metadataChanged) + def stackInfoText(self): + return self.app_controller.get_current_metadata().get("stack_info_text", "") + + @Property(str, notify=metadataChanged) + def get_stack_summary(self): + if not self.app_controller.stacks: + return "No stacks defined." + + summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" + for i, (start, end) in enumerate(self.app_controller.stacks): + count = end - start + 1 + summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" + return summary + + # --- Slots for QML to call --- + @Slot() + def nextImage(self): + self.app_controller.next_image() + + @Slot() + def prevImage(self): + self.app_controller.prev_image() + + @Slot() + def toggleFlag(self): + self.app_controller.toggle_current_flag() + + @Slot() + def launch_helicon(self): + self.app_controller.launch_helicon() + + @Slot() + def clear_all_stacks(self): + self.app_controller.clear_all_stacks() + + @Slot(result=str) + def get_helicon_path(self): + return self.app_controller.get_helicon_path() + + @Slot(str) + def set_helicon_path(self, path): + self.app_controller.set_helicon_path(path) + + @Slot(result=str) + def open_file_dialog(self): + return self.app_controller.open_file_dialog() + + @Slot(str, result=bool) + def check_path_exists(self, path): + return self.app_controller.check_path_exists(path) + + @Slot(result=float) + def get_cache_size(self): + return self.app_controller.get_cache_size() + + @Slot(float) + def set_cache_size(self, size): + self.app_controller.set_cache_size(size) + + @Slot(result=int) + def get_prefetch_radius(self): + return self.app_controller.get_prefetch_radius() + + @Slot(int) + def set_prefetch_radius(self, radius): + self.app_controller.set_prefetch_radius(radius) + + @Slot(result=int) + def get_theme(self): + return self.app_controller.get_theme() + + @Slot(int) + def set_theme(self, theme_index): + # update UI property + self.ui_state.theme = theme_index + + # persist + theme = 'dark' if theme_index == 0 else 'light' + config.set('core', 'theme', theme) + config.save() + + @Slot(result=str) + def get_default_directory(self): + return self.app_controller.get_default_directory() + + @Slot(str) + def set_default_directory(self, path): + self.app_controller.set_default_directory(path) + + @Slot(result=str) + def open_directory_dialog(self): + return self.app_controller.open_directory_dialog() + + @Slot() + def preloadAllImages(self): + self.app_controller.preload_all_images() + + @Slot(int, int) + def onDisplaySizeChanged(self, width: int, height: int): + self.app_controller.on_display_size_changed(width, height) diff --git a/faststack.working-menus/patch b/faststack.working-menus/patch new file mode 100644 index 0000000..cf0f009 --- /dev/null +++ b/faststack.working-menus/patch @@ -0,0 +1,746 @@ +--- /dev/null ++++ b/faststack/imaging/editor.py +@@ -0,0 +1,141 @@ ++import os ++import shutil ++import glob ++from pathlib import Path ++from typing import Optional, Dict, Any, Tuple ++from PIL import Image, ImageEnhance ++from io import BytesIO ++ ++# Aspect Ratios for cropping ++INSTAGRAM_RATIOS = { ++ "Freeform": None, ++ "1:1 (Square)": (1, 1), ++ "4:5 (Portrait)": (4, 5), ++ "1.91:1 (Landscape)": (191, 100), ++ "9:16 (Story)": (9, 16), ++} ++ ++class ImageEditor: ++ """Handles core image manipulation using PIL.""" ++ def __init__(self): ++ # Stores the currently loaded PIL Image object (original) ++ self.original_image: Optional[Image.Image] = None ++ # Stores the currently applied edits (used for preview) ++ self.current_edits: Dict[str, Any] = self._initial_edits() ++ self.current_filepath: Optional[Path] = None ++ ++ def _initial_edits(self) -> Dict[str, Any]: ++ return { ++ 'brightness': 0.0, ++ 'contrast': 0.0, ++ 'saturation': 0.0, ++ 'white_balance_by': 0.0, # Blue/Yellow (Cool/Warm) ++ 'white_balance_mg': 0.0, # Magenta/Green (Tint) ++ 'crop_box': None, # (left, top, right, bottom) normalized to 0-1000 ++ } ++ ++ def load_image(self, filepath: str): ++ """Load a new image for editing.""" ++ if not filepath or not Path(filepath).exists(): ++ self.original_image = None ++ self.current_filepath = None ++ return False ++ ++ self.current_filepath = Path(filepath) ++ # Reset edits ++ self.current_edits = self._initial_edits() ++ ++ try: ++ # We must load and close the original file handle immediately ++ self.original_image = Image.open(self.current_filepath).convert("RGB") ++ return True ++ except Exception as e: ++ print(f"Error loading image for editing: {e}") ++ self.original_image = None ++ return False ++ ++ def _apply_edits(self, img: Image.Image) -> Image.Image: ++ """Applies all current edits to the provided PIL Image.""" ++ ++ # 1. Cropping (if defined) ++ crop_box = self.current_edits.get('crop_box') ++ if crop_box: ++ # Denormalize crop_box (0-1000) to actual pixel values ++ width, height = img.size ++ left = int(crop_box[0] * width / 1000) ++ top = int(crop_box[1] * height / 1000) ++ right = int(crop_box[2] * width / 1000) ++ bottom = int(crop_box[3] * height / 1000) ++ img = img.crop((left, top, right, bottom)) ++ ++ # 2. Brightness (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ bright_factor = 1.0 + self.current_edits['brightness'] ++ img = ImageEnhance.Brightness(img).enhance(bright_factor) ++ ++ # 3. Contrast (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ contrast_factor = 1.0 + self.current_edits['contrast'] ++ img = ImageEnhance.Contrast(img).enhance(contrast_factor) ++ ++ # 4. Saturation (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ saturation_factor = 1.0 + self.current_edits['saturation'] ++ img = ImageEnhance.Color(img).enhance(saturation_factor) ++ ++ # 5. White Balance (Tint - simplistic color matrix manipulation) ++ by_val = self.current_edits['white_balance_by'] * 0.5 # -0.5 to 0.5 ++ mg_val = self.current_edits['white_balance_mg'] * 0.5 # -0.5 to 0.5 ++ ++ r, g, b = img.split() ++ ++ # Blue/Yellow adjustment ++ r_by_adjust = r.point(lambda i: i + by_val * 255) ++ g_by_adjust = g.point(lambda i: i + by_val * 255) ++ b_by_adjust = b.point(lambda i: i - by_val * 255) ++ ++ # Magenta/Green adjustment ++ r_mg_adjust = r_by_adjust.point(lambda i: i + mg_val * 255) ++ g_mg_adjust = g_by_adjust.point(lambda i: i - mg_val * 255) ++ b_mg_adjust = b_by_adjust.point(lambda i: i + mg_val * 255) ++ ++ return Image.merge("RGB", (r_mg_adjust, g_mg_adjust, b_mg_adjust)) ++ ++ ++ def get_preview_data(self) -> Optional[bytes]: ++ """Apply current edits to the image and return the data as PNG bytes.""" ++ if self.original_image is None: ++ return None ++ ++ img = self.original_image.copy() ++ img = self._apply_edits(img) ++ ++ # Convert the PIL image to bytes (PNG to avoid repeated lossy compression during preview) ++ byte_arr = BytesIO() ++ img.save(byte_arr, format='PNG') ++ return byte_arr.getvalue() ++ ++ ++ def set_edit_param(self, key: str, value: float) -> bool: ++ """Update a single edit parameter (normalized -1.0 to 1.0).""" ++ if key in self.current_edits and key != 'crop_box': ++ self.current_edits[key] = value ++ return True ++ return False ++ ++ def set_crop_box(self, crop_box: Tuple[int, int, int, int]): ++ """Set the normalized crop box (left, top, right, bottom) from 0-1000.""" ++ self.current_edits['crop_box'] = crop_box ++ ++ def save_image(self) -> Optional[Path]: ++ """Saves the edited image, backing up the original.""" ++ if self.original_image is None or self.current_filepath is None: ++ return None ++ ++ final_img = self.original_image.copy() ++ final_img = self._apply_edits(final_img) ++ ++ original_path = self.current_filepath ++ backup_path_base = original_path.parent / (original_path.stem + "-backup") ++ backup_path = backup_path_base.with_suffix(".jpg") ++ ++ i = 1 ++ while backup_path.exists(): ++ backup_path = backup_path_base.with_suffix(f"#{i}.jpg") ++ i += 1 ++ ++ try: ++ # Perform the backup and overwrite ++ shutil.copy2(original_path, backup_path) ++ final_img.save(original_path, format='JPEG', quality=95) # Save as high-quality JPEG ++ return original_path ++ except Exception as e: ++ print(f"Failed to save edited image or backup: {e}") ++ return None ++ ++# Dictionary of ratios for QML dropdown ++ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] ++``` +--- /dev/null ++++ b/qml/ImageEditorDialog.qml +@@ -0,0 +1,105 @@ ++import QtQuick 2.15 ++import QtQuick.Controls 2.15 ++import QtQuick.Layouts 1.15 ++ ++// Draggable popup dialog for image editing controls +Pane { ++ id: editDialog ++ // Use fixed size for simplicity in a draggable panel ++ width: 300 ++ height: 500 ++ ++ // Initial position - centered, slightly high ++ x: (parent.width - width) / 2 ++ y: parent.height * 0.1 ++ ++ // Draggable behavior (Title Bar) ++ MouseArea { ++ id: titleBar ++ anchors.top: parent.top ++ anchors.left: parent.left ++ anchors.right: parent.right ++ height: 30 ++ acceptedButtons: Qt.LeftButton ++ ++ Rectangle { ++ anchors.fill: parent ++ color: uiState.theme === 1 ? "#333333" : "#DDDDDD" ++ border.color: "black" ++ border.width: 1 ++ Text { ++ anchors.centerIn: parent ++ text: "Image Editor" ++ color: uiState.theme === 1 ? "white" : "black" ++ font.bold: true ++ } ++ } ++ ++ property point dragPoint: Qt.point(0,0) ++ ++ onPressed: { dragPoint = Qt.point(mouse.x, mouse.y) } ++ onPositionChanged: { ++ editDialog.x += mouse.x - dragPoint.x ++ editDialog.y += mouse.y - dragPoint.y ++ } ++ } ++ ++ ColumnLayout { ++ anchors.top: titleBar.bottom ++ anchors.left: parent.left ++ anchors.right: parent.right ++ spacing: 10 ++ padding: 10 ++ ++ // Helper component for Sliders ++ Component { ++ id: editSlider ++ ColumnLayout { ++ Text { ++ text: modelData.name + ": " + (modelData.value * 100).toFixed(0) ++ color: uiState.theme === 1 ? "white" : "black" ++ font.pixelSize: 14 ++ } ++ Slider { ++ id: slider ++ Layout.fillWidth: true ++ from: -100 ++ to: 100 ++ stepSize: 1 ++ value: modelData.value * 100 ++ onValueChanged: { ++ // Normalize back to -1.0 to 1.0 for Python logic ++ appModel.app_controller.set_edit_parameter(modelData.key, slider.value / 100.0) ++ } ++ } ++ } ++ } ++ ++ // Sliders List ++ Repeater { ++ model: [ ++ { name: "Brightness", key: "brightness", value: appModel.brightness }, ++ { name: "Contrast", key: "contrast", value: appModel.contrast }, ++ { name: "Saturation", key: "saturation", value: appModel.saturation }, ++ { name: "White Balance (B/Y)", key: "white_balance_by", value: appModel.whiteBalanceBY }, ++ { name: "White Balance (M/G)", key: "white_balance_mg", value: appModel.whiteBalanceMG } ++ ] ++ delegate: editSlider ++ } ++ ++ // Crop Controls ++ RowLayout { ++ Layout.fillWidth: true ++ spacing: 5 ++ ++ Button { ++ id: cropButton ++ text: appModel.isCropping ? "Cropping (Click/Drag)" : "Crop" ++ Layout.fillWidth: true ++ onClicked: { appModel.isCropping = !appModel.isCropping } ++ } ++ ++ ComboBox { ++ id: aspectRatioDropdown ++ Layout.preferredWidth: 100 ++ model: appModel.aspectRatioNames ++ currentIndex: appModel.currentAspectRatioIndex ++ onCurrentIndexChanged: { appModel.currentAspectRatioIndex = currentIndex } ++ // NOTE: Aspect ratio locking logic is not fully implemented in this patch ++ // due to complexity, but the model property is available for future use. ++ } ++ } ++ ++ Text { ++ Layout.fillWidth: true ++ wrapMode: Text.WordWrap ++ text: appModel.isCropping ? "Click/Drag to select crop area. Press ENTER to execute crop." : "" ++ color: "red" ++ horizontalAlignment: Text.AlignHCenter ++ font.pixelSize: 12 ++ } ++ ++ Button { ++ text: "Reset All Edits" ++ Layout.fillWidth: true ++ onClicked: { appModel.reset_edit_parameters() } ++ } ++ } ++} +``` +--- a/app.py ++++ b/app.py +@@ -52,12 +52,24 @@ + + # -------------------------------------------------------------------------- + # Image Provider Setup +- self.image_provider = ImageProvider(self.app_controller.app_state_model) ++ # Use a single provider for both original and edited images ++ self.image_provider = ImageProvider(self.app_controller.app_state_model, is_edited_provider=False) + self.engine.addImageProvider("imageProvider", self.image_provider) ++ ++ # Separate provider for the live-edited image preview ++ self.edited_image_provider = ImageProvider(self.app_controller.app_state_model, is_edited_provider=True) ++ self.engine.addImageProvider("editedImageProvider", self.edited_image_provider) + + # -------------------------------------------------------------------------- + # Engine Context Setup + self.engine.rootContext().setContextProperty("appModel", self.app_controller.app_state_model) ++ ++ # -------------------------------------------------------------------------- ++ # Connect signals for edit mode to manage image source ++ self.app_controller.app_state_model.isEditingChanged.connect(self._handle_editing_toggle) ++ # Connect data change signal to the edited provider ++ self.app_controller.app_state_model.currentEditedImageDataChanged.connect( ++ lambda: self.edited_image_provider.image_data = self.app_controller.app_state_model.current_edited_image_data ++ ) + + # Load the QML file + qml_file = Path(__file__).parent / "qml" / "main.qml" +@@ -74,6 +86,14 @@ + if self.engine.rootObjects(): + self.engine.rootObjects()[0].show() + ++ def _handle_editing_toggle(self): ++ """Update the image provider source when editing mode changes.""" ++ is_editing = self.app_controller.app_state_model.is_editing ++ ++ # If starting edit mode, ensure the initial edited data is generated ++ if is_editing: ++ # Trigger the edited image data update to show the initial (unmodified) preview ++ self.app_controller.app_state_model.current_edited_image_data = self.app_controller.image_editor.get_preview_data() + + def main(): + """Main function for FastStack.""" +--- a/app_controller.py ++++ b/app_controller.py +@@ -10,6 +10,7 @@ + from faststack.io.indexer import find_images + from faststack.io.sidecar import SidecarManager + from faststack.imaging.prefetch import Prefetcher ++from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS + + # Set up logger + logger = logging.getLogger(__name__) +@@ -23,6 +24,7 @@ + self.prefetcher = Prefetcher(self) + self.sidecar_manager = SidecarManager(self) + self.app_state_model = AppStateModel(self) ++ self.image_editor = ImageEditor() + + # Load initial directory + self.set_current_directory(config.get("default_directory", str(Path.home()))) +@@ -118,6 +120,53 @@ + self.prefetcher.start_prefetch(self.current_image_index) + + self.app_state_model.current_image_file = current_image ++ # Load the image into the editor instance ++ self.image_editor.load_image(current_image.path if current_image else "") ++ self.app_state_model.reset_edit_parameters() # Reset edits for new image ++ ++ @Slot(result=QVariant) ++ def get_aspect_ratios(self) -> List[Dict[str, Any]]: ++ """Returns the list of aspect ratio names/values.""" ++ return ASPECT_RATIOS ++ ++ @Slot(str, float) ++ def set_edit_parameter(self, key: str, value: float): ++ """Sets a single editing parameter (brightness, saturation, etc.) and updates preview.""" ++ if self.image_editor.set_edit_param(key, value): ++ # Trigger image provider to update the current image with preview data ++ self.app_state_model.current_edited_image_data = self.image_editor.get_preview_data() ++ self.app_state_model.edits_pending = True ++ # Manually update the model property to keep QML sliders synced ++ if key == 'brightness': self.app_state_model.brightness = value ++ elif key == 'contrast': self.app_state_model.contrast = value ++ elif key == 'saturation': self.app_state_model.saturation = value ++ elif key == 'white_balance_by': self.app_state_model.whiteBalanceBY = value ++ elif key == 'white_balance_mg': self.app_state_model.whiteBalanceMG = value ++ ++ @Slot(float, float, float, float) ++ def set_crop_selection_normalized(self, x1, y1, x2, y2): ++ """Sets the normalized crop box (0-1) which is scaled to (0-1000) for Python logic.""" ++ # Python logic expects normalized 0-1000 ++ crop_box = (int(x1*1000), int(y1*1000), int(x2*1000), int(y2*1000)) ++ self.image_editor.set_crop_box(crop_box) ++ # Update preview ++ self.app_state_model.current_edited_image_data = self.image_editor.get_preview_data() ++ self.app_state_model.edits_pending = True ++ ++ @Slot() ++ def execute_crop(self): ++ """Executes the crop selection and disables cropping mode.""" ++ self.app_state_model.isCropping = False ++ self.app_state_model.hasActiveCropSelection = False ++ ++ @Slot(bool) ++ def save_or_discard_edits(self, save_changes: bool): ++ """Handles saving or discarding changes when edit mode is exited.""" ++ if save_changes and self.app_state_model.edits_pending: ++ saved_path = self.image_editor.save_image() ++ if saved_path: ++ self.reload_current_image() # Reload to show the newly saved file ++ ++ # Always reset internal state and clear edited preview data ++ self.app_state_model.current_edited_image_data = None ++ self.app_state_model.reset_edit_parameters() + + @Slot(result=bool) + def is_currently_staring(self): +--- a/app_state_model.py ++++ b/app_state_model.py +@@ -2,7 +2,7 @@ + import logging + from pathlib import Path + from typing import TYPE_CHECKING, Optional, List, Any +-from PySide6.QtCore import QObject, Signal, Slot, Property, QUrl ++from PySide6.QtCore import QObject, Signal, Slot, Property, QUrl, QByteArray, QVariant + + if TYPE_CHECKING: + from .app_controller import AppController +@@ -37,6 +37,7 @@ + # Image editing properties + isEditingChanged = Signal() + isCroppingChanged = Signal() ++ editsPendingChanged = Signal() + + brightnessChanged = Signal() + contrastChanged = Signal() +@@ -47,7 +48,7 @@ + + hasActiveCropSelectionChanged = Signal() + +- # --- New signals for image editing --- ++ currentEditedImageDataChanged = Signal() + + aspectRatioNamesChanged = Signal() + currentAspectRatioIndexChanged = Signal() +@@ -62,6 +63,16 @@ + self._is_editing = False + self._is_cropping = False + ++ # Editing parameters (normalized to -1.0 to 1.0) ++ self._edits_pending = False ++ self._brightness = 0.0 ++ self._contrast = 0.0 ++ self._saturation = 0.0 ++ self._white_balance_by = 0.0 ++ self._white_balance_mg = 0.0 ++ self._has_active_crop_selection = False ++ self._current_edited_image_data = None # Holds image data bytes for QML provider ++ self._current_aspect_ratio_index = 0 ++ self._aspect_ratio_names = [r["name"] for r in self.app_controller.get_aspect_ratios()] ++ + @Property(str, notify=currentImagePathChanged) + def current_image_path(self): + return str(self._current_image_file.path) if self._current_image_file else "" +@@ -176,3 +187,94 @@ + if self._is_cropping != value: + self._is_cropping = value + self.isCroppingChanged.emit() ++ ++ @Property(bool, notify=editsPendingChanged) ++ def edits_pending(self): ++ return self._edits_pending ++ ++ @edits_pending.setter ++ def edits_pending(self, value: bool): ++ if self._edits_pending != value: ++ self._edits_pending = value ++ self.editsPendingChanged.emit() ++ ++ @Property(float, notify=brightnessChanged) ++ def brightness(self): ++ return self._brightness ++ ++ @brightness.setter ++ def brightness(self, value: float): ++ if self._brightness != value: ++ self._brightness = value ++ self.brightnessChanged.emit() ++ ++ @Property(float, notify=contrastChanged) ++ def contrast(self): ++ return self._contrast ++ ++ @contrast.setter ++ def contrast(self, value: float): ++ if self._contrast != value: ++ self._contrast = value ++ self.contrastChanged.emit() ++ ++ @Property(float, notify=saturationChanged) ++ def saturation(self): ++ return self._saturation ++ ++ @saturation.setter ++ def saturation(self, value: float): ++ if self._saturation != value: ++ self._saturation = value ++ self.saturationChanged.emit() ++ ++ @Property(float, notify=whiteBalanceBYChanged) ++ def whiteBalanceBY(self): ++ return self._white_balance_by ++ ++ @whiteBalanceBY.setter ++ def whiteBalanceBY(self, value: float): ++ if self._white_balance_by != value: ++ self._white_balance_by = value ++ self.whiteBalanceBYChanged.emit() ++ ++ @Property(float, notify=whiteBalanceMGChanged) ++ def whiteBalanceMG(self): ++ return self._white_balance_mg ++ ++ @whiteBalanceMG.setter ++ def whiteBalanceMG(self, value: float): ++ if self._white_balance_mg != value: ++ self._white_balance_mg = value ++ self.whiteBalanceMGChanged.emit() ++ ++ @Property(bool, notify=hasActiveCropSelectionChanged) ++ def hasActiveCropSelection(self): ++ return self._has_active_crop_selection ++ ++ @hasActiveCropSelection.setter ++ def hasActiveCropSelection(self, value: bool): ++ if self._has_active_crop_selection != value: ++ self._has_active_crop_selection = value ++ self.hasActiveCropSelectionChanged.emit() ++ ++ @Property(QByteArray, notify=currentEditedImageDataChanged) ++ def current_edited_image_data(self): ++ return self._current_edited_image_data ++ ++ @current_edited_image_data.setter ++ def current_edited_image_data(self, value: Optional[bytes]): ++ data = QByteArray(value) if value is not None else QByteArray() ++ if self._current_edited_image_data != data: ++ self._current_edited_image_data = data ++ self.currentEditedImageDataChanged.emit() ++ ++ @Property("QStringList", notify=aspectRatioNamesChanged) ++ def aspectRatioNames(self): ++ return self._aspect_ratio_names ++ ++ @Property(int, notify=currentAspectRatioIndexChanged) ++ def currentAspectRatioIndex(self): ++ return self._current_aspect_ratio_index ++ ++ @currentAspectRatioIndex.setter ++ def currentAspectRatioIndex(self, value: int): ++ if self._current_aspect_ratio_index != value: ++ self._current_aspect_ratio_index = value ++ self.currentAspectRatioIndexChanged.emit() ++ ++ @Slot() ++ def reset_edit_parameters(self): ++ self.brightness = self.contrast = self.saturation = 0.0 ++ self.whiteBalanceBY = self.whiteBalanceMG = 0.0 ++ self.edits_pending = False ++ self.isCropping = False ++ self.hasActiveCropSelection = False +--- a/faststack/ui/provider.py ++++ b/faststack/ui/provider.py +@@ -10,13 +10,18 @@ + class ImageProvider(QQuickImageProvider): + """Custom image provider for QML to retrieve images from the cache/disk.""" + +- def __init__(self, app_state_model: "AppStateModel"): ++ def __init__(self, app_state_model: "AppStateModel", is_edited_provider: bool): + super().__init__(QQuickImageProvider.Pixmap) + self.app_state_model = app_state_model + self.app_controller = self.app_state_model.app_controller ++ self.is_edited_provider = is_edited_provider + self.current_path: Optional[Path] = None + self.current_data: Optional[DecodedImage] = None + self.last_load_time: float = 0 ++ ++ # For edited provider, we store the raw byte data ++ self.image_data: QByteArray = QByteArray() ++ self.edited_pixmap: QPixmap = QPixmap() + + def requestPixmap(self, id: str, size: QSize): + """ +@@ -25,7 +30,8 @@ + :param size: The requested size of the image. + :return: A QPixmap containing the image. + """ +- if id.startswith("thumb_"): ++ # If this is the edited provider, serve the in-memory edited data ++ if self.is_edited_provider and not self.image_data.isEmpty(): ++ self.edited_pixmap.loadFromData(self.image_data, "PNG") ++ # Scale the pixmap to the requested size ++ return self.edited_pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) ++ ++ elif id.startswith("thumb_"): + # Handle thumbnail request (existing logic) + path_str = id.replace("thumb_", "", 1) + path = Path(path_str) +--- a/qml/main.qml ++++ b/qml/main.qml +@@ -7,6 +7,8 @@ + import QtQuick.Controls 2.15 + import QtQuick.Layouts 1.15 + ++ImageEditorDialog { id: editorDialog; visible: appModel.isEditing } ++ + + ApplicationWindow { + id: applicationWindow +@@ -37,13 +39,21 @@ + + // 'E' to toggle Edit Mode + else if (event.key === Qt.Key_E) { +- appModel.isEditing = !appModel.isEditing ++ if (appModel.isEditing) { ++ // If exiting, ask to save if changes are pending ++ if (appModel.edits_pending) { ++ saveDiscardDialog.open() ++ } else { ++ // No changes, just close ++ appModel.isEditing = false ++ } ++ } else { ++ // Entering edit mode ++ appModel.isEditing = true ++ } + event.accepted = true + } +- } +- +- ++ ++ // ENTER to execute crop in crop mode or confirm save/discard ++ else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { ++ if (appModel.isEditing && appModel.isCropping && imageMouseArea.selectionActive) { ++ appModel.app_controller.execute_crop() ++ // The crop is now "applied" (set in the model) and cropping selection ends ++ event.accepted = true ++ } else if (saveDiscardDialog.visible) { ++ saveDiscardDialog.handleClose(true) // Default to Yes ++ saveDiscardDialog.close() ++ event.accepted = true ++ } ++ } ++ ++ } + + // Main Content Area + RowLayout { +@@ -105,11 +115,14 @@ + sourceSize.width: mainImage.width + sourceSize.height: mainImage.height +- source: "image://imageProvider/" + appModel.current_image_path ++ ++ // Switch source based on edit mode ++ source: appModel.isEditing ++ ? "image://editedImageProvider/currentEdit" ++ : "image://imageProvider/" + appModel.current_image_path + + // Keep mouse wheel zoom working in edit mode + MouseArea { + id: imageMouseArea + anchors.fill: parent + hoverEnabled: true +@@ -124,6 +137,39 @@ + onWheel: { + imageMouseArea.forceActiveFocus() + applicationWindow.zoomImage(event.angleDelta.y) + } ++ ++ // Cropping selection logic ++ property bool selectionActive: false ++ property point startPoint: Qt.point(0, 0) ++ property point endPoint: Qt.point(0, 0) ++ ++ onPressed: { ++ if (appModel.isEditing && appModel.isCropping) { ++ selectionActive = true ++ startPoint = Qt.point(mouse.x, mouse.y) ++ endPoint = startPoint ++ appModel.hasActiveCropSelection = true ++ } ++ } ++ onMouseMoved: { ++ if (appModel.isEditing && appModel.isCropping && selectionActive) { ++ endPoint = Qt.point(mouse.x, mouse.y) ++ } ++ } ++ onReleased: { ++ if (appModel.isEditing && appModel.isCropping && selectionActive) { ++ // Send normalized coordinates to Python for preview update ++ var rect = normalizeCrop(startPoint, endPoint) ++ // Python expects normalized left, top, right, bottom from 0-1 ++ appModel.app_controller.set_crop_selection_normalized(rect.left, rect.top, rect.right, rect.bottom) ++ } ++ } ++ ++ function normalizeCrop(p1, p2) { ++ var left = Math.min(p1.x, p2.x) / mainImage.width ++ var top = Math.min(p1.y, p2.y) / mainImage.height ++ var right = Math.max(p1.x, p2.x) / mainImage.width ++ var bottom = Math.max(p1.y, p2.y) / mainImage.height ++ return { left: left, top: top, right: right, bottom: bottom } ++ } ++ } ++ ++ // Visual Cropping Overlay ++ Rectangle { ++ visible: appModel.isEditing && appModel.isCropping && imageMouseArea.selectionActive ++ x: Math.min(imageMouseArea.startPoint.x, imageMouseArea.endPoint.x) ++ y: Math.min(imageMouseArea.startPoint.y, imageMouseArea.endPoint.y) ++ width: Math.abs(imageMouseArea.startPoint.x - imageMouseArea.endPoint.x) ++ height: Math.abs(imageMouseArea.startPoint.y - imageMouseArea.endPoint.y) ++ color: "transparent" ++ border.color: "red" ++ border.width: 2 ++ opacity: 0.8 + } + } + +@@ -133,3 +179,21 @@ + Layout.fillWidth: true + } + } ++ ++ // Save/Discard Confirmation Dialog ++ MessageDialog { ++ id: saveDiscardDialog ++ title: "Unsaved Edits" ++ text: "Do you want to save your image edits?" ++ ++ // Set default button to Yes (AcceptRole) ++ standardButtons: Dialog.Yes | Dialog.No ++ ++ // Helper function to handle saving/discarding and closing the editor ++ function handleClose(save: bool) { ++ appModel.app_controller.save_or_discard_edits(save) ++ appModel.isEditing = false // Close edit mode ++ } ++ ++ onAccepted: handleClose(true) // 'Yes' is the default/accepted action ++ onRejected: handleClose(false) // 'No' is the rejected action ++ } + } diff --git a/faststack.working-menus/pyproject.toml b/faststack.working-menus/pyproject.toml new file mode 100644 index 0000000..ae403f2 --- /dev/null +++ b/faststack.working-menus/pyproject.toml @@ -0,0 +1,34 @@ + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "faststack" +version = "1.0" +authors = [ + { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, +] +description = "Ultra-fast JPG Viewer for Focus Stacking Selection" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", +] +dependencies = [ + "PySide6>=6.0,<7.0", + "PyTurboJPEG>=1.8,<2.0", + "numpy>=2.0,<3.0", + "cachetools>=5.0,<6.0", + "watchdog>=4.0,<5.0", + "Pillow>=10.0,<11.0", + "pytest>=8.0,<9.0", +] + +[project.scripts] +faststack = "faststack.app:cli" + +[tool.setuptools] +packages = ["faststack"] diff --git a/faststack.working-menus/requirements.txt b/faststack.working-menus/requirements.txt new file mode 100644 index 0000000..f67333a --- /dev/null +++ b/faststack.working-menus/requirements.txt @@ -0,0 +1,9 @@ +PySide6==6.10.* +PyTurboJPEG==1.* +numpy==2.* +cachetools==5.* +watchdog==4.* +typer==0.12.* +Pillow==10.* # fallback decode; keep it +pyinstaller==6.* +pytest==8.* diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index f404bf2..84fff59 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,6 +1,33 @@ # ChangeLog -Todo: Add image brightness control / cropping. Make batches a bit more intuitive. Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. +Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Add the ability to pull in images from a stack if they are taken with a camera with in-camera stacking. + +## [1.1.0] - 2025-11-22 + +### Major Features +- **Built-in Image Editor:** Full-featured image editor with draggable window + - Exposure, highlights, shadows, whites, blacks, brightness, contrast + - White balance (Blue/Yellow and Magenta/Green axes) + - Auto White Balance button using grey world assumption + - Saturation, vibrance, clarity, sharpness + - Vignette effect + - Rotation (90°, 180°, 270°) + - EXIF metadata preservation (GPS, camera settings, timestamps) + - Press `E` to open editor, `Ctrl+S` to save + - Sequential backup naming (filename-backup.jpg, filename-backup2.jpg, etc.) + +- **Quick Auto White Balance:** Press `A` key for instant auto white balance + - Uses grey world assumption algorithm + - Automatically saves with backup + - Full undo support with Ctrl+Z + +- **Enhanced Batch Display:** Batch counter shows total selected images + - `B` key toggles images in/out of batch selection + +### UI/UX Improvements +- **Updated Key Bindings Dialog:** Added documentation for new features + - Auto white balance (A key) + - Image editor toggle (E key) ## [1.0.0] - 2025-11-21 diff --git a/faststack/README.md b/faststack/README.md index 154f7c5..0dbbf0e 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.0 - November 21, 2025 +# Version 1.1 - November 22, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -13,7 +13,9 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). -- **Photoshop Integration:** Edit current image in Photoshop (E key) - uses RAW files when available +- **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) +- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance load the raw into Photoshop with the P key. +- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available, even for backup files - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename - **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. @@ -54,7 +56,9 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `Enter`: Launch Helicon Focus with selected RAWs - `P`: Edit in Photoshop (uses RAW file if available) - `Delete` / `Backspace`: Move image to recycle bin -- `Ctrl+Z`: Undo last delete +- `Ctrl+Z`: Undo last action (delete or auto white balance) +- `A`: Quick auto white balance (saves automatically) +- `E`: Toggle Image Editor - `Ctrl+C`: Copy image path to clipboard - `Ctrl+0`: Reset zoom and pan - `C`: Clear all stacks diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index d2ad528..cf7affa 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: faststack -Version: 1.0 +Version: 1.1 Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload Author-email: Alan Rockefeller Classifier: Programming Language :: Python :: 3 @@ -21,7 +21,7 @@ Dynamic: license-file # FastStack -# Version 1.0 - November 21, 2025 +# Version 1.1 - November 22, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 7a6800c..9be09ec 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -7,13 +7,14 @@ import time import argparse from pathlib import Path -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Any from datetime import date import os import concurrent.futures import threading import subprocess from faststack.ui.provider import ImageProvider, UIState +import PySide6 from PySide6.QtGui import QDrag, QPixmap from PySide6.QtCore import ( QUrl, @@ -42,6 +43,7 @@ from faststack.imaging.prefetch import Prefetcher, clear_icc_caches from faststack.ui.provider import ImageProvider from faststack.ui.keystrokes import Keybinder +from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS def make_hdrop(paths): """ @@ -91,6 +93,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Backend Components -- self.watcher = Watcher(self.image_dir, self.refresh_image_list) self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) + self.image_editor = ImageEditor() # Initialize the editor # -- Caching & Prefetching -- cache_size_gb = config.getfloat('core', 'cache_size_gb', 1.5) @@ -130,6 +133,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Delete/Undo State -- self.recycle_bin_dir = self.image_dir / "image recycle bin" self.delete_history: List[tuple[Path, Optional[Path]]] = [] # [(jpg_path, raw_path), ...] + # Track all undoable actions with timestamps + self.undo_history: List[Tuple[str, Any, float]] = [] # (action_type, action_data, timestamp) self.resize_timer = QTimer() self.resize_timer.setSingleShot(True) @@ -321,6 +326,12 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: log.warning("get_decoded_image called with empty image_files or out of bounds index.") return None + # If editor is open for this image, return the live preview + if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): + preview_data = self.image_editor.get_preview_data() + if preview_data: + return preview_data + _, _, display_gen = self.get_display_info() cache_key = f"{index}_{display_gen}" @@ -725,11 +736,44 @@ def toggle_batch_membership(self): self.update_status_message("Removed image from batch") log.info("Removed index %d from a batch.", index_to_toggle) else: - # Add to a new batch - self.batches.append([index_to_toggle, index_to_toggle]) - self.batches.sort() - self.update_status_message("Added image to batch") - log.info("Added index %d to a new batch.", index_to_toggle) + # Add to batch - merge with adjacent batches if possible + if not self.batches: + self.batches.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new batch with current image.") + log.info("No existing batches. Created new batch for index %d.", index_to_toggle) + else: + # Check if adjacent to any existing batch + merged = False + for i, (start, end) in enumerate(self.batches): + # Adjacent to start of batch + if index_to_toggle == start - 1: + self.batches[i] = [index_to_toggle, end] + merged = True + break + # Adjacent to end of batch + elif index_to_toggle == end + 1: + self.batches[i] = [start, index_to_toggle] + merged = True + break + + if not merged: + # Not adjacent to any batch, create new one + self.batches.append([index_to_toggle, index_to_toggle]) + + # Sort and merge any overlapping batches + self.batches.sort() + merged_batches = [self.batches[0]] if self.batches else [] + for i in range(1, len(self.batches)): + last_start, last_end = merged_batches[-1] + current_start, current_end = self.batches[i] + if current_start <= last_end + 1: + merged_batches[-1] = [last_start, max(last_end, current_end)] + else: + merged_batches.append([current_start, current_end]) + self.batches = merged_batches + + self.update_status_message("Added image to batch") + log.info("Added index %d to batch.", index_to_toggle) self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1141,7 +1185,10 @@ def delete_current_image(self): # Add to delete history only if at least one file was moved if deleted_files: + import time + timestamp = time.time() self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) # Update status if deleted_files: @@ -1168,58 +1215,97 @@ def delete_current_image(self): @Slot() def undo_delete(self): - """Restores the last deleted image from recycle bin.""" - if not self.delete_history: + """Unified undo that handles both delete and auto white balance operations.""" + if not self.undo_history: self.update_status_message("Nothing to undo.") return - jpg_path, raw_path = self.delete_history.pop() + # Get the most recent action + action_type, action_data, timestamp = self.undo_history.pop() - restored_files = [] - try: - # Restore JPG - jpg_in_bin = self.recycle_bin_dir / jpg_path.name - if jpg_in_bin.exists(): - jpg_in_bin.rename(jpg_path) - restored_files.append(jpg_path.name) - log.info("Restored %s from recycle bin", jpg_path.name) - - # Restore RAW - if raw_path: - raw_in_bin = self.recycle_bin_dir / raw_path.name - if raw_in_bin.exists(): - raw_in_bin.rename(raw_path) - restored_files.append(raw_path.name) - log.info("Restored %s from recycle bin", raw_path.name) - - # Update status - if restored_files: - files_str = ", ".join(restored_files) - self.update_status_message(f"Restored: {files_str}") - else: - self.update_status_message("No files to restore") - - # Refresh image list - self.refresh_image_list() + if action_type == "delete": + jpg_path, raw_path = action_data + # Also remove from delete_history + if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): + self.delete_history.pop() - # Find and navigate to the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == jpg_path: - self.current_index = i - break - - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() + restored_files = [] + try: + # Restore JPG + jpg_in_bin = self.recycle_bin_dir / jpg_path.name + if jpg_in_bin.exists(): + jpg_in_bin.rename(jpg_path) + restored_files.append(jpg_path.name) + log.info("Restored %s from recycle bin", jpg_path.name) + + # Restore RAW + if raw_path: + raw_in_bin = self.recycle_bin_dir / raw_path.name + if raw_in_bin.exists(): + raw_in_bin.rename(raw_path) + restored_files.append(raw_path.name) + log.info("Restored %s from recycle bin", raw_path.name) + + # Update status + if restored_files: + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + else: + self.update_status_message("No files to restore") + + # Refresh image list + self.refresh_image_list() + + # Find and navigate to the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == jpg_path: + self.current_index = i + break + + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to restore image") + # Put it back in history if it failed + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + self.delete_history.append((jpg_path, raw_path)) + + elif action_type == "auto_white_balance": + filepath, saved_path = action_data + filepath_obj = Path(filepath) + backup_path = filepath_obj.parent / f"{filepath_obj.stem}_backup{filepath_obj.suffix}" - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to restore image") - # Put it back in history if it failed - self.delete_history.append((jpg_path, raw_path)) + try: + if backup_path.exists(): + # Restore the backup + filepath_obj.unlink() # Remove the edited version + backup_path.rename(filepath_obj) # Restore backup + log.info("Restored backup for %s", filepath) + + # Refresh the view + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Undid auto white balance") + else: + self.update_status_message("Backup not found") + log.warning("Backup not found at %s", backup_path) + # Put it back in history if backup not found + self.undo_history.append(("auto_white_balance", (filepath, saved_path), timestamp)) + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to undo auto white balance") + # Put it back in history if it failed + self.undo_history.append(("auto_white_balance", (filepath, saved_path), timestamp)) def shutdown(self): log.info("Application shutting down.") @@ -1312,14 +1398,34 @@ def edit_in_photoshop(self): # Prefer RAW file if it exists, otherwise use JPG image_file = self.image_files[self.current_index] - raw_path = image_file.raw_pair + jpg_path = image_file.path + + # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW + import re + original_stem = jpg_path.stem + # Remove -backup with optional digits or -backup-digits (handles both formats) + original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) + + # Look for RAW file with the original stem + raw_path = None + if image_file.raw_pair and image_file.raw_pair.exists(): + # Use the paired RAW if it exists + raw_path = image_file.raw_pair + else: + # Search for RAW file manually by original stem + from faststack.io.indexer import RAW_EXTENSIONS + for ext in RAW_EXTENSIONS: + potential_raw = jpg_path.parent / f"{original_stem}{ext}" + if potential_raw.exists(): + raw_path = potential_raw + break if raw_path and raw_path.exists(): current_image_path = raw_path log.info("Using RAW file for Photoshop: %s", raw_path) else: - current_image_path = image_file.path - log.info("Using JPG file for Photoshop: %s", current_image_path) + current_image_path = jpg_path + log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) photoshop_exe = config.get('photoshop', 'exe') photoshop_args = config.get('photoshop', 'args') @@ -1503,6 +1609,257 @@ def start_drag_current_image(self): self.sync_ui_state() log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) + # --- Image Editor Logic --- + + @Slot(result=bool) + def load_image_for_editing(self): + """Loads the currently viewed image into the editor.""" + if self.image_files and self.current_index < len(self.image_files): + filepath = str(self.image_files[self.current_index].path) + # Only load if the editor is not already open for this file + if str(self.image_editor.current_filepath) == filepath and self.image_editor.original_image is not None: + # Already loaded, just reset UI state for a fresh start + self.reset_edit_parameters() + return True + + # Get the cached, display-sized image to use for fast previews + cached_preview = self.get_decoded_image(self.current_index) + + if self.image_editor.load_image(filepath, cached_preview=cached_preview): + # Pass initial edits to uiState + initial_edits = self.image_editor._initial_edits() + for key, value in initial_edits.items(): + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Set aspect ratios for QML dropdown + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually + return True + return False + + @Slot(result=bytes) + def get_preview_data(self) -> Optional[bytes]: + """Gets the PNG bytes of the currently edited image.""" + data = self.image_editor.get_preview_data() + if data is None: + return b'' + return data + + @Slot(str, "QVariant") + def set_edit_parameter(self, key: str, value: Any): + """Sets an edit parameter and updates the UIState for the slider visual.""" + if self.image_editor.set_edit_param(key, value): + # Update the corresponding UIState property to reflect the new value in QML + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Trigger a refresh of the image to show the edit + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @Slot(int, int, int, int) + def set_crop_box(self, left: int, top: int, right: int, bottom: int): + """Sets the normalized crop box (0-1000) in the editor.""" + from typing import Tuple + crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) + self.image_editor.set_crop_box(crop_box) + self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) + + @Slot() + def reset_edit_parameters(self): + """Resets all editing parameters in the editor.""" + self.image_editor.current_edits = self.image_editor._initial_edits() + if hasattr(self.ui_state, 'reset_editor_state'): + self.ui_state.reset_editor_state() + + # Trigger a refresh to show the reset image + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @Slot() + def save_edited_image(self): + """Saves the edited image.""" + saved_path = self.image_editor.save_image() + if saved_path: + # Clear the image editor state so it will reload fresh next time + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None + + # Reset all edit parameters in the controller/UI + self.reset_edit_parameters() + + # Refresh the view - need to refresh image list since backup file was created + original_path = saved_path + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache and refresh display + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + QMessageBox.information( + None, + "Save Successful", + f"Image saved to: {saved_path}. Original backed up.", + QMessageBox.Ok + ) + + @Slot() + def rotate_image_cw(self): + """Rotate the edited image 90 degrees clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current + 90) % 360 + self.set_edit_parameter('rotation', new_rotation) + + @Slot() + def rotate_image_ccw(self): + """Rotate the edited image 90 degrees counter-clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current - 90) % 360 + if new_rotation < 0: + new_rotation += 360 + self.set_edit_parameter('rotation', new_rotation) + + @Slot() + def quick_auto_white_balance(self): + """Quickly apply auto white balance, save the image, and track for undo.""" + if not self.image_files: + self.update_status_message("No image to adjust") + return + + import time + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + # Load the image into the editor if not already loaded + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + self.update_status_message("Failed to load image") + return + + # Calculate and apply auto white balance + self.auto_white_balance() + + # Save the edited image (this creates a backup automatically) + saved_path = self.image_editor.save_image() + if saved_path: + # Track this action for undo + timestamp = time.time() + self.undo_history.append(("auto_white_balance", (filepath, saved_path), timestamp)) + + # Force the image editor to clear its current state so it reloads fresh + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None + + # Refresh the view - need to refresh image list since backup file was created + original_path = Path(filepath) + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache for the edited image so it's reloaded from disk + # This ensures the Image Editor will see the updated version + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Auto white balance applied and saved") + log.info("Quick auto white balance applied to %s", filepath) + else: + self.update_status_message("Failed to save image") + + @Slot() + def auto_white_balance(self): + """Calculates and applies auto white balance using grey world assumption.""" + if not self.image_editor.original_image: + log.warning("No image loaded in editor for auto white balance") + return + + import numpy as np + + # Work with the original image for accurate calculation + img = self.image_editor.original_image + + # Convert to numpy array + arr = np.array(img, dtype=np.float32) + + # Calculate mean values for each channel + r_mean = arr[:, :, 0].mean() + g_mean = arr[:, :, 1].mean() + b_mean = arr[:, :, 2].mean() + + # Grey world assumption: average should be neutral grey + grey_target = (r_mean + g_mean + b_mean) / 3.0 + + log.info("Auto white balance - means: R=%.1f G=%.1f B=%.1f, target=%.1f", + r_mean, g_mean, b_mean, grey_target) + + # Calculate how much each channel differs from grey (positive = too high) + r_diff = r_mean - grey_target + g_diff = g_mean - grey_target + b_diff = b_mean - grey_target + + # From editor.py, the white balance equations are: + # R' = R + by_shift + mg_shift + # G' = G + by_shift - mg_shift + # B' = B - by_shift + mg_shift + # + # To neutralize: + # We want R' = G' = B' = grey_target + # So: R + by_shift + mg_shift = grey_target => by_shift + mg_shift = -r_diff + # G + by_shift - mg_shift = grey_target => by_shift - mg_shift = -g_diff + # B - by_shift + mg_shift = grey_target => -by_shift + mg_shift = -b_diff + # + # From first two equations: + # by_shift = -(r_diff + g_diff) / 2 + # mg_shift = -(r_diff - g_diff) / 2 + + by_shift = -(r_diff + g_diff) / 2.0 + mg_shift = -(r_diff - g_diff) / 2.0 + + # Convert to the -1 to 1 range expected by the editor (editor multiplies by 0.5 then 127.5) + # So our value goes through: value * 0.5 * 127.5 = value * 63.75 + # We want: by_shift, so value = by_shift / 63.75 + by_value = by_shift / 63.75 + mg_value = mg_shift / 63.75 + + # Clamp to -1.0 to 1.0 range + by_value = float(np.clip(by_value, -1.0, 1.0)) + mg_value = float(np.clip(mg_value, -1.0, 1.0)) + + log.info("Auto white balance: by_shift=%.1f mg_shift=%.1f", by_shift, mg_shift) + log.info("Auto white balance values: B/Y=%.3f, M/G=%.3f", by_value, mg_value) + + # Apply the adjustments + self.image_editor.set_edit_param('white_balance_by', by_value) + self.image_editor.set_edit_param('white_balance_mg', mg_value) + + # Update UIState properties directly to force slider refresh + self.ui_state.white_balance_by = by_value + self.ui_state.white_balance_mg = mg_value + + # Trigger image refresh + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + def _get_stack_info(self, index: int) -> str: info = "" for i, (start, end) in enumerate(self.stacks): @@ -1519,14 +1876,20 @@ def _get_stack_info(self, index: int) -> str: def _get_batch_info(self, index: int) -> str: """Get batch info for the given index.""" info = "" + # Check if current image is in any batch + in_batch = False for i, (start, end) in enumerate(self.batches): if start <= index <= end: - count_in_batch = end - start + 1 - pos_in_batch = index - start + 1 - info = "In Batch" + in_batch = True break - if not info and self.batch_start_index is not None and self.batch_start_index == index: + + if in_batch: + # Calculate total count across all batches + total_count = sum(end - start + 1 for start, end in self.batches) + info = f"{total_count} in Batch" + elif self.batch_start_index is not None and self.batch_start_index == index: info = "Batch Start Marked" + log.debug("_get_batch_info for index %d: %s", index, info) return info @@ -1557,6 +1920,7 @@ def main(image_dir: str = "", debug: bool = False): log.info("Starting FastStack") os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") app = QApplication(sys.argv) # Moved here if debug: @@ -1583,6 +1947,12 @@ def main(image_dir: str = "", debug: bool = False): app.setApplicationName("FastStack") engine = QQmlApplicationEngine() + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) + engine.addImportPath("qrc:/qt-project.org/imports") + engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) + # Add the path to Qt5Compat.GraphicalEffects to QML import paths + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) + controller = AppController(image_dir_path, engine) if debug: log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py new file mode 100644 index 0000000..38a9a2b --- /dev/null +++ b/faststack/faststack/imaging/editor.py @@ -0,0 +1,296 @@ +import os +import shutil +import glob +from pathlib import Path +from typing import Optional, Dict, Any, Tuple +import numpy as np +from PIL import Image, ImageEnhance, ImageFilter +from io import BytesIO + +from faststack.models import DecodedImage +from PySide6.QtGui import QImage + +# Aspect Ratios for cropping +INSTAGRAM_RATIOS = { + "Freeform": None, + "1:1 (Square)": (1, 1), + "4:5 (Portrait)": (4, 5), + "1.91:1 (Landscape)": (191, 100), + "9:16 (Story)": (9, 16), +} + +class ImageEditor: + """Handles core image manipulation using PIL.""" + def __init__(self): + # Stores the currently loaded PIL Image object (original) + self.original_image: Optional[Image.Image] = None + # A smaller version of the original image for fast previews + self._preview_image: Optional[Image.Image] = None + # Stores the currently applied edits (used for preview) + self.current_edits: Dict[str, Any] = self._initial_edits() + self.current_filepath: Optional[Path] = None + + def _initial_edits(self) -> Dict[str, Any]: + return { + 'brightness': 0.0, + 'contrast': 0.0, + 'saturation': 0.0, + 'white_balance_by': 0.0, # Blue/Yellow (Cool/Warm) + 'white_balance_mg': 0.0, # Magenta/Green (Tint) + 'crop_box': None, # (left, top, right, bottom) normalized to 0-1000 + 'sharpness': 0.0, + 'rotation': 0, + 'exposure': 0.0, + 'highlights': 0.0, + 'shadows': 0.0, + 'vibrance': 0.0, + 'vignette': 0.0, + 'blacks': 0.0, + 'whites': 0.0, + 'clarity': 0.0, + } + + def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = None): + """Load a new image for editing.""" + if not filepath or not Path(filepath).exists(): + self.original_image = None + self.current_filepath = None + self._preview_image = None + return False + + self.current_filepath = Path(filepath) + # Reset edits + self.current_edits = self._initial_edits() + + try: + # We must load and close the original file handle immediately + self.original_image = Image.open(self.current_filepath).convert("RGB") + + # Use the cached, display-sized preview if available + if cached_preview: + self._preview_image = Image.frombytes( + "RGB", + (cached_preview.width, cached_preview.height), + bytes(cached_preview.buffer) + ) + else: + # Fallback: create a thumbnail if no preview is provided + self._preview_image = self.original_image.copy() + self._preview_image.thumbnail((1920, 1080)) # Reasonable fallback size + + return True + except Exception as e: + print(f"Error loading image for editing: {e}") + self.original_image = None + self._preview_image = None + return False + + def _apply_edits(self, img: Image.Image) -> Image.Image: + """Applies all current edits to the provided PIL Image.""" + # 1. Rotation + rotation = self.current_edits['rotation'] + if rotation == 90: + img = img.transpose(Image.Transpose.ROTATE_90) + elif rotation == 180: + img = img.transpose(Image.Transpose.ROTATE_180) + elif rotation == 270: + img = img.transpose(Image.Transpose.ROTATE_270) + + # 2. Cropping + crop_box = self.current_edits.get('crop_box') + if crop_box: + width, height = img.size + left = int(crop_box[0] * width / 1000) + top = int(crop_box[1] * height / 1000) + right = int(crop_box[2] * width / 1000) + bottom = int(crop_box[3] * height / 1000) + img = img.crop((left, top, right, bottom)) + + # 3. Exposure (gamma-based) + exposure = self.current_edits['exposure'] + if abs(exposure) > 0.001: + gamma = 1.0 / (1.0 + exposure) if exposure >= 0 else 1.0 - exposure + arr = np.array(img, dtype=np.float32) / 255.0 + arr = np.power(arr, gamma) + arr = (arr * 255).clip(0, 255).astype(np.uint8) + img = Image.fromarray(arr) + + # 4. Blacks/Whites (Levels) + blacks = self.current_edits['blacks'] + whites = self.current_edits['whites'] + if abs(blacks) > 0.001 or abs(whites) > 0.001: + arr = np.array(img, dtype=np.float32) + black_point = -blacks * 40 + white_point = 255 + whites * 40 + arr = (arr - black_point) * (255.0 / (white_point - black_point)) + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + + # 5. Highlights/Shadows + highlights = self.current_edits['highlights'] + shadows = self.current_edits['shadows'] + if abs(highlights) > 0.001 or abs(shadows) > 0.001: + arr = np.array(img, dtype=np.float32) + if abs(shadows) > 0.001: + shadow_mask = 1.0 - np.clip(arr / 128.0, 0, 1) + arr += shadows * 60 * shadow_mask + if abs(highlights) > 0.001: + highlight_mask = np.clip((arr - 128) / 127.0, 0, 1) + arr += highlights * 60 * highlight_mask + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + + # 6. Brightness + bright_factor = 1.0 + self.current_edits['brightness'] + if abs(bright_factor - 1.0) > 0.001: + img = ImageEnhance.Brightness(img).enhance(bright_factor) + + # 7. Contrast + contrast_factor = 1.0 + self.current_edits['contrast'] + if abs(contrast_factor - 1.0) > 0.001: + img = ImageEnhance.Contrast(img).enhance(contrast_factor) + + # 8. Clarity + clarity = self.current_edits['clarity'] + if abs(clarity) > 0.001: + arr = np.array(img, dtype=np.float32) + luminance = 0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2] + lum_img = Image.fromarray(luminance.astype(np.uint8)) + blurred = lum_img.filter(ImageFilter.GaussianBlur(radius=20)) + blurred_arr = np.array(blurred, dtype=np.float32) + midtone_mask = 1.0 - np.abs(luminance - 128) / 128.0 + local_contrast = (luminance - blurred_arr) * clarity * midtone_mask + for c in range(3): + arr[:,:,c] += local_contrast + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + + # 9. Saturation + saturation_factor = 1.0 + self.current_edits['saturation'] + if abs(saturation_factor - 1.0) > 0.001: + img = ImageEnhance.Color(img).enhance(saturation_factor) + + # 10. Vibrance + vibrance = self.current_edits['vibrance'] + if abs(vibrance) > 0.001: + arr = np.array(img, dtype=np.float32) + sat = (arr.max(axis=2) - arr.min(axis=2)) / 255.0 + boost = (1.0 - sat) * vibrance + gray = arr.mean(axis=2, keepdims=True) + arr = gray + (arr - gray) * (1.0 + boost[:, :, np.newaxis]) + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + + # 11. White Balance + by_val = self.current_edits['white_balance_by'] * 0.5 + mg_val = self.current_edits['white_balance_mg'] * 0.5 + if abs(by_val) > 0.001 or abs(mg_val) > 0.001: + arr = np.array(img, dtype=np.float32) + by_shift = by_val * 127.5 + mg_shift = mg_val * 127.5 + arr[:, :, 0] += by_shift + mg_shift + arr[:, :, 1] += by_shift - mg_shift + arr[:, :, 2] -= by_shift - mg_shift + np.clip(arr, 0, 255, out=arr) + img = Image.fromarray(arr.astype(np.uint8)) + + # 12. Sharpness + sharp_factor = 1.0 + self.current_edits['sharpness'] + if abs(sharp_factor - 1.0) > 0.001: + img = ImageEnhance.Sharpness(img).enhance(sharp_factor) + + # 13. Vignette + vignette = self.current_edits['vignette'] + if vignette > 0.001: + arr = np.array(img, dtype=np.float32) + h, w = arr.shape[:2] + y, x = np.ogrid[:h, :w] + cx, cy = w / 2, h / 2 + dist = np.sqrt((x - cx)**2 + (y - cy)**2) + max_dist = np.sqrt(cx**2 + cy**2) + dist = dist / max_dist + vignette_mask = 1.0 - (dist ** 2) * vignette + vignette_mask = vignette_mask[:, :, np.newaxis] + arr = arr * vignette_mask + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + + return img + + def get_preview_data(self) -> Optional[DecodedImage]: + """Apply current edits and return the data as a DecodedImage.""" + if self._preview_image is None: + return None + + # Always start from a fresh copy of the small preview image + img = self._preview_image.copy() + img = self._apply_edits(img) + + # The image is in RGB mode after _apply_edits + buffer = img.tobytes() + return DecodedImage( + buffer=memoryview(buffer), + width=img.width, + height=img.height, + bytes_per_line=img.width * 3, # 3 bytes per pixel for RGB + format=QImage.Format.Format_RGB888 + ) + + def set_edit_param(self, key: str, value: Any) -> bool: + """Update a single edit parameter.""" + if key in self.current_edits and key != 'crop_box': + self.current_edits[key] = value + return True + return False + + def set_crop_box(self, crop_box: Tuple[int, int, int, int]): + """Set the normalized crop box (left, top, right, bottom) from 0-1000.""" + self.current_edits['crop_box'] = crop_box + + def save_image(self) -> Optional[Path]: + """Saves the edited image, backing up the original.""" + if self.original_image is None or self.current_filepath is None: + return None + + final_img = self.original_image.copy() + final_img = self._apply_edits(final_img) + + original_path = self.current_filepath + + # Extract base name without any existing -backup suffix + stem = original_path.stem + # Remove any existing -backup, -backup2, -backup-1, etc. (handles both old and new formats) + import re + base_stem = re.sub(r'-backup(-?\d+)?$', '', stem) + + # Try filename-backup.jpg first + backup_path = original_path.parent / f"{base_stem}-backup{original_path.suffix}" + + # If that exists, try filename-backup2.jpg, filename-backup3.jpg, etc. + i = 2 + while backup_path.exists(): + backup_path = original_path.parent / f"{base_stem}-backup{i}{original_path.suffix}" + i += 1 + + try: + # Perform the backup and overwrite + shutil.copy2(original_path, backup_path) + + # Preserve EXIF data from original image + try: + # Load the original image again to extract EXIF + original_img = Image.open(original_path) + exif_data = original_img.info.get('exif') + + # Save with EXIF data preserved + if exif_data: + final_img.save(original_path, format='JPEG', quality=95, exif=exif_data) + else: + final_img.save(original_path, format='JPEG', quality=95) + except Exception as e: + print(f"Warning: Could not preserve EXIF data: {e}") + # Fall back to saving without EXIF if there's an issue + final_img.save(original_path, format='JPEG', quality=95) + + return original_path + except Exception as e: + print(f"Failed to save edited image or backup: {e}") + return None + +# Dictionary of ratios for QML dropdown +ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 2b60b4a..416f8cc 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -26,6 +26,8 @@ Item { source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" fillMode: Image.PreserveAspectFit cache: false // We do our own caching in Python + smooth: !uiState.anySliderPressed + mipmap: !uiState.anySliderPressed Component.onCompleted: { if (width > 0 && height > 0) { diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml new file mode 100644 index 0000000..07c3c43 --- /dev/null +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -0,0 +1,230 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 + +Window { + id: editDialog + width: 720 + height: 700 + title: "Image Editor" + visible: uiState.isEditorOpen + flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + property int updatePulse: 0 + + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + // When the dialog is closed by the user (e.g. clicking X), update the state + onVisibleChanged: { + if (!visible) { + uiState.isEditorOpen = false + } + } + + property int slidersPressedCount: 0 + onSlidersPressedCountChanged: { + uiState.setAnySliderPressed(slidersPressedCount > 0) + } + + function getBackendValue(key) { + var _dependency = updatePulse; + if (key in uiState) return uiState[key]; + return 0.0; + } + + // Background + color: "#2b2b2b" + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + clip: true + contentWidth: availableWidth + + RowLayout { + width: parent.width + spacing: 20 + + ColumnLayout { // Left Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Light Group --- + Label { text: "Light"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: lightModel + ListElement { name: "Exposure"; key: "exposure" } + ListElement { name: "Highlights"; key: "highlights" } + ListElement { name: "Shadows"; key: "shadows" } + ListElement { name: "Whites"; key: "whites" } + ListElement { name: "Blacks"; key: "blacks" } + ListElement { name: "Brightness"; key: "brightness" } + ListElement { name: "Contrast"; key: "contrast" } + } + Repeater { model: lightModel; delegate: editSlider } + + // --- Detail Group --- + Label { text: "Detail"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: detailModel + ListElement { name: "Clarity"; key: "clarity" } + ListElement { name: "Sharpness"; key: "sharpness" } + } + Repeater { model: detailModel; delegate: editSlider } + } + + ColumnLayout { // Right Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Color Group --- + Label { text: "Color"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: colorModel + ListElement { name: "Saturation"; key: "saturation"; reverse: false } + ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } + ListElement { name: "White Balance (B/Y)"; key: "white_balance_by"; reverse: false } + ListElement { name: "White Balance (G/M)"; key: "white_balance_mg"; reverse: false } + } + Repeater { model: colorModel; delegate: editSlider } + + Button { + text: "Auto White Balance" + Layout.fillWidth: true + Layout.topMargin: 5 + onClicked: { + controller.auto_white_balance() + editDialog.updatePulse++ + } + } + + // --- Effects Group --- + Label { text: "Effects"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: effectsModel + ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } + } + Repeater { model: effectsModel; delegate: editSlider } + + // --- Transform Group --- + Label { text: "Transform"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + RowLayout { + Layout.fillWidth: true + Label { text: "Rotation"; color: uiState.theme === 0 ? "white" : "black" } + Button { text: "↶"; onClicked: controller.rotate_image_ccw() } + Button { text: "↷"; onClicked: controller.rotate_image_cw() } + } + + // --- Action Buttons --- + Item { Layout.fillHeight: true; Layout.minimumHeight: 20 } + Button { + text: "Reset All Edits" + Layout.fillWidth: true + onClicked: { + controller.reset_edit_parameters() + editDialog.updatePulse++ + } + } + Button { + text: "Save Edited Image (Ctrl+S)" + Layout.fillWidth: true + onClicked: controller.save_edited_image() + } + Button { + text: "Close Editor (E)" + Layout.fillWidth: true + onClicked: { + uiState.isEditorOpen = false + } + } + } + } + } + + Component { + id: editSlider + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + property bool isReversed: model.reverse !== undefined ? model.reverse : false + property real displayValue: isReversed ? -slider.value : slider.value + + Text { + text: model.name + ": " + displayValue.toFixed(0) + color: uiState.theme === 0 ? "white" : "black" + font.pixelSize: 14 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Slider { + id: slider + Layout.fillWidth: true + Layout.minimumHeight: 30 + from: model.min === undefined ? -100 : model.min + to: model.max === undefined ? 100 : model.max + stepSize: 1 + + property real backendValue: { + var val = editDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) + return isReversed ? -val : val + } + + value: backendValue + + Connections { + target: editDialog + function onUpdatePulseChanged() { + if (!slider.pressed) { + // This forces the visual handle to snap to the backendValue + // even if backendValue hasn't numerically changed (e.g. 0 -> 0) + slider.value = slider.backendValue + } + } + } + + onMoved: { + var sendValue = isReversed ? -value : value + controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + } + + onPressedChanged: { + if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; + } + + onBackendValueChanged: { + // Check '!pressed' to avoid fighting the user if they are + // currently dragging the slider while an update comes in. + if (!pressed) { + value = backendValue + } + } + + background: Rectangle { + x: slider.leftPadding + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: slider.availableWidth + height: 4 + radius: 2 + color: "#404040" + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: slider.pressed ? "#4fb360" : "#6fcf7c" + border.color: "#3d8a4a" + } + } + } + } +} diff --git a/faststack/faststack/qml/ImageEditorDialog.qml.new b/faststack/faststack/qml/ImageEditorDialog.qml.new new file mode 100644 index 0000000..550be81 --- /dev/null +++ b/faststack/faststack/qml/ImageEditorDialog.qml.new @@ -0,0 +1,219 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 +import Qt5Compat.GraphicalEffects + +Window { + id: editDialog + width: 720 + height: 600 + title: "Image Editor" + visible: uiState.isEditorOpen + flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + // When the dialog is closed by the user (e.g. clicking X), update the state + onVisibleChanged: { + if (!visible) { + uiState.isEditorOpen = false + } + } + + property int slidersPressedCount: 0 + onSlidersPressedCountChanged: { + uiState.setAnySliderPressed(slidersPressedCount > 0) + } + + function getBackendValue(key) { + if (key in uiState) return uiState[key]; + return 0.0; + } + + // Background + color: "#2b2b2b" + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + clip: true + contentWidth: availableWidth + + RowLayout { + width: parent.width + spacing: 20 + + ColumnLayout { // Left Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Light Group --- + Label { text: "Light"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: lightModel + ListElement { name: "Exposure"; key: "exposure" } + ListElement { name: "Highlights"; key: "highlights" } + ListElement { name: "Shadows"; key: "shadows" } + ListElement { name: "Whites"; key: "whites"; reverse: true } + ListElement { name: "Blacks"; key: "blacks" } + ListElement { name: "Brightness"; key: "brightness" } + ListElement { name: "Contrast"; key: "contrast" } + } + Repeater { model: lightModel; delegate: editSlider } + + // --- Detail Group --- + Label { text: "Detail"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: detailModel + ListElement { name: "Clarity"; key: "clarity" } + ListElement { name: "Sharpness"; key: "sharpness" } + } + Repeater { model: detailModel; delegate: editSlider } + } + + ColumnLayout { // Right Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Color Group --- + Label { text: "Color"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: colorModel + ListElement { name: "Saturation"; key: "saturation"; reverse: false } + ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } + ListElement { name: "White Balance (B/Y)"; key: "white_balance_by"; reverse: false } + ListElement { name: "White Balance (G/M)"; key: "white_balance_mg"; reverse: false } + } + Repeater { model: colorModel; delegate: editSlider } + + // --- Effects Group --- + Label { text: "Effects"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: effectsModel + ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } + } + Repeater { model: effectsModel; delegate: editSlider } + + // --- Transform Group --- + Label { text: "Transform"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + RowLayout { + Layout.fillWidth: true + Label { text: "Rotation"; color: uiState.theme === 0 ? "white" : "black" } + Button { text: "↶"; onClicked: controller.rotate_image_ccw() } + Button { text: "↷"; onClicked: controller.rotate_image_cw() } + } + + // --- Action Buttons --- + Item { Layout.fillHeight: true; Layout.minimumHeight: 20 } + Button { + text: "Reset All Edits" + Layout.fillWidth: true + onClicked: controller.reset_edit_parameters() + } + Button { + text: "Save Edited Image (Ctrl+S)" + Layout.fillWidth: true + onClicked: controller.save_edited_image() + } + Button { + text: "Close Editor (E)" + Layout.fillWidth: true + onClicked: { + uiState.isEditorOpen = false + } + } + } + } + } + + Component { + id: editSlider + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + property bool isReversed: model.reverse !== undefined ? model.reverse : false + property real displayValue: isReversed ? -slider.value : slider.value + + Text { + text: model.name + ": " + displayValue.toFixed(0) + color: uiState.theme === 0 ? "white" : "black" + font.pixelSize: 14 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Slider { + id: slider + Layout.fillWidth: true + Layout.minimumHeight: 30 + from: model.min === undefined ? -100 : model.min + to: model.max === undefined ? 100 : model.max + stepSize: 1 + + property real backendValue: { + var val = editDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) + return isReversed ? -val : val + } + + value: backendValue + + onMoved: { + var sendValue = isReversed ? -value : value + controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + } + + onPressedChanged: { + if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; + } + + background: Rectangle { + implicitHeight: 8 + radius: 4 + color: "#333333" + + Rectangle { + width: slider.visualPosition * parent.width + height: parent.height + radius: 4 + gradient: Gradient { + GradientStop { position: 0.0; color: "#6fcf7c" } + GradientStop { position: 1.0; color: "#4fb360" } + } + } + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + implicitWidth: 24 + implicitHeight: 24 + radius: 12 + color: slider.pressed ? "#4fb360" : "white" + border.color: "#4fb360" + border.width: 3 + + Behavior on width { NumberAnimation { duration: 120 } } + Behavior on height { NumberAnimation { duration: 120 } } + + // Optional: subtle shadow + layer.enabled: true + layer.effect: Shadow { + radius: 8 + samples: 17 + color: "#80000000" + verticalOffset: 2 + horizontalOffset: 0 + } + } + + } + } + } +} diff --git a/faststack/faststack/qml/ImageEditorDialog.qml.old b/faststack/faststack/qml/ImageEditorDialog.qml.old new file mode 100644 index 0000000..a17ee0d --- /dev/null +++ b/faststack/faststack/qml/ImageEditorDialog.qml.old @@ -0,0 +1,196 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 + +Window { + id: editDialog + width: 720 + height: 600 + title: "Image Editor" + visible: uiState.isEditorOpen + flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + // When the dialog is closed by the user (e.g. clicking X), update the state + onVisibleChanged: { + if (!visible) { + uiState.isEditorOpen = false + } + } + + property int slidersPressedCount: 0 + onSlidersPressedCountChanged: { + uiState.setAnySliderPressed(slidersPressedCount > 0) + } + + function getBackendValue(key) { + if (key in uiState) return uiState[key]; + return 0.0; + } + + // Background + color: "#2b2b2b" + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + clip: true + contentWidth: availableWidth + + RowLayout { + width: parent.width + spacing: 20 + + ColumnLayout { // Left Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Light Group --- + Label { text: "Light"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: lightModel + ListElement { name: "Exposure"; key: "exposure" } + ListElement { name: "Highlights"; key: "highlights" } + ListElement { name: "Shadows"; key: "shadows" } + ListElement { name: "Whites"; key: "whites" } + ListElement { name: "Blacks"; key: "blacks" } + ListElement { name: "Brightness"; key: "brightness" } + ListElement { name: "Contrast"; key: "contrast" } + } + Repeater { model: lightModel; delegate: editSlider } + + // --- Detail Group --- + Label { text: "Detail"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: detailModel + ListElement { name: "Clarity"; key: "clarity" } + ListElement { name: "Sharpness"; key: "sharpness" } + } + Repeater { model: detailModel; delegate: editSlider } + } + + ColumnLayout { // Right Column + Layout.fillWidth: true + Layout.preferredWidth: (parent.width - 20) / 2 + Layout.alignment: Qt.AlignTop + spacing: 2 + + // --- Color Group --- + Label { text: "Color"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } + ListModel { + id: colorModel + ListElement { name: "Saturation"; key: "saturation"; reverse: false } + ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } + ListElement { name: "White Balance (B/Y)"; key: "white_balance_by"; reverse: false } + ListElement { name: "White Balance (G/M)"; key: "white_balance_mg"; reverse: false } + } + Repeater { model: colorModel; delegate: editSlider } + + // --- Effects Group --- + Label { text: "Effects"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + ListModel { + id: effectsModel + ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } + } + Repeater { model: effectsModel; delegate: editSlider } + + // --- Transform Group --- + Label { text: "Transform"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } + RowLayout { + Layout.fillWidth: true + Label { text: "Rotation"; color: uiState.theme === 0 ? "white" : "black" } + Button { text: "↶"; onClicked: controller.rotate_image_ccw() } + Button { text: "↷"; onClicked: controller.rotate_image_cw() } + } + + // --- Action Buttons --- + Item { Layout.fillHeight: true; Layout.minimumHeight: 20 } + Button { + text: "Reset All Edits" + Layout.fillWidth: true + onClicked: controller.reset_edit_parameters() + } + Button { + text: "Save Edited Image (Ctrl+S)" + Layout.fillWidth: true + onClicked: controller.save_edited_image() + } + Button { + text: "Close Editor (E)" + Layout.fillWidth: true + onClicked: { + uiState.isEditorOpen = false + } + } + } + } + } + + Component { + id: editSlider + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + property bool isReversed: model.reverse !== undefined ? model.reverse : false + property real displayValue: isReversed ? -slider.value : slider.value + + Text { + text: model.name + ": " + displayValue.toFixed(0) + color: uiState.theme === 0 ? "white" : "black" + font.pixelSize: 14 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Slider { + id: slider + Layout.fillWidth: true + Layout.minimumHeight: 30 + from: model.min === undefined ? -100 : model.min + to: model.max === undefined ? 100 : model.max + stepSize: 1 + + property real backendValue: { + var val = editDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) + return isReversed ? -val : val + } + + value: backendValue + + onMoved: { + var sendValue = isReversed ? -value : value + controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + } + + onPressedChanged: { + if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; + } + + background: Rectangle { + x: slider.leftPadding + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: slider.availableWidth + height: 4 + radius: 2 + color: "#404040" + } + + handle: Rectangle { + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: slider.pressed ? "#4fb360" : "#6fcf7c" + border.color: "#3d8a4a" + } + } + } + } +} diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 0ac92e2..aef9698 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -44,6 +44,25 @@ ApplicationWindow { anchors.fill: parent anchors.topMargin: titleBar.height source: "Components.qml" + z: -1 + focus: true + Keys.onPressed: (event) => { + // Toggle Image Editor with 'E' key + if (event.key === Qt.Key_E && !event.isAutoRepeat) { + uiState.isEditorOpen = !uiState.isEditorOpen + if (uiState.isEditorOpen) { + controller.load_image_for_editing() + } + event.accepted = true + } + // Global Key for saving edited image (Ctrl+S) + else if (event.key === Qt.Key_S && event.modifiers & Qt.ControlModifier) { + if (uiState.isEditorOpen) { + controller.save_edited_image() + event.accepted = true + } + } + } } // Keyboard focus and event handling @@ -188,6 +207,7 @@ ApplicationWindow { MouseArea { anchors.fill: parent + z: -1 property point lastGlobalPos: Qt.point(0, 0) onPressed: function(mouse) { lastGlobalPos = Qt.point(root.x + mouse.x, root.y + mouse.y) @@ -209,19 +229,18 @@ ApplicationWindow { - MenuBar { - id: menuBar - Layout.preferredWidth: 300 // Give it some width - background: Rectangle { - color: root.currentBackgroundColor - } - palette.buttonText: root.currentTextColor - palette.button: root.currentBackgroundColor - palette.window: root.currentBackgroundColor - palette.text: root.currentTextColor - - Menu { - title: "&File" + Row { + spacing: 5 + z: 10 + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + + Button { + text: "File" + onClicked: fileMenu.popup() + Menu { + id: fileMenu + x: 0 + y: parent.height Action { text: "&Open Folder..." } Action { text: "&Settings..." @@ -236,9 +255,16 @@ ApplicationWindow { } } Action { text: "&Exit"; onTriggered: Qt.quit() } + } } - Menu { - title: "&View" + + Button { + text: "View" + onClicked: viewMenu.popup() + Menu { + id: viewMenu + x: 0 + y: parent.height Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } MenuSeparator {} @@ -268,19 +294,42 @@ ApplicationWindow { onTriggered: controller.set_color_mode("icc") ActionGroup.group: colorModeGroup } + } } - Menu { - title: "&Actions" + + Button { + text: "Actions" + onClicked: actionsMenu.popup() + Menu { + id: actionsMenu + x: 0 + y: parent.height + Action { + text: "Edit Image" + onTriggered: { + uiState.isEditorOpen = !uiState.isEditorOpen + if (uiState.isEditorOpen) + controller.load_image_for_editing() + } + } Action { text: "Run Stacks"; onTriggered: uiState.launch_helicon() } Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } Action { text: "Filter Images..."; onTriggered: filterDialog.open() } Action { text: "Clear Filename Filter"; onTriggered: controller.clear_filter() } + } } - Menu { - title: "&Help" - Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } + + Button { + text: "Help" + onClicked: helpMenu.popup() + Menu { + id: helpMenu + x: 0 + y: parent.height + Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } + } } } @@ -379,10 +428,12 @@ ApplicationWindow { "  Ctrl+S: Toggle stacked flag

" + "File Management:
" + "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last delete

" + + "  Ctrl+Z: Undo last action (delete or auto white balance)

" + "Actions:
" + "  Enter: Launch Helicon Focus
" + "  P: Edit in Photoshop
" + + "  A: Quick auto white balance (saves automatically)
" + + "  E: Toggle Image Editor
" + "  Ctrl+C: Copy image path to clipboard" padding: 10 wrapMode: Text.WordWrap @@ -429,6 +480,15 @@ ApplicationWindow { maxImageCount: uiState.imageCount } + ImageEditorDialog { + id: imageEditorDialog + onVisibleChanged: { + if (!visible) { + mainViewLoader.forceActiveFocus() + } + } + } + function show_jump_to_image_dialog() { jumpToImageDialog.open() } diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 84b4a25..1f8b9bf 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -45,6 +45,7 @@ def __init__(self, controller): Qt.Key_Return: "launch_helicon", Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", + Qt.Key_A: "quick_auto_white_balance", Qt.Key_Delete: "delete_current_image", Qt.Key_Backspace: "delete_current_image", } diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 93e9881..26d369f 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -84,6 +84,29 @@ class UIState(QObject): filterStringChanged = Signal() # Signal for filter string updates colorModeChanged = Signal() # Signal for color mode updates saturationFactorChanged = Signal() # Signal for saturation factor updates + default_directory_changed = Signal(str) + # Image Editor Signals + is_editor_open_changed = Signal(bool) + is_cropping_changed = Signal(bool) + brightness_changed = Signal(float) + contrast_changed = Signal(float) + saturation_changed = Signal(float) + white_balance_by_changed = Signal(float) + white_balance_mg_changed = Signal(float) + aspect_ratio_names_changed = Signal(list) + current_aspect_ratio_index_changed = Signal(int) + current_crop_box_changed = Signal(tuple) # (left, top, right, bottom) normalized to 0-1000 + anySliderPressedChanged = Signal(bool) + sharpness_changed = Signal(float) + rotation_changed = Signal(int) + exposure_changed = Signal(float) + highlights_changed = Signal(float) + shadows_changed = Signal(float) + vibrance_changed = Signal(float) + vignette_changed = Signal(float) + blacks_changed = Signal(float) + whites_changed = Signal(float) + clarity_changed = Signal(float) def __init__(self, app_controller): super().__init__() @@ -93,6 +116,28 @@ def __init__(self, app_controller): # 1 = light, 0 = dark (controller will overwrite this on startup) self._theme = 1 self._status_message = "" # New private variable for status message + # Image Editor State + self._is_editor_open = False + self._is_cropping = False + self._brightness = 0.0 + self._contrast = 0.0 + self._saturation = 0.0 + self._white_balance_by = 0.0 + self._white_balance_mg = 0.0 + self._current_crop_box = (0, 0, 1000, 1000) + self._aspect_ratio_names = [] + self._current_aspect_ratio_index = 0 + self._any_slider_pressed = False + self._sharpness = 0.0 + self._rotation = 0 + self._exposure = 0.0 + self._highlights = 0.0 + self._shadows = 0.0 + self._vibrance = 0.0 + self._vignette = 0.0 + self._blacks = 0.0 + self._whites = 0.0 + self._clarity = 0.0 # ---- THEME PROPERTY ---- @Property(int, notify=themeChanged) @@ -341,3 +386,257 @@ def resetZoomPan(self): """Triggers a reset of zoom and pan in QML.""" self.resetZoomPanRequested.emit() + # --- Image Editor Properties --- + + @Property(bool, notify=is_editor_open_changed) + def isEditorOpen(self) -> bool: + return self._is_editor_open + + @isEditorOpen.setter + def isEditorOpen(self, new_value: bool): + if self._is_editor_open != new_value: + self._is_editor_open = new_value + self.is_editor_open_changed.emit(new_value) + + @Property(bool, notify=anySliderPressedChanged) + def anySliderPressed(self): + return self._any_slider_pressed + + @anySliderPressed.setter + def anySliderPressed(self, value): + if self._any_slider_pressed != value: + self._any_slider_pressed = value + self.anySliderPressedChanged.emit(value) + + @Slot(bool) + def setAnySliderPressed(self, pressed: bool): + self.anySliderPressed = pressed + + @Property(bool, notify=is_cropping_changed) + def isCropping(self) -> bool: + return self._is_cropping + + @isCropping.setter + def isCropping(self, new_value: bool): + if self._is_cropping != new_value: + self._is_cropping = new_value + self.is_cropping_changed.emit(new_value) + + @Property(float, notify=brightness_changed) + def brightness(self) -> float: + return self._brightness + + @brightness.setter + def brightness(self, new_value: float): + if self._brightness != new_value: + self._brightness = new_value + self.brightness_changed.emit(new_value) + + @Property(float, notify=contrast_changed) + def contrast(self) -> float: + return self._contrast + + @contrast.setter + def contrast(self, new_value: float): + if self._contrast != new_value: + self._contrast = new_value + self.contrast_changed.emit(new_value) + + @Property(float, notify=saturation_changed) + def saturation(self) -> float: + return self._saturation + + @saturation.setter + def saturation(self, new_value: float): + if self._saturation != new_value: + self._saturation = new_value + self.saturation_changed.emit(new_value) + + @Property(float, notify=white_balance_by_changed) + def whiteBalanceBY(self) -> float: + return self._white_balance_by + + @whiteBalanceBY.setter + def whiteBalanceBY(self, new_value: float): + if self._white_balance_by != new_value: + self._white_balance_by = new_value + self.white_balance_by_changed.emit(new_value) + + @Property(float, notify=white_balance_mg_changed) + def whiteBalanceMG(self) -> float: + return self._white_balance_mg + + @whiteBalanceMG.setter + def whiteBalanceMG(self, new_value: float): + if self._white_balance_mg != new_value: + self._white_balance_mg = new_value + self.white_balance_mg_changed.emit(new_value) + + # Snake_case aliases for QML bracket notation access + @Property(float, notify=white_balance_by_changed) + def white_balance_by(self) -> float: + return self._white_balance_by + + @white_balance_by.setter + def white_balance_by(self, new_value: float): + self.whiteBalanceBY = new_value + + @Property(float, notify=white_balance_mg_changed) + def white_balance_mg(self) -> float: + return self._white_balance_mg + + @white_balance_mg.setter + def white_balance_mg(self, new_value: float): + self.whiteBalanceMG = new_value + + @Property('QVariantList', notify=aspect_ratio_names_changed) + def aspectRatioNames(self) -> list: + return self._aspect_ratio_names + + @aspectRatioNames.setter + def aspectRatioNames(self, new_value: list): + if self._aspect_ratio_names != new_value: + self._aspect_ratio_names = new_value + self.aspect_ratio_names_changed.emit(new_value) + + @Property(int, notify=current_aspect_ratio_index_changed) + def currentAspectRatioIndex(self) -> int: + return self._current_aspect_ratio_index + + @currentAspectRatioIndex.setter + def currentAspectRatioIndex(self, new_value: int): + if self._current_aspect_ratio_index != new_value: + self._current_aspect_ratio_index = new_value + self.current_aspect_ratio_index_changed.emit(new_value) + + @Property('QVariant', notify=current_crop_box_changed) + def currentCropBox(self) -> tuple: + # QML will receive this as a list + return self._current_crop_box + + @currentCropBox.setter + def currentCropBox(self, new_value: tuple): + if self._current_crop_box != new_value: + self._current_crop_box = new_value + self.current_crop_box_changed.emit(new_value) + + # --- New Properties --- + @Property(float, notify=sharpness_changed) + def sharpness(self) -> float: + return self._sharpness + + @sharpness.setter + def sharpness(self, new_value: float): + if self._sharpness != new_value: + self._sharpness = new_value + self.sharpness_changed.emit(new_value) + + @Property(int, notify=rotation_changed) + def rotation(self) -> int: + return self._rotation + + @rotation.setter + def rotation(self, new_value: int): + if self._rotation != new_value: + self._rotation = new_value + self.rotation_changed.emit(new_value) + + @Property(float, notify=exposure_changed) + def exposure(self) -> float: + return self._exposure + + @exposure.setter + def exposure(self, new_value: float): + if self._exposure != new_value: + self._exposure = new_value + self.exposure_changed.emit(new_value) + + @Property(float, notify=highlights_changed) + def highlights(self) -> float: + return self._highlights + + @highlights.setter + def highlights(self, new_value: float): + if self._highlights != new_value: + self._highlights = new_value + self.highlights_changed.emit(new_value) + + @Property(float, notify=shadows_changed) + def shadows(self) -> float: + return self._shadows + + @shadows.setter + def shadows(self, new_value: float): + if self._shadows != new_value: + self._shadows = new_value + self.shadows_changed.emit(new_value) + + @Property(float, notify=vibrance_changed) + def vibrance(self) -> float: + return self._vibrance + + @vibrance.setter + def vibrance(self, new_value: float): + if self._vibrance != new_value: + self._vibrance = new_value + self.vibrance_changed.emit(new_value) + + @Property(float, notify=vignette_changed) + def vignette(self) -> float: + return self._vignette + + @vignette.setter + def vignette(self, new_value: float): + if self._vignette != new_value: + self._vignette = new_value + self.vignette_changed.emit(new_value) + + @Property(float, notify=blacks_changed) + def blacks(self) -> float: + return self._blacks + + @blacks.setter + def blacks(self, new_value: float): + if self._blacks != new_value: + self._blacks = new_value + self.blacks_changed.emit(new_value) + + @Property(float, notify=whites_changed) + def whites(self) -> float: + return self._whites + + @whites.setter + def whites(self, new_value: float): + if self._whites != new_value: + self._whites = new_value + self.whites_changed.emit(new_value) + + @Property(float, notify=clarity_changed) + def clarity(self) -> float: + return self._clarity + + @clarity.setter + def clarity(self, new_value: float): + if self._clarity != new_value: + self._clarity = new_value + self.clarity_changed.emit(new_value) + + def reset_editor_state(self): + """Resets all UI state variables for the editor.""" + self.brightness = 0.0 + self.contrast = 0.0 + self.saturation = 0.0 + self.whiteBalanceBY = 0.0 + self.whiteBalanceMG = 0.0 + self.currentCropBox = (0, 0, 1000, 1000) + self.isCropping = False + self.sharpness = 0.0 + self.rotation = 0 + self.exposure = 0.0 + self.highlights = 0.0 + self.shadows = 0.0 + self.vibrance = 0.0 + self.vignette = 0.0 + self.blacks = 0.0 + self.whites = 0.0 + self.clarity = 0.0 diff --git a/faststack/patch b/faststack/patch new file mode 100644 index 0000000..cf0f009 --- /dev/null +++ b/faststack/patch @@ -0,0 +1,746 @@ +--- /dev/null ++++ b/faststack/imaging/editor.py +@@ -0,0 +1,141 @@ ++import os ++import shutil ++import glob ++from pathlib import Path ++from typing import Optional, Dict, Any, Tuple ++from PIL import Image, ImageEnhance ++from io import BytesIO ++ ++# Aspect Ratios for cropping ++INSTAGRAM_RATIOS = { ++ "Freeform": None, ++ "1:1 (Square)": (1, 1), ++ "4:5 (Portrait)": (4, 5), ++ "1.91:1 (Landscape)": (191, 100), ++ "9:16 (Story)": (9, 16), ++} ++ ++class ImageEditor: ++ """Handles core image manipulation using PIL.""" ++ def __init__(self): ++ # Stores the currently loaded PIL Image object (original) ++ self.original_image: Optional[Image.Image] = None ++ # Stores the currently applied edits (used for preview) ++ self.current_edits: Dict[str, Any] = self._initial_edits() ++ self.current_filepath: Optional[Path] = None ++ ++ def _initial_edits(self) -> Dict[str, Any]: ++ return { ++ 'brightness': 0.0, ++ 'contrast': 0.0, ++ 'saturation': 0.0, ++ 'white_balance_by': 0.0, # Blue/Yellow (Cool/Warm) ++ 'white_balance_mg': 0.0, # Magenta/Green (Tint) ++ 'crop_box': None, # (left, top, right, bottom) normalized to 0-1000 ++ } ++ ++ def load_image(self, filepath: str): ++ """Load a new image for editing.""" ++ if not filepath or not Path(filepath).exists(): ++ self.original_image = None ++ self.current_filepath = None ++ return False ++ ++ self.current_filepath = Path(filepath) ++ # Reset edits ++ self.current_edits = self._initial_edits() ++ ++ try: ++ # We must load and close the original file handle immediately ++ self.original_image = Image.open(self.current_filepath).convert("RGB") ++ return True ++ except Exception as e: ++ print(f"Error loading image for editing: {e}") ++ self.original_image = None ++ return False ++ ++ def _apply_edits(self, img: Image.Image) -> Image.Image: ++ """Applies all current edits to the provided PIL Image.""" ++ ++ # 1. Cropping (if defined) ++ crop_box = self.current_edits.get('crop_box') ++ if crop_box: ++ # Denormalize crop_box (0-1000) to actual pixel values ++ width, height = img.size ++ left = int(crop_box[0] * width / 1000) ++ top = int(crop_box[1] * height / 1000) ++ right = int(crop_box[2] * width / 1000) ++ bottom = int(crop_box[3] * height / 1000) ++ img = img.crop((left, top, right, bottom)) ++ ++ # 2. Brightness (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ bright_factor = 1.0 + self.current_edits['brightness'] ++ img = ImageEnhance.Brightness(img).enhance(bright_factor) ++ ++ # 3. Contrast (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ contrast_factor = 1.0 + self.current_edits['contrast'] ++ img = ImageEnhance.Contrast(img).enhance(contrast_factor) ++ ++ # 4. Saturation (normalized -1.0 to 1.0 -> factor 0.0 to 2.0) ++ saturation_factor = 1.0 + self.current_edits['saturation'] ++ img = ImageEnhance.Color(img).enhance(saturation_factor) ++ ++ # 5. White Balance (Tint - simplistic color matrix manipulation) ++ by_val = self.current_edits['white_balance_by'] * 0.5 # -0.5 to 0.5 ++ mg_val = self.current_edits['white_balance_mg'] * 0.5 # -0.5 to 0.5 ++ ++ r, g, b = img.split() ++ ++ # Blue/Yellow adjustment ++ r_by_adjust = r.point(lambda i: i + by_val * 255) ++ g_by_adjust = g.point(lambda i: i + by_val * 255) ++ b_by_adjust = b.point(lambda i: i - by_val * 255) ++ ++ # Magenta/Green adjustment ++ r_mg_adjust = r_by_adjust.point(lambda i: i + mg_val * 255) ++ g_mg_adjust = g_by_adjust.point(lambda i: i - mg_val * 255) ++ b_mg_adjust = b_by_adjust.point(lambda i: i + mg_val * 255) ++ ++ return Image.merge("RGB", (r_mg_adjust, g_mg_adjust, b_mg_adjust)) ++ ++ ++ def get_preview_data(self) -> Optional[bytes]: ++ """Apply current edits to the image and return the data as PNG bytes.""" ++ if self.original_image is None: ++ return None ++ ++ img = self.original_image.copy() ++ img = self._apply_edits(img) ++ ++ # Convert the PIL image to bytes (PNG to avoid repeated lossy compression during preview) ++ byte_arr = BytesIO() ++ img.save(byte_arr, format='PNG') ++ return byte_arr.getvalue() ++ ++ ++ def set_edit_param(self, key: str, value: float) -> bool: ++ """Update a single edit parameter (normalized -1.0 to 1.0).""" ++ if key in self.current_edits and key != 'crop_box': ++ self.current_edits[key] = value ++ return True ++ return False ++ ++ def set_crop_box(self, crop_box: Tuple[int, int, int, int]): ++ """Set the normalized crop box (left, top, right, bottom) from 0-1000.""" ++ self.current_edits['crop_box'] = crop_box ++ ++ def save_image(self) -> Optional[Path]: ++ """Saves the edited image, backing up the original.""" ++ if self.original_image is None or self.current_filepath is None: ++ return None ++ ++ final_img = self.original_image.copy() ++ final_img = self._apply_edits(final_img) ++ ++ original_path = self.current_filepath ++ backup_path_base = original_path.parent / (original_path.stem + "-backup") ++ backup_path = backup_path_base.with_suffix(".jpg") ++ ++ i = 1 ++ while backup_path.exists(): ++ backup_path = backup_path_base.with_suffix(f"#{i}.jpg") ++ i += 1 ++ ++ try: ++ # Perform the backup and overwrite ++ shutil.copy2(original_path, backup_path) ++ final_img.save(original_path, format='JPEG', quality=95) # Save as high-quality JPEG ++ return original_path ++ except Exception as e: ++ print(f"Failed to save edited image or backup: {e}") ++ return None ++ ++# Dictionary of ratios for QML dropdown ++ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] ++``` +--- /dev/null ++++ b/qml/ImageEditorDialog.qml +@@ -0,0 +1,105 @@ ++import QtQuick 2.15 ++import QtQuick.Controls 2.15 ++import QtQuick.Layouts 1.15 ++ ++// Draggable popup dialog for image editing controls +Pane { ++ id: editDialog ++ // Use fixed size for simplicity in a draggable panel ++ width: 300 ++ height: 500 ++ ++ // Initial position - centered, slightly high ++ x: (parent.width - width) / 2 ++ y: parent.height * 0.1 ++ ++ // Draggable behavior (Title Bar) ++ MouseArea { ++ id: titleBar ++ anchors.top: parent.top ++ anchors.left: parent.left ++ anchors.right: parent.right ++ height: 30 ++ acceptedButtons: Qt.LeftButton ++ ++ Rectangle { ++ anchors.fill: parent ++ color: uiState.theme === 1 ? "#333333" : "#DDDDDD" ++ border.color: "black" ++ border.width: 1 ++ Text { ++ anchors.centerIn: parent ++ text: "Image Editor" ++ color: uiState.theme === 1 ? "white" : "black" ++ font.bold: true ++ } ++ } ++ ++ property point dragPoint: Qt.point(0,0) ++ ++ onPressed: { dragPoint = Qt.point(mouse.x, mouse.y) } ++ onPositionChanged: { ++ editDialog.x += mouse.x - dragPoint.x ++ editDialog.y += mouse.y - dragPoint.y ++ } ++ } ++ ++ ColumnLayout { ++ anchors.top: titleBar.bottom ++ anchors.left: parent.left ++ anchors.right: parent.right ++ spacing: 10 ++ padding: 10 ++ ++ // Helper component for Sliders ++ Component { ++ id: editSlider ++ ColumnLayout { ++ Text { ++ text: modelData.name + ": " + (modelData.value * 100).toFixed(0) ++ color: uiState.theme === 1 ? "white" : "black" ++ font.pixelSize: 14 ++ } ++ Slider { ++ id: slider ++ Layout.fillWidth: true ++ from: -100 ++ to: 100 ++ stepSize: 1 ++ value: modelData.value * 100 ++ onValueChanged: { ++ // Normalize back to -1.0 to 1.0 for Python logic ++ appModel.app_controller.set_edit_parameter(modelData.key, slider.value / 100.0) ++ } ++ } ++ } ++ } ++ ++ // Sliders List ++ Repeater { ++ model: [ ++ { name: "Brightness", key: "brightness", value: appModel.brightness }, ++ { name: "Contrast", key: "contrast", value: appModel.contrast }, ++ { name: "Saturation", key: "saturation", value: appModel.saturation }, ++ { name: "White Balance (B/Y)", key: "white_balance_by", value: appModel.whiteBalanceBY }, ++ { name: "White Balance (M/G)", key: "white_balance_mg", value: appModel.whiteBalanceMG } ++ ] ++ delegate: editSlider ++ } ++ ++ // Crop Controls ++ RowLayout { ++ Layout.fillWidth: true ++ spacing: 5 ++ ++ Button { ++ id: cropButton ++ text: appModel.isCropping ? "Cropping (Click/Drag)" : "Crop" ++ Layout.fillWidth: true ++ onClicked: { appModel.isCropping = !appModel.isCropping } ++ } ++ ++ ComboBox { ++ id: aspectRatioDropdown ++ Layout.preferredWidth: 100 ++ model: appModel.aspectRatioNames ++ currentIndex: appModel.currentAspectRatioIndex ++ onCurrentIndexChanged: { appModel.currentAspectRatioIndex = currentIndex } ++ // NOTE: Aspect ratio locking logic is not fully implemented in this patch ++ // due to complexity, but the model property is available for future use. ++ } ++ } ++ ++ Text { ++ Layout.fillWidth: true ++ wrapMode: Text.WordWrap ++ text: appModel.isCropping ? "Click/Drag to select crop area. Press ENTER to execute crop." : "" ++ color: "red" ++ horizontalAlignment: Text.AlignHCenter ++ font.pixelSize: 12 ++ } ++ ++ Button { ++ text: "Reset All Edits" ++ Layout.fillWidth: true ++ onClicked: { appModel.reset_edit_parameters() } ++ } ++ } ++} +``` +--- a/app.py ++++ b/app.py +@@ -52,12 +52,24 @@ + + # -------------------------------------------------------------------------- + # Image Provider Setup +- self.image_provider = ImageProvider(self.app_controller.app_state_model) ++ # Use a single provider for both original and edited images ++ self.image_provider = ImageProvider(self.app_controller.app_state_model, is_edited_provider=False) + self.engine.addImageProvider("imageProvider", self.image_provider) ++ ++ # Separate provider for the live-edited image preview ++ self.edited_image_provider = ImageProvider(self.app_controller.app_state_model, is_edited_provider=True) ++ self.engine.addImageProvider("editedImageProvider", self.edited_image_provider) + + # -------------------------------------------------------------------------- + # Engine Context Setup + self.engine.rootContext().setContextProperty("appModel", self.app_controller.app_state_model) ++ ++ # -------------------------------------------------------------------------- ++ # Connect signals for edit mode to manage image source ++ self.app_controller.app_state_model.isEditingChanged.connect(self._handle_editing_toggle) ++ # Connect data change signal to the edited provider ++ self.app_controller.app_state_model.currentEditedImageDataChanged.connect( ++ lambda: self.edited_image_provider.image_data = self.app_controller.app_state_model.current_edited_image_data ++ ) + + # Load the QML file + qml_file = Path(__file__).parent / "qml" / "main.qml" +@@ -74,6 +86,14 @@ + if self.engine.rootObjects(): + self.engine.rootObjects()[0].show() + ++ def _handle_editing_toggle(self): ++ """Update the image provider source when editing mode changes.""" ++ is_editing = self.app_controller.app_state_model.is_editing ++ ++ # If starting edit mode, ensure the initial edited data is generated ++ if is_editing: ++ # Trigger the edited image data update to show the initial (unmodified) preview ++ self.app_controller.app_state_model.current_edited_image_data = self.app_controller.image_editor.get_preview_data() + + def main(): + """Main function for FastStack.""" +--- a/app_controller.py ++++ b/app_controller.py +@@ -10,6 +10,7 @@ + from faststack.io.indexer import find_images + from faststack.io.sidecar import SidecarManager + from faststack.imaging.prefetch import Prefetcher ++from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS + + # Set up logger + logger = logging.getLogger(__name__) +@@ -23,6 +24,7 @@ + self.prefetcher = Prefetcher(self) + self.sidecar_manager = SidecarManager(self) + self.app_state_model = AppStateModel(self) ++ self.image_editor = ImageEditor() + + # Load initial directory + self.set_current_directory(config.get("default_directory", str(Path.home()))) +@@ -118,6 +120,53 @@ + self.prefetcher.start_prefetch(self.current_image_index) + + self.app_state_model.current_image_file = current_image ++ # Load the image into the editor instance ++ self.image_editor.load_image(current_image.path if current_image else "") ++ self.app_state_model.reset_edit_parameters() # Reset edits for new image ++ ++ @Slot(result=QVariant) ++ def get_aspect_ratios(self) -> List[Dict[str, Any]]: ++ """Returns the list of aspect ratio names/values.""" ++ return ASPECT_RATIOS ++ ++ @Slot(str, float) ++ def set_edit_parameter(self, key: str, value: float): ++ """Sets a single editing parameter (brightness, saturation, etc.) and updates preview.""" ++ if self.image_editor.set_edit_param(key, value): ++ # Trigger image provider to update the current image with preview data ++ self.app_state_model.current_edited_image_data = self.image_editor.get_preview_data() ++ self.app_state_model.edits_pending = True ++ # Manually update the model property to keep QML sliders synced ++ if key == 'brightness': self.app_state_model.brightness = value ++ elif key == 'contrast': self.app_state_model.contrast = value ++ elif key == 'saturation': self.app_state_model.saturation = value ++ elif key == 'white_balance_by': self.app_state_model.whiteBalanceBY = value ++ elif key == 'white_balance_mg': self.app_state_model.whiteBalanceMG = value ++ ++ @Slot(float, float, float, float) ++ def set_crop_selection_normalized(self, x1, y1, x2, y2): ++ """Sets the normalized crop box (0-1) which is scaled to (0-1000) for Python logic.""" ++ # Python logic expects normalized 0-1000 ++ crop_box = (int(x1*1000), int(y1*1000), int(x2*1000), int(y2*1000)) ++ self.image_editor.set_crop_box(crop_box) ++ # Update preview ++ self.app_state_model.current_edited_image_data = self.image_editor.get_preview_data() ++ self.app_state_model.edits_pending = True ++ ++ @Slot() ++ def execute_crop(self): ++ """Executes the crop selection and disables cropping mode.""" ++ self.app_state_model.isCropping = False ++ self.app_state_model.hasActiveCropSelection = False ++ ++ @Slot(bool) ++ def save_or_discard_edits(self, save_changes: bool): ++ """Handles saving or discarding changes when edit mode is exited.""" ++ if save_changes and self.app_state_model.edits_pending: ++ saved_path = self.image_editor.save_image() ++ if saved_path: ++ self.reload_current_image() # Reload to show the newly saved file ++ ++ # Always reset internal state and clear edited preview data ++ self.app_state_model.current_edited_image_data = None ++ self.app_state_model.reset_edit_parameters() + + @Slot(result=bool) + def is_currently_staring(self): +--- a/app_state_model.py ++++ b/app_state_model.py +@@ -2,7 +2,7 @@ + import logging + from pathlib import Path + from typing import TYPE_CHECKING, Optional, List, Any +-from PySide6.QtCore import QObject, Signal, Slot, Property, QUrl ++from PySide6.QtCore import QObject, Signal, Slot, Property, QUrl, QByteArray, QVariant + + if TYPE_CHECKING: + from .app_controller import AppController +@@ -37,6 +37,7 @@ + # Image editing properties + isEditingChanged = Signal() + isCroppingChanged = Signal() ++ editsPendingChanged = Signal() + + brightnessChanged = Signal() + contrastChanged = Signal() +@@ -47,7 +48,7 @@ + + hasActiveCropSelectionChanged = Signal() + +- # --- New signals for image editing --- ++ currentEditedImageDataChanged = Signal() + + aspectRatioNamesChanged = Signal() + currentAspectRatioIndexChanged = Signal() +@@ -62,6 +63,16 @@ + self._is_editing = False + self._is_cropping = False + ++ # Editing parameters (normalized to -1.0 to 1.0) ++ self._edits_pending = False ++ self._brightness = 0.0 ++ self._contrast = 0.0 ++ self._saturation = 0.0 ++ self._white_balance_by = 0.0 ++ self._white_balance_mg = 0.0 ++ self._has_active_crop_selection = False ++ self._current_edited_image_data = None # Holds image data bytes for QML provider ++ self._current_aspect_ratio_index = 0 ++ self._aspect_ratio_names = [r["name"] for r in self.app_controller.get_aspect_ratios()] ++ + @Property(str, notify=currentImagePathChanged) + def current_image_path(self): + return str(self._current_image_file.path) if self._current_image_file else "" +@@ -176,3 +187,94 @@ + if self._is_cropping != value: + self._is_cropping = value + self.isCroppingChanged.emit() ++ ++ @Property(bool, notify=editsPendingChanged) ++ def edits_pending(self): ++ return self._edits_pending ++ ++ @edits_pending.setter ++ def edits_pending(self, value: bool): ++ if self._edits_pending != value: ++ self._edits_pending = value ++ self.editsPendingChanged.emit() ++ ++ @Property(float, notify=brightnessChanged) ++ def brightness(self): ++ return self._brightness ++ ++ @brightness.setter ++ def brightness(self, value: float): ++ if self._brightness != value: ++ self._brightness = value ++ self.brightnessChanged.emit() ++ ++ @Property(float, notify=contrastChanged) ++ def contrast(self): ++ return self._contrast ++ ++ @contrast.setter ++ def contrast(self, value: float): ++ if self._contrast != value: ++ self._contrast = value ++ self.contrastChanged.emit() ++ ++ @Property(float, notify=saturationChanged) ++ def saturation(self): ++ return self._saturation ++ ++ @saturation.setter ++ def saturation(self, value: float): ++ if self._saturation != value: ++ self._saturation = value ++ self.saturationChanged.emit() ++ ++ @Property(float, notify=whiteBalanceBYChanged) ++ def whiteBalanceBY(self): ++ return self._white_balance_by ++ ++ @whiteBalanceBY.setter ++ def whiteBalanceBY(self, value: float): ++ if self._white_balance_by != value: ++ self._white_balance_by = value ++ self.whiteBalanceBYChanged.emit() ++ ++ @Property(float, notify=whiteBalanceMGChanged) ++ def whiteBalanceMG(self): ++ return self._white_balance_mg ++ ++ @whiteBalanceMG.setter ++ def whiteBalanceMG(self, value: float): ++ if self._white_balance_mg != value: ++ self._white_balance_mg = value ++ self.whiteBalanceMGChanged.emit() ++ ++ @Property(bool, notify=hasActiveCropSelectionChanged) ++ def hasActiveCropSelection(self): ++ return self._has_active_crop_selection ++ ++ @hasActiveCropSelection.setter ++ def hasActiveCropSelection(self, value: bool): ++ if self._has_active_crop_selection != value: ++ self._has_active_crop_selection = value ++ self.hasActiveCropSelectionChanged.emit() ++ ++ @Property(QByteArray, notify=currentEditedImageDataChanged) ++ def current_edited_image_data(self): ++ return self._current_edited_image_data ++ ++ @current_edited_image_data.setter ++ def current_edited_image_data(self, value: Optional[bytes]): ++ data = QByteArray(value) if value is not None else QByteArray() ++ if self._current_edited_image_data != data: ++ self._current_edited_image_data = data ++ self.currentEditedImageDataChanged.emit() ++ ++ @Property("QStringList", notify=aspectRatioNamesChanged) ++ def aspectRatioNames(self): ++ return self._aspect_ratio_names ++ ++ @Property(int, notify=currentAspectRatioIndexChanged) ++ def currentAspectRatioIndex(self): ++ return self._current_aspect_ratio_index ++ ++ @currentAspectRatioIndex.setter ++ def currentAspectRatioIndex(self, value: int): ++ if self._current_aspect_ratio_index != value: ++ self._current_aspect_ratio_index = value ++ self.currentAspectRatioIndexChanged.emit() ++ ++ @Slot() ++ def reset_edit_parameters(self): ++ self.brightness = self.contrast = self.saturation = 0.0 ++ self.whiteBalanceBY = self.whiteBalanceMG = 0.0 ++ self.edits_pending = False ++ self.isCropping = False ++ self.hasActiveCropSelection = False +--- a/faststack/ui/provider.py ++++ b/faststack/ui/provider.py +@@ -10,13 +10,18 @@ + class ImageProvider(QQuickImageProvider): + """Custom image provider for QML to retrieve images from the cache/disk.""" + +- def __init__(self, app_state_model: "AppStateModel"): ++ def __init__(self, app_state_model: "AppStateModel", is_edited_provider: bool): + super().__init__(QQuickImageProvider.Pixmap) + self.app_state_model = app_state_model + self.app_controller = self.app_state_model.app_controller ++ self.is_edited_provider = is_edited_provider + self.current_path: Optional[Path] = None + self.current_data: Optional[DecodedImage] = None + self.last_load_time: float = 0 ++ ++ # For edited provider, we store the raw byte data ++ self.image_data: QByteArray = QByteArray() ++ self.edited_pixmap: QPixmap = QPixmap() + + def requestPixmap(self, id: str, size: QSize): + """ +@@ -25,7 +30,8 @@ + :param size: The requested size of the image. + :return: A QPixmap containing the image. + """ +- if id.startswith("thumb_"): ++ # If this is the edited provider, serve the in-memory edited data ++ if self.is_edited_provider and not self.image_data.isEmpty(): ++ self.edited_pixmap.loadFromData(self.image_data, "PNG") ++ # Scale the pixmap to the requested size ++ return self.edited_pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) ++ ++ elif id.startswith("thumb_"): + # Handle thumbnail request (existing logic) + path_str = id.replace("thumb_", "", 1) + path = Path(path_str) +--- a/qml/main.qml ++++ b/qml/main.qml +@@ -7,6 +7,8 @@ + import QtQuick.Controls 2.15 + import QtQuick.Layouts 1.15 + ++ImageEditorDialog { id: editorDialog; visible: appModel.isEditing } ++ + + ApplicationWindow { + id: applicationWindow +@@ -37,13 +39,21 @@ + + // 'E' to toggle Edit Mode + else if (event.key === Qt.Key_E) { +- appModel.isEditing = !appModel.isEditing ++ if (appModel.isEditing) { ++ // If exiting, ask to save if changes are pending ++ if (appModel.edits_pending) { ++ saveDiscardDialog.open() ++ } else { ++ // No changes, just close ++ appModel.isEditing = false ++ } ++ } else { ++ // Entering edit mode ++ appModel.isEditing = true ++ } + event.accepted = true + } +- } +- +- ++ ++ // ENTER to execute crop in crop mode or confirm save/discard ++ else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { ++ if (appModel.isEditing && appModel.isCropping && imageMouseArea.selectionActive) { ++ appModel.app_controller.execute_crop() ++ // The crop is now "applied" (set in the model) and cropping selection ends ++ event.accepted = true ++ } else if (saveDiscardDialog.visible) { ++ saveDiscardDialog.handleClose(true) // Default to Yes ++ saveDiscardDialog.close() ++ event.accepted = true ++ } ++ } ++ ++ } + + // Main Content Area + RowLayout { +@@ -105,11 +115,14 @@ + sourceSize.width: mainImage.width + sourceSize.height: mainImage.height +- source: "image://imageProvider/" + appModel.current_image_path ++ ++ // Switch source based on edit mode ++ source: appModel.isEditing ++ ? "image://editedImageProvider/currentEdit" ++ : "image://imageProvider/" + appModel.current_image_path + + // Keep mouse wheel zoom working in edit mode + MouseArea { + id: imageMouseArea + anchors.fill: parent + hoverEnabled: true +@@ -124,6 +137,39 @@ + onWheel: { + imageMouseArea.forceActiveFocus() + applicationWindow.zoomImage(event.angleDelta.y) + } ++ ++ // Cropping selection logic ++ property bool selectionActive: false ++ property point startPoint: Qt.point(0, 0) ++ property point endPoint: Qt.point(0, 0) ++ ++ onPressed: { ++ if (appModel.isEditing && appModel.isCropping) { ++ selectionActive = true ++ startPoint = Qt.point(mouse.x, mouse.y) ++ endPoint = startPoint ++ appModel.hasActiveCropSelection = true ++ } ++ } ++ onMouseMoved: { ++ if (appModel.isEditing && appModel.isCropping && selectionActive) { ++ endPoint = Qt.point(mouse.x, mouse.y) ++ } ++ } ++ onReleased: { ++ if (appModel.isEditing && appModel.isCropping && selectionActive) { ++ // Send normalized coordinates to Python for preview update ++ var rect = normalizeCrop(startPoint, endPoint) ++ // Python expects normalized left, top, right, bottom from 0-1 ++ appModel.app_controller.set_crop_selection_normalized(rect.left, rect.top, rect.right, rect.bottom) ++ } ++ } ++ ++ function normalizeCrop(p1, p2) { ++ var left = Math.min(p1.x, p2.x) / mainImage.width ++ var top = Math.min(p1.y, p2.y) / mainImage.height ++ var right = Math.max(p1.x, p2.x) / mainImage.width ++ var bottom = Math.max(p1.y, p2.y) / mainImage.height ++ return { left: left, top: top, right: right, bottom: bottom } ++ } ++ } ++ ++ // Visual Cropping Overlay ++ Rectangle { ++ visible: appModel.isEditing && appModel.isCropping && imageMouseArea.selectionActive ++ x: Math.min(imageMouseArea.startPoint.x, imageMouseArea.endPoint.x) ++ y: Math.min(imageMouseArea.startPoint.y, imageMouseArea.endPoint.y) ++ width: Math.abs(imageMouseArea.startPoint.x - imageMouseArea.endPoint.x) ++ height: Math.abs(imageMouseArea.startPoint.y - imageMouseArea.endPoint.y) ++ color: "transparent" ++ border.color: "red" ++ border.width: 2 ++ opacity: 0.8 + } + } + +@@ -133,3 +179,21 @@ + Layout.fillWidth: true + } + } ++ ++ // Save/Discard Confirmation Dialog ++ MessageDialog { ++ id: saveDiscardDialog ++ title: "Unsaved Edits" ++ text: "Do you want to save your image edits?" ++ ++ // Set default button to Yes (AcceptRole) ++ standardButtons: Dialog.Yes | Dialog.No ++ ++ // Helper function to handle saving/discarding and closing the editor ++ function handleClose(save: bool) { ++ appModel.app_controller.save_or_discard_edits(save) ++ appModel.isEditing = false // Close edit mode ++ } ++ ++ onAccepted: handleClose(true) // 'Yes' is the default/accepted action ++ onRejected: handleClose(false) // 'No' is the rejected action ++ } + } diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index ae403f2..c0e2676 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.0" +version = "1.1" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ] diff --git a/z b/z new file mode 100644 index 0000000..0a47495 --- /dev/null +++ b/z @@ -0,0 +1,2977 @@ +tar: faststack/README.md: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/dependency_links.txt: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/requires.txt: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/entry_points.txt: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/PKG-INFO: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/top_level.txt: Cannot open: No such file or directory +tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory +tar: faststack/faststack.egg-info/SOURCES.txt: Cannot open: No such file or directory +tar: faststack/LICENSE: Cannot open: No such file or directory +tar: faststack/faststack.spec: Cannot open: No such file or directory +tar: faststack/ChangeLog.md: Cannot open: No such file or directory +tar: faststack/pyproject.toml: Cannot open: No such file or directory +tar: faststack/patch: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/config.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/indexer.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/helicon.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__/helicon.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__/executable_validator.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__/sidecar.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__/indexer.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/__pycache__/watcher.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/watcher.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/executable_validator.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/io/sidecar.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/keystrokes.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/provider.py.bak: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/__pycache__: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/__pycache__/controller.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/__pycache__/provider.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/__pycache__/keystrokes.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/keystrokes.py.bak: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/ui/provider.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/benchmark_decode.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/app.py.bak: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/models.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/app.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/logging_setup.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/entry.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/__init__.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__pycache__/config.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/JumpToImageDialog.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/Main.qml.bak: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/SettingsDialog.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/Main.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/Components.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/ImageEditorDialog.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/qml/FilterDialog.qml: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/app.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/logging_setup.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/cache.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/editor.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/prefetch.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/__pycache__: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/__pycache__/cache.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/__pycache__/jpeg.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/__pycache__/prefetch.cpython-313.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/imaging/jpeg.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/models.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/__init__.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/test_executable_validator.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/__pycache__: Cannot mkdir: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/__pycache__/test_executable_validator.cpython-313-pytest-8.4.2.pyc: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/test_sidecar.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/test_cache.py: Cannot open: No such file or directory +tar: faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/faststack/tests/test_pairing.py: Cannot open: No such file or directory +tar: faststack/requirements.txt: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_operator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/weakref.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/copy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_weakrefset.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/contextvars.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/genericpath.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/contextlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers/expat: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers/expat/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers/expat/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/parsers/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/minidom.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/minicompat.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/xmlbuilder.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/xmlbuilder.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/minicompat.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/domreg.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/expatbuilder.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/expatbuilder.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/domreg.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/dom/minidom.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/_exceptions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/handler.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/xmlreader.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/_exceptions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/handler.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/sax/xmlreader.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/etree: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/etree/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/etree/ElementTree.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/etree/ElementTree.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/xml/etree/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_decimal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/socket.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/cProfile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/fractions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/builtins.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/copyreg.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/os: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/os/path.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/os/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/os/path.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/os/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/math.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/selectors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numbers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/dis.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/warnings.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/itertools.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_bisect.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bisect.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_locale.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/codeop.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_compile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/signal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/contextvars.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/client.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/client.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/cookiejar.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/http/cookiejar.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/locale.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pickle.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/selectors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_weakref.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageFile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/TiffImagePlugin.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImagePalette.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImagePalette.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_typing.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageFilter.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/Image.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/TiffTags.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_imaging.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageColor.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageColor.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/PaletteFile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/GimpPaletteFile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_imagingcms.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_version.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_version.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageFilter.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageCms.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_imaging.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ExifTags.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/Image.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ExifTags.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_imagingcms.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageMode.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/TiffTags.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageQt.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_util.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageOps.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_util.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageFile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_typing.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageOps.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/GimpGradientFile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_binary.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/GimpPaletteFile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageCms.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageMode.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/GimpGradientFile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/TiffImagePlugin.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_deprecate.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_deprecate.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/ImageQt.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/PaletteFile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PIL/_binary.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_warnings.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_weakref.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/keyword.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_constants.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/keyword.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/typing.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/string.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/codeop.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/struct.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipimport.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bisect.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/io.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/time.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/shlex.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/wave.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ssl.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tokenize.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/reprlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/@plugins_snapshot.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/token.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/types.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipimport.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/base64.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/getopt.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/configparser.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_ast.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pathlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_typeshed: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_typeshed/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_typeshed/xml.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_typeshed/xml.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_typeshed/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bdb.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pathlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/builtins.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_codecs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/parse.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/parse.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/error.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/request.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/response.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/request.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/error.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/urllib/response.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/dataclasses.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sqlite3: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sqlite3/dbapi2.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sqlite3/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sqlite3/dbapi2.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sqlite3/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pdb.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/shutil.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_thread.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pydoc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/copyreg.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_constants.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numbers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/contextlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/linecache.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_random.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/dataclasses.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/random.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/subprocess.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/reprlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/log.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/traitlets.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/_version.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/_version.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/loader.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/configurable.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/application.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/argcomplete_config.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/configurable.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/loader.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/argcomplete_config.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/config/application.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/log.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/traitlets.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/text.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/warnings.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/decorators.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/decorators.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/bunch.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/sentinel.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/bunch.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/getargspec.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/descriptions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/importstring.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/warnings.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/importstring.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/sentinel.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/nested_update.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/getargspec.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/text.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/descriptions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traitlets/utils/nested_update.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bdb.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/uuid.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/subprocess.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/operator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/threading.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_compression.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/notebooknode.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/json_compat.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/nbjson.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/nbbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/nbjson.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/nbbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/rwbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/rwbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/convert.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v1/convert.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_imports.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/warnings.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/sentinel.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/validator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_imports.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbjson.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbjson.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbpy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbpy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/nbbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/rwbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/rwbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/convert.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v3/convert.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_version.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_version.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/nbjson.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/nbbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/nbjson.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/nbbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/rwbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/rwbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/convert.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v4/convert.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/warnings.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/json_compat.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_struct.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/reader.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/sentinel.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbjson.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbjson.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbpy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbpy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbxml.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/rwbase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/nbxml.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/rwbase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/convert.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/v2/convert.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/validator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/_struct.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/converter.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/notebooknode.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/converter.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.data.json.bc126a5597e10293: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/nbformat/reader.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/opcode.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/random.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/posixpath.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/display.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/paths.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/display.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/display.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/display.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/clipboard.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/pretty.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/pretty.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/lib/clipboard.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_match.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_match.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/filters.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_suggest.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_suggest.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/filters.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/ptutils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/interactiveshell.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/embed.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/magics.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/magics.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/prompts.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/ptutils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/embed.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/debugger.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/ipapp.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/prompts.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/debugger.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/ipapp.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/terminal/interactiveshell.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/paths.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/extensions: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/extensions/storemagic.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/extensions/storemagic.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/extensions/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/extensions/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/testing: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/testing/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/testing/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/testing/skipdoctest.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/testing/skipdoctest.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/capture.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/contexts.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/tokenutil.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_common.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/text.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/path.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/decorators.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/encoding.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/decorators.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/data.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/sentinel.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/coloransi.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/io.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/openpy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/py3compat.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/module_paths.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_posix.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/process.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/encoding.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/strdispatch.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_win32.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/dir2.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/importstring.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_sysinfo.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/wildcard.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/capture.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/module_paths.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/docs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/syspathcontext.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/process.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/docs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/timing.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/importstring.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/sentinel.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/frame.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/sysinfo.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_sysinfo.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/colorable.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/generics.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_win32.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/path.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_common.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/strdispatch.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/syspathcontext.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/ipstruct.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/terminal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/openpy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/dir2.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/contexts.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/text.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/py3compat.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/PyColorize.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/sysinfo.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/PyColorize.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/io.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/frame.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/tokenutil.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/data.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/_process_posix.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/coloransi.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/wildcard.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/terminal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/colorable.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/timing.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/generics.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/utils/ipstruct.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/payload.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/builtin_trap.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/ultratb.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/pylabtools.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/profiledir.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/payload.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/shellapp.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/excolors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/displayhook.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display_trap.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/page.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/code.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/pylab.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/display.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/execution.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/namespace.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/display.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/history.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/code.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/extension.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/pylab.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/config.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/logging.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/namespace.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/script.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/packaging.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/osm.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/execution.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/basic.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/basic.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/auto.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/extension.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/history.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/packaging.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/auto.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/script.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/config.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/ast_mod.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/osm.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/logging.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magics/ast_mod.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/alias.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/hooks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/ultratb.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/completer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/excolors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/interactiveshell.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/pylabtools.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/history.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/error.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/crashhandler.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/getipython.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/oinspect.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/prefilter.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/displaypub.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/release.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/completerlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/oinspect.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/prefilter.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magic.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/page.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/crashhandler.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/logger.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/guarded_eval.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/extensions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/compilerop.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/latex_symbols.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/guarded_eval.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/completerlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/displayhook.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/application.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/usage.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magic.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/inputtransformer2.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/splitinput.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/async_helpers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/macro.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/autocall.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/logger.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/builtin_trap.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/inputtransformer2.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/usage.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/extensions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/compilerop.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/history.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/hooks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/macro.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/shellapp.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/completer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/formatters.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/error.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/autocall.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/debugger.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/alias.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/release.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/displaypub.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/async_helpers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magic_arguments.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/latex_symbols.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/magic_arguments.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/getipython.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/debugger.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display_functions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/profiledir.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display_trap.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/display_functions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/application.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/splitinput.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/formatters.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/IPython/core/interactiveshell.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/timeit.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/typing_extensions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/socket.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/glob.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/collections: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/collections/abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/collections/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/collections/abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/collections/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pickle.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_codecs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/inspect.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/platform.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/glob.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/api.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_c.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/polling.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/polling.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/api.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_buffer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_buffer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_c.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/patterns.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/patterns.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/bricks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/dirsnapshot.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/bricks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/dirsnapshot.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/delayed_queue.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/watchdog/utils/delayed_queue.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pdb.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_stat.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bz2.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/time.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/cmd.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_lsprof.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sys: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sys/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sys/_monitoring.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sys/_monitoring.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sys/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/math.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/datetime.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/errno.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/functools.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/async_case.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/result.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/async_case.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/_log.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/runner.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/loader.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/_log.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/signals.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/signals.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/suite.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/main.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/loader.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/result.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/case.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/case.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/suite.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/main.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unittest/runner.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/codecs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/resource.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_ctypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/enum.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/site.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/__future__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/mimetypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/struct.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/_endian.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/util.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/util.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/wintypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/_endian.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ctypes/wintypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ast.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/string.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/difflib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/timeit.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/weakref.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unicodedata.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_collections_abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_compile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pydoc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/queue.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/atexit.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipfile: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipfile/_path.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipfile/_path.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipfile/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/zipfile/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/linecache.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/mimetypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/dis.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/warnings.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/__future__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/html: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/html/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/html/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/fnmatch.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tarfile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/gzip.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/gzip.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_ctypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/shutil.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/unicodedata.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/__main__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/argparse.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/datetime.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/msvcrt.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/getopt.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/resource.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_compression.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_thread.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/hashlib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/bz2.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/functools.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/runpy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tokenize.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/textwrap.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/configparser.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/colorsys.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/gc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/opcode.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/shlex.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_decimal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/decimal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tarfile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/stat.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/base64.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/profile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/enum.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/typing_extensions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ast.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/operator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_weakrefset.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/mmap.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/cmd.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/posixpath.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/ssl.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/decoder.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/decoder.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/encoder.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/json/encoder.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_socket.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/atexit.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/argparse.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/threading.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_locale.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/runpy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/types.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/context.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/reduction.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/shared_memory.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/synchronize.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_fork.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/synchronize.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/sharedctypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/util.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_posix.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/spawn.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/process.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_posix.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/util.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_win32.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/queues.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_win32.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/reduction.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/context.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/shared_memory.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/pool.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/process.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/managers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/connection.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/sharedctypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/spawn.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_fork.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/queues.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/managers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_forkserver.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/popen_forkserver.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/pool.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/multiprocessing/connection.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/colorsys.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_parse.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/itertools.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_warnings.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/sre_parse.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/re.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/difflib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/fractions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_bisect.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pstats.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/token.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/codecs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_collections_abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/locale.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tempfile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/textwrap.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/_abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/machinery.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/metadata: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/metadata/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/metadata/_meta.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/metadata/_meta.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/metadata/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/readers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/_abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/readers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/resources: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/resources/abc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/resources/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/resources/abc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/resources/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/importlib/machinery.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/futures.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/exceptions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/selector_events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/unix_events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/futures.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/timeouts.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/subprocess.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/subprocess.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/unix_events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/exceptions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/locks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/protocols.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/queues.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/locks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/base_events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/coroutines.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/transports.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/taskgroups.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/selector_events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/transports.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/coroutines.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/mixins.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/queues.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/mixins.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/base_events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/threads.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/streams.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/tasks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/taskgroups.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/tasks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/runners.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/streams.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/runners.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/threads.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/protocols.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/asyncio/timeouts.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traceback.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/ui.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/sidecar.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/sidecar.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/executable_validator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/indexer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/helicon.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/indexer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/helicon.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io/executable_validator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/models.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/ui.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/logging_setup.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/logging_setup.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/imaging.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/models.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/config.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/faststack.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/imaging.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/faststack.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/config.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/faststack: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/faststack/io.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/profile.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/select.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/binascii.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/uuid.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_random.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/io.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_lsprof.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/typing.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/traceback.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_operator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/charset.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/_policybase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/message.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/header.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/policy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/errors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/charset.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/contentmanager.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/errors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/message.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/_policybase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/contentmanager.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/header.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/email/policy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/array.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/platform.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_ast.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/msvcrt.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_stat.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/copy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/binascii.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/handlers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/config.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/handlers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/logging/config.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/stat.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/fnmatch.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/exceptions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/typing: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/typing/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/typing/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/polynomial.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/legendre.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/legendre.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/polyutils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/_polybase.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/chebyshev.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/laguerre.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/_polybase.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite_e.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite_e.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/polynomial.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/polyutils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/chebyshev.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/polynomial/laguerre.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/version.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/dtypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_pytesttester.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_pytesttester.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/dtypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/exceptions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ctypeslib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/scimath.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/stride_tricks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/npyio.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/npyio.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.data.json.fb9c4864355d404e: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/twodim_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/histograms.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/format.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/function_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/index_tricks.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/shape_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/polynomial.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/nanfunctions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arrayterator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/nanfunctions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/_version.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/_version.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arrayterator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/stride_tricks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arraypad.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/arraypad.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/shape_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/type_check.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/ufunclike.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/type_check.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/scimath.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/function_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/mixins.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/polynomial.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/mixins.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/histograms.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/twodim_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/ufunclike.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/format.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/lib/index_tricks.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_callable.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.data.json.feedcc2d4b38d34a: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_scalars.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_nbit.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_scalars.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_array_like.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_ufunc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_array_like.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_shape.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_extended_precision.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_shape.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_ufunc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_add_docstring.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_extended_precision.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_nbit.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_nested_sequence.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_nested_sequence.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_add_docstring.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_dtype_like.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_callable.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_typing/_dtype_like.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/bit_generator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_philox.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_mt19937.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_pcg64.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_generator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/bit_generator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_generator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_philox.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/mtrand.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_mt19937.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_pcg64.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_sfc64.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/mtrand.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/random/_sfc64.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/_pocketfft.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/helper.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/helper.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/fft/_pocketfft.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/extras.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/extras.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/core.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/core.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/mrecords.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ma/mrecords.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/matrixlib: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/matrixlib/defmatrix.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/matrixlib/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/matrixlib/defmatrix.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/matrixlib/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/ctypeslib.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_utils: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_utils/_convertions.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_utils/_convertions.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_utils/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/_utils/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/linalg: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/linalg/linalg.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/linalg/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/linalg/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/linalg/linalg.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/_private: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/_private/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/_private/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/_private/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/_private/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/testing/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/version.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/numerictypes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/fromnumeric.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/fromnumeric.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/numerictypes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_ufunc_config.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/function_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/shape_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/numeric.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/numeric.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_asarray.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_asarray.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/records.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_internal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/multiarray.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_ufunc_config.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/defchararray.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/umath.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_type_aliases.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/einsumfunc.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/multiarray.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/arrayprint.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/arrayprint.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/shape_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/records.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/defchararray.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/umath.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/function_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_type_aliases.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/einsumfunc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/numpy/core/_internal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/hashlib.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/__main__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/mmap.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/signal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/decimal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/cProfile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/queue.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/inspect.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/sip.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/QtCore.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/QtGui.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/sip.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/QtCore.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/PyQt6/QtGui.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/array.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/process.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/thread.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/thread.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/process.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/_base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/futures/_base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/concurrent/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/tempfile.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/site.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/gc.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/_socket.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pprint.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pprint.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pstats.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/buffer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dummy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dimension.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/controls.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/scrollable_pane.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/margins.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/mouse_handlers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/menus.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/screen.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/layout.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dummy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/mouse_handlers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/processors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/containers.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/scrollable_pane.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dimension.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/controls.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/containers.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/processors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/menus.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/margins.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/layout.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/screen.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/data_structures.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/filesystem.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/fuzzy_completer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/word_completer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/deduplicate.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/nested.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/word_completer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/filesystem.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/nested.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/deduplicate.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/fuzzy_completer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/formatters.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/formatters.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/prompt.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/prompt.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/dialogs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/dialogs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/search.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/app.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/cli.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/app.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/cli.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/keys.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/mouse_events.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/history.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/enums.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/buffer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/cache.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/auto_suggest.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/keys.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/patch_stdout.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/inputhook.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/inputhook.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/async_generator.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/async_generator.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/auto_suggest.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/validation.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/flush_stdout.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/plain_text.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/vt100.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/plain_text.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/defaults.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/color_depth.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/vt100.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/defaults.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/flush_stdout.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/color_depth.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/mouse_events.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/document.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/renderer.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/toolbars.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/menus.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/dialogs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/toolbars.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/dialogs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/menus.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_processor.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/cpr.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/named_commands.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/cpr.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/completion.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/focus.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/auto_suggest.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/focus.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/auto_suggest.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/mouse.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/vi.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/page_navigation.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/open_in_editor.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/basic.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/basic.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/open_in_editor.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/scroll.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/completion.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/mouse.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/emacs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/vi.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/emacs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/page_navigation.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/scroll.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/named_commands.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_bindings.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_processor.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/vi_state.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/emacs_state.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/defaults.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/defaults.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/digraphs.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/vi_state.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/emacs_state.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_bindings.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/digraphs.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/vt100_parser.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/typeahead.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/ansi_escape_sequences.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/defaults.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/typeahead.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/defaults.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/vt100_parser.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/ansi_escape_sequences.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/enums.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/search.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/history.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/validation.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/utils.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/ansi.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/pygments.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/utils.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/pygments.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/ansi.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/html.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/html.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/patch_stdout.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/cursor_shapes.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/pygments.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style_transformation.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/named_colors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/defaults.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/pygments.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style_transformation.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/defaults.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/named_colors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/dummy.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/dummy.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/current.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/run_in_terminal.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/application.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/run_in_terminal.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/current.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/application.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/in_memory.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/in_memory.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/document.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/pygments.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/pygments.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/base.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/base.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/data_structures.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/selection.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/selection.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/renderer.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/cache.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/prompt_toolkit/cursor_shapes.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/re.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/genericpath.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/errors.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/__init__.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/errors.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/__init__.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/model.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/pyexpat/model.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/select.meta.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/errno.data.json: Cannot open: No such file or directory +tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory +tar: faststack/.mypy_cache/3.12/wave.data.json: Cannot open: No such file or directory +tar: faststack: Cannot utime: No such file or directory +tar: faststack: Cannot stat: No such file or directory +tar: Exiting with failure status due to previous errors