From de42838f8c87e4a1afbcd5a7d89b38576a155066 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 12 Mar 2026 08:28:51 -0700 Subject: [PATCH 1/2] Refactor get_metadata to return None when create=False Previously, get_metadata with create=False returned a blank EntryMetadata object if no entry existed. This obscured the difference between missing metadata and empty metadata. This commit updates the API to return None when a requested sidecar entry is missing and create=False. Changes: - Added overload type hints to get_metadata for strict return typing. - Updated read-only metadata lookups in AppController to handle None. - Updated side_effect mocks in tests to accept keyword arguments. --- faststack/app.py | 51 +++++++++++-------- faststack/io/sidecar.py | 21 ++++++-- faststack/tests/test_jump_to_last_uploaded.py | 4 +- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 31e1502..97939f9 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1795,7 +1795,7 @@ def jump_to_last_uploaded(self): # Dynamic look-up of self.sidecar as requested (important for mocks in tests) meta = self.sidecar.get_metadata(img.path.stem, create=False) - uploaded = meta.uploaded + uploaded = meta.uploaded if meta else False if uploaded is True: last_uploaded_index = idx @@ -2115,13 +2115,22 @@ def _get_metadata_dict(self, stem: str) -> dict: """Get metadata for a file stem as a dict for thumbnail model.""" try: meta = self.sidecar.get_metadata(stem, create=False) + if meta is None: + return { + "stacked": False, + "uploaded": False, + "edited": False, + "restacked": False, + "favorite": False, + "todo": False, + } return { - "stacked": getattr(meta, "stacked", False), - "uploaded": getattr(meta, "uploaded", False), - "edited": getattr(meta, "edited", False), - "restacked": getattr(meta, "restacked", False), - "favorite": getattr(meta, "favorite", False), - "todo": getattr(meta, "todo", False), + "stacked": meta.stacked, + "uploaded": meta.uploaded, + "edited": meta.edited, + "restacked": meta.restacked, + "favorite": meta.favorite, + "todo": meta.todo, } except Exception as e: # Broad catch for UI plumbing - don't crash grid view log.debug("Failed to get metadata for %s: %s", stem, e) @@ -2372,17 +2381,17 @@ def get_current_metadata(self) -> Dict: self._metadata_cache = { "filename": filename, "exif_brief": exif_brief, - "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 "", - "restacked": meta.restacked, - "restacked_date": meta.restacked_date or "", - "favorite": meta.favorite, - "todo": getattr(meta, "todo", False), - "todo_date": getattr(meta, "todo_date", "") or "", + "stacked": meta.stacked if meta else False, + "stacked_date": (meta.stacked_date or "") if meta else "", + "uploaded": meta.uploaded if meta else False, + "uploaded_date": (meta.uploaded_date or "") if meta else "", + "edited": meta.edited if meta else False, + "edited_date": (meta.edited_date or "") if meta else "", + "restacked": meta.restacked if meta else False, + "restacked_date": (meta.restacked_date or "") if meta else "", + "favorite": meta.favorite if meta else False, + "todo": meta.todo if meta else False, + "todo_date": (meta.todo_date or "") if meta else "", "stack_info_text": stack_info, "batch_info_text": batch_info, } @@ -2607,7 +2616,7 @@ def add_favorites_to_batch(self): indices_to_add = [] for i, img in enumerate(self.image_files): meta = self.sidecar.get_metadata(img.path.stem, create=False) - if meta.favorite: + if meta and meta.favorite: indices_to_add.append(i) if not indices_to_add: @@ -2662,7 +2671,7 @@ def add_uploaded_to_batch(self): indices_to_add = [] for i, img in enumerate(self.image_files): meta = self.sidecar.get_metadata(img.path.stem, create=False) - if meta.uploaded: + if meta and meta.uploaded: indices_to_add.append(i) if not indices_to_add: @@ -7362,7 +7371,7 @@ def is_stacked(self) -> bool: return False stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem, create=False) - return meta.stacked + return meta.stacked if meta else False def _update_cache_stats(self): if self.debug_cache: diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index 2491208..98a2f6f 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -4,6 +4,7 @@ import logging import time from pathlib import Path +from typing import Literal, Optional, overload from faststack.models import Sidecar, EntryMetadata @@ -118,13 +119,27 @@ def save(self): if was_watcher_running: self.start_watcher() - def get_metadata(self, image_stem: str, *, create: bool = True) -> EntryMetadata: - """Get metadata for an image, optionally creating a persistent entry.""" + @overload + def get_metadata(self, image_stem: str, *, create: Literal[True] = True) -> EntryMetadata: ... + + @overload + def get_metadata(self, image_stem: str, *, create: Literal[False]) -> Optional[EntryMetadata]: ... + + @overload + def get_metadata(self, image_stem: str, *, create: bool) -> Optional[EntryMetadata]: ... + + def get_metadata(self, image_stem: str, *, create: bool = True) -> Optional[EntryMetadata]: + """Get metadata for an image, optionally creating a persistent entry. + + When create=True (default), always returns an EntryMetadata (creating + and storing one if it doesn't exist). When create=False, returns None + if no entry exists — callers must handle the None case explicitly. + """ meta = self.data.entries.get(image_stem) if meta is None and create: meta = EntryMetadata() self.data.entries[image_stem] = meta - return meta if meta is not None else EntryMetadata() + return meta def set_last_index(self, index: int): self.data.last_index = index diff --git a/faststack/tests/test_jump_to_last_uploaded.py b/faststack/tests/test_jump_to_last_uploaded.py index 8610035..524d8a3 100644 --- a/faststack/tests/test_jump_to_last_uploaded.py +++ b/faststack/tests/test_jump_to_last_uploaded.py @@ -66,7 +66,7 @@ def test_jump_to_last_uploaded_success(mock_controller): meta2 = EntryMetadata(uploaded=False) meta3 = EntryMetadata(uploaded=True) - def side_effect(stem): + def side_effect(stem, **kwargs): return {"img1": meta1, "img2": meta2, "img3": meta3}.get(stem, EntryMetadata()) mock_controller.sidecar.get_metadata.side_effect = side_effect @@ -137,7 +137,7 @@ def test_jump_to_last_uploaded_one(mock_controller): mock_controller.image_files = [img1, img2, img3] mock_controller.current_index = 0 - def side_effect(stem): + def side_effect(stem, **kwargs): return {"img1": meta1, "img2": meta2, "img3": meta3}.get(stem, EntryMetadata()) mock_controller.sidecar.get_metadata.side_effect = side_effect From 4700ea685ccb6e7c1120b5f5ce47def75b833f35 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 12 Mar 2026 08:38:30 -0700 Subject: [PATCH 2/2] update changelog --- ChangeLog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index c36f9cb..627f711 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -14,6 +14,10 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Improved prefetch behavior when zooming or resizing to reduce stale background work. - Improved thumbnail lookup speed by adding a faster path-to-row mapping. - Reduced chances of UI state getting out of sync after external file changes. +- Added `@overload` type hints to `SidecarManager.get_metadata` to provide strict static typing based on the state of the `create` parameter. +- Modified `SidecarManager.get_metadata` to accept a `create` boolean parameter. When `create=False`, the method now returns `None` instead of instantiating and saving an empty metadata entry. +- Updated `AppController` read-only operations (such as thumbnail dictionary generation, status checks, and batching) to request metadata with `create=False`. +- Refactored `AppController` flag extraction (e.g., `uploaded`, `favorite`) to explicitly handle `None` values, replacing older, bulky type-checking logic that looked for both `dict` and `object` structures. ## 1.5.9 (2026-02-16)