From 8d583f300284c9cb7ef1619d65870b2ae7eeb9d5 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Tue, 17 Mar 2026 17:40:35 +0100 Subject: [PATCH 01/13] Handle missing turbojpeg DLL on Windows --- README.md | 45 +++++++++++++++ faststack/imaging/jpeg.py | 23 ++------ faststack/imaging/turbo.py | 77 ++++++++++++++++++++++++++ faststack/logging_setup.py | 39 ++++++++++++- faststack/thumbnail_view/prefetcher.py | 15 ++--- 5 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 faststack/imaging/turbo.py diff --git a/README.md b/README.md index a2cc359..28f91e7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,51 @@ pip install . faststack ``` +### Windows Performance Note +On Windows, `PyTurboJPEG` also needs the native `libjpeg-turbo` library (`turbojpeg.dll`). + +- If `turbojpeg.dll` is installed, FastStack uses it automatically for faster JPEG decode and thumbnail generation. +- If it is missing, FastStack still runs, but falls back to Pillow and may feel slower on large folders. + +Recommended install location: + +- `C:\libjpeg-turbo64\bin\turbojpeg.dll` + +FastStack also checks these optional environment variables if you installed it elsewhere: + +- `FASTSTACK_TURBOJPEG_LIB` +- `TURBOJPEG_LIB` + +Example: + +```cmd +set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll +venv\Scripts\python.exe -m faststack.app "C:\path\to\photos" +``` + +### Troubleshooting on Windows +If startup logs mention: + +```text +Unable to locate turbojpeg library automatically. +You may specify the turbojpeg library path manually. +``` + +that means the Python package is installed but the native `turbojpeg.dll` is not available in a location that `PyTurboJPEG` can find. + +Fastest fixes: + +1. Install `libjpeg-turbo` for Windows x64 so that this file exists: + `C:\libjpeg-turbo64\bin\turbojpeg.dll` +2. Or point FastStack to the dll explicitly: + +```cmd +set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll +venv\Scripts\python.exe -m faststack.app "C:\path\to\photos" +``` + +If you do nothing, FastStack will still run, but JPEG decoding and thumbnail generation will use Pillow instead of `libjpeg-turbo`, which is slower. + ## Keyboard Shortcuts - `J` / `Right Arrow`: Next Image diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index aa9f58d..9beaa99 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -5,27 +5,16 @@ import numpy as np from PIL import Image +from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg log = logging.getLogger(__name__) -# Attempt to import PyTurboJPEG - -try: - from turbojpeg import TurboJPEG, TJPF_RGB -except ImportError: - jpeg_decoder = None - TURBO_AVAILABLE = False - log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") +# Attempt to initialize PyTurboJPEG with explicit native library discovery. +jpeg_decoder, TURBO_AVAILABLE = create_turbojpeg() +if TURBO_AVAILABLE: + log.info("PyTurboJPEG is available. Using it for JPEG decoding.") else: - try: - jpeg_decoder = TurboJPEG() - except Exception: - jpeg_decoder = None - TURBO_AVAILABLE = False - log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") - else: - TURBO_AVAILABLE = True - log.info("PyTurboJPEG is available. Using it for JPEG decoding.") + log.warning("PyTurboJPEG unavailable. Falling back to Pillow for JPEG decoding.") def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py new file mode 100644 index 0000000..995b5c4 --- /dev/null +++ b/faststack/imaging/turbo.py @@ -0,0 +1,77 @@ +"""Helpers for optional libjpeg-turbo discovery and initialization.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Optional, Tuple + +log = logging.getLogger(__name__) + +try: + from turbojpeg import TurboJPEG, TJPF_RGB +except ImportError: + TurboJPEG = None + TJPF_RGB = None + + +def _candidate_library_paths() -> list[Path]: + """Return likely native library locations for libjpeg-turbo on Windows.""" + candidates: list[Path] = [] + + for env_name in ("FASTSTACK_TURBOJPEG_LIB", "TURBOJPEG_LIB"): + value = os.getenv(env_name) + if value: + candidates.append(Path(value)) + + if os.name == "nt": + candidates.extend( + [ + Path(r"C:\libjpeg-turbo64\bin\turbojpeg.dll"), + Path(r"C:\libjpeg-turbo\bin\turbojpeg.dll"), + Path(r"C:\Program Files\libjpeg-turbo\bin\turbojpeg.dll"), + Path(r"C:\Program Files\libjpeg-turbo64\bin\turbojpeg.dll"), + Path(r"C:\Program Files (x86)\libjpeg-turbo\bin\turbojpeg.dll"), + Path(r"C:\Program Files (x86)\libjpeg-turbo64\bin\turbojpeg.dll"), + ] + ) + + for path_dir in os.getenv("PATH", "").split(os.pathsep): + if path_dir: + candidates.append(Path(path_dir) / "turbojpeg.dll") + + # Preserve order while removing duplicates. + unique: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = os.path.normcase(str(candidate)) + if key in seen: + continue + seen.add(key) + unique.append(candidate) + return unique + + +def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: + """Create a TurboJPEG decoder if both wrapper and native library exist.""" + if TurboJPEG is None: + log.debug("PyTurboJPEG Python package not available") + return None, False + + try: + return TurboJPEG(), True + except Exception: + log.debug("TurboJPEG auto-discovery failed; trying explicit library paths") + + for candidate in _candidate_library_paths(): + if not candidate.exists(): + continue + try: + decoder = TurboJPEG(lib_path=str(candidate)) + log.info("Using libjpeg-turbo at %s", candidate) + return decoder, True + except Exception: + log.debug("Failed to initialize turbojpeg from %s", candidate) + + return None, False diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index ab684a4..a209ef8 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -6,12 +6,45 @@ from pathlib import Path +def _is_writable_dir(path: Path) -> bool: + """Return True when the directory can be created and written to.""" + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".write_test" + with probe.open("w", encoding="utf-8") as f: + f.write("ok") + probe.unlink(missing_ok=True) + return True + except OSError: + return False + + def get_app_data_dir() -> Path: - """Returns the application data directory.""" + """Return a writable application data directory, with fallbacks.""" + candidates = [] + + override = os.getenv("FASTSTACK_APPDATA") + if override: + candidates.append(Path(override)) + app_data = os.getenv("APPDATA") if app_data: - return Path(app_data) / "faststack" - return Path.home() / ".faststack" + candidates.append(Path(app_data) / "faststack") + + local_app_data = os.getenv("LOCALAPPDATA") + if local_app_data: + candidates.append(Path(local_app_data) / "faststack") + + candidates.append(Path.home() / ".faststack") + candidates.append(Path.cwd() / "var" / "appdata") + + for candidate in candidates: + if _is_writable_dir(candidate): + return candidate + + # Final fallback: return the first candidate even if unwritable so callers + # still get a deterministic location for error reporting. + return candidates[0] if candidates else Path.cwd() / "var" / "appdata" def setup_logging(debug: bool = False): diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index e0c1b38..5544ae3 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -18,6 +18,7 @@ from PIL import Image from contextlib import nullcontext +from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg from faststack.util.executors import create_priority_executor from faststack.imaging.orientation import get_exif_orientation, apply_orientation_to_np from faststack.io.utils import compute_path_hash @@ -38,16 +39,10 @@ class _ReadyEmitter(QObject): _HAS_QT = False QCoreApplication = None -# Try to import turbojpeg for faster JPEG decoding -try: - from turbojpeg import TurboJPEG, TJPF_RGB - - _tj = TurboJPEG() - HAS_TURBOJPEG = True -except ImportError: - _tj = None - HAS_TURBOJPEG = False - log.debug("TurboJPEG not available, using PIL for thumbnail decoding") +# Try to initialize turbojpeg with shared discovery logic. +_tj, HAS_TURBOJPEG = create_turbojpeg() +if not HAS_TURBOJPEG: + log.debug("TurboJPEG unavailable, using PIL for thumbnail decoding") class ThumbnailPrefetcher: From fc57cbe224870561a6d395d08f0e119f942730d0 Mon Sep 17 00:00:00 2001 From: Andy Arijs <78924184+Dj-Shortcut@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:38:02 +0100 Subject: [PATCH 02/13] Improve TurboJPEG fallback handling on Windows --- faststack/imaging/jpeg.py | 56 ++------------- faststack/imaging/turbo.py | 102 +++++++++++++++++---------- faststack/logging_setup.py | 57 ++++++++------- faststack/tests/test_turbo.py | 126 ++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 112 deletions(-) create mode 100644 faststack/tests/test_turbo.py diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index bd2cffd..059b640 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -6,42 +6,19 @@ import numpy as np from PIL import Image + from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg log = logging.getLogger(__name__) -# Attempt to initialize PyTurboJPEG with explicit native library discovery. -jpeg_decoder, TURBO_AVAILABLE = create_turbojpeg() -if TURBO_AVAILABLE: - log.info("PyTurboJPEG is available. Using it for JPEG decoding.") -else: - log.warning("PyTurboJPEG unavailable. Falling back to Pillow for JPEG decoding.") -# Attempt to import PyTurboJPEG - -try: - from turbojpeg import TurboJPEG, TJPF_RGB -except ImportError: - JPEG_DECODER = None - TURBO_AVAILABLE = False - log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") -else: - try: - JPEG_DECODER = TurboJPEG() - except Exception: - JPEG_DECODER = None - TURBO_AVAILABLE = False - log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") - else: - TURBO_AVAILABLE = True - log.info("PyTurboJPEG is available. Using it for JPEG decoding.") +JPEG_DECODER, TURBO_AVAILABLE = create_turbojpeg() def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> 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 + # Decode with proper color space handling (no TJFLAG_FASTDCT). flags = 0 if fast_dct: # TJFLAG_FASTDCT = 2048 @@ -49,9 +26,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd return JPEG_DECODER.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) except Exception as e: log.exception("PyTurboJPEG failed to decode image: %s. Trying Pillow.", e) - # Fall through to Pillow fallback - # Fallback to Pillow try: img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") return np.array(img) @@ -66,17 +41,14 @@ def decode_jpeg_thumb_rgb( """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 + flags=0, ) if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: img = Image.fromarray(decoded) @@ -88,7 +60,6 @@ def decode_jpeg_thumb_rgb( "PyTurboJPEG failed to decode thumbnail: %s. Trying Pillow.", e ) - # Fallback to Pillow try: img = Image.open(BytesIO(jpeg_bytes)) img.thumbnail((max_dim, max_dim)) @@ -105,7 +76,6 @@ def _get_turbojpeg_scaling_factor( 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], @@ -116,7 +86,6 @@ def _get_turbojpeg_scaling_factor( 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 @@ -129,15 +98,11 @@ def decode_jpeg_resized( if TURBO_AVAILABLE and JPEG_DECODER: try: - # Get image header to determine dimensions img_width, img_height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) - # Determine which dimension is the limiting factor if img_width * height > img_height * width: - # Image is wider relative to target box; width is the constraint max_dim = width else: - # Image is taller relative to target box; height is the constraint max_dim = height scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) @@ -145,27 +110,23 @@ def decode_jpeg_resized( if scale_factor: flags = 0 if fast_dct: - # TJFLAG_FASTDCT = 2048 flags |= 2048 decoded = JPEG_DECODER.decode( jpeg_bytes, scaling_factor=scale_factor, pixel_format=TJPF_RGB, - flags=flags, # Proper color space handling + flags=flags, ) - # Only use Pillow for final resize if needed if decoded.shape[0] > height or decoded.shape[1] > width: img = Image.fromarray(decoded) - # Use BILINEAR for speed img.thumbnail((width, height), Image.Resampling.BILINEAR) return np.array(img) return decoded except Exception as e: log.exception("PyTurboJPEG failed: %s", e) - # Fallback to Pillow (existing code) try: img = Image.open(BytesIO(jpeg_bytes)) @@ -174,13 +135,10 @@ def decode_jpeg_resized( 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 + resampling = Image.Resampling.BILINEAR else: - resampling = ( - Image.Resampling.LANCZOS - ) # Higher quality for smaller downscales + resampling = Image.Resampling.LANCZOS img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py index 995b5c4..defd06e 100644 --- a/faststack/imaging/turbo.py +++ b/faststack/imaging/turbo.py @@ -1,4 +1,4 @@ -"""Helpers for optional libjpeg-turbo discovery and initialization.""" +"""TurboJPEG discovery helpers with Windows DLL fallbacks.""" from __future__ import annotations @@ -11,41 +11,63 @@ try: from turbojpeg import TurboJPEG, TJPF_RGB -except ImportError: +except ImportError: # pragma: no cover - exercised via create_turbojpeg TurboJPEG = None TJPF_RGB = None -def _candidate_library_paths() -> list[Path]: - """Return likely native library locations for libjpeg-turbo on Windows.""" - candidates: list[Path] = [] +def _candidate_library_paths() -> list[Optional[str]]: + """Return candidate libjpeg-turbo library paths to try in priority order.""" + candidates: list[Optional[str]] = [] - for env_name in ("FASTSTACK_TURBOJPEG_LIB", "TURBOJPEG_LIB"): - value = os.getenv(env_name) - if value: - candidates.append(Path(value)) + explicit = os.getenv("FASTSTACK_TURBOJPEG_LIB") or os.getenv("TURBOJPEG_LIB") + if explicit: + candidates.append(explicit) + else: + candidates.append(None) if os.name == "nt": - candidates.extend( - [ - Path(r"C:\libjpeg-turbo64\bin\turbojpeg.dll"), - Path(r"C:\libjpeg-turbo\bin\turbojpeg.dll"), - Path(r"C:\Program Files\libjpeg-turbo\bin\turbojpeg.dll"), - Path(r"C:\Program Files\libjpeg-turbo64\bin\turbojpeg.dll"), - Path(r"C:\Program Files (x86)\libjpeg-turbo\bin\turbojpeg.dll"), - Path(r"C:\Program Files (x86)\libjpeg-turbo64\bin\turbojpeg.dll"), - ] - ) + common_roots = [ + os.getenv("FASTSTACK_TURBOJPEG_ROOT"), + os.getenv("ProgramFiles"), + os.getenv("ProgramFiles(x86)"), + ] + suffixes = [ + ("libjpeg-turbo", "bin", "turbojpeg.dll"), + ("libjpeg-turbo64", "bin", "turbojpeg.dll"), + ("libjpeg-turbo-gcc64", "bin", "turbojpeg.dll"), + ("TurboJPEG", "bin", "turbojpeg.dll"), + ("bin", "turbojpeg.dll"), + ] + for root in common_roots: + if not root: + continue + for suffix in suffixes: + candidates.append(str(Path(root).joinpath(*suffix))) + + local_app_data = os.getenv("LOCALAPPDATA") + if local_app_data: + candidates.append( + str( + Path(local_app_data) + / "Programs" + / "libjpeg-turbo" + / "bin" + / "turbojpeg.dll" + ) + ) for path_dir in os.getenv("PATH", "").split(os.pathsep): if path_dir: - candidates.append(Path(path_dir) / "turbojpeg.dll") + candidates.append(str(Path(path_dir) / "turbojpeg.dll")) + + if explicit: + candidates.append(None) - # Preserve order while removing duplicates. - unique: list[Path] = [] + unique: list[Optional[str]] = [] seen: set[str] = set() for candidate in candidates: - key = os.path.normcase(str(candidate)) + key = "__default__" if candidate is None else os.path.normcase(candidate) if key in seen: continue seen.add(key) @@ -54,24 +76,30 @@ def _candidate_library_paths() -> list[Path]: def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: - """Create a TurboJPEG decoder if both wrapper and native library exist.""" + """Create a TurboJPEG decoder if possible.""" if TurboJPEG is None: - log.debug("PyTurboJPEG Python package not available") + log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") return None, False - try: - return TurboJPEG(), True - except Exception: - log.debug("TurboJPEG auto-discovery failed; trying explicit library paths") - + failures: list[str] = [] for candidate in _candidate_library_paths(): - if not candidate.exists(): - continue try: - decoder = TurboJPEG(lib_path=str(candidate)) - log.info("Using libjpeg-turbo at %s", candidate) - return decoder, True - except Exception: - log.debug("Failed to initialize turbojpeg from %s", candidate) + decoder = TurboJPEG() if candidate is None else TurboJPEG(candidate) + except Exception as exc: + source = "default loader" if candidate is None else candidate + failures.append(f"{source}: {exc}") + continue + + if candidate is None: + log.info("PyTurboJPEG is available. Using it for JPEG decoding.") + else: + log.info("Loaded TurboJPEG library from %s", candidate) + return decoder, True + for failure in failures: + log.warning("TurboJPEG load attempt failed: %s", failure) + log.warning( + "TurboJPEG initialization failed for all attempted locations. " + "Falling back to Pillow." + ) return None, False diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index a209ef8..57859b0 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -4,23 +4,31 @@ import logging.handlers import os from pathlib import Path +from tempfile import NamedTemporaryFile, gettempdir -def _is_writable_dir(path: Path) -> bool: - """Return True when the directory can be created and written to.""" +def _is_writable_directory(path: Path) -> bool: + """Return True when the directory exists and a temp file can be created there.""" try: - path.mkdir(parents=True, exist_ok=True) - probe = path / ".write_test" - with probe.open("w", encoding="utf-8") as f: - f.write("ok") - probe.unlink(missing_ok=True) + if not path.exists() or not path.is_dir(): + return False + with NamedTemporaryFile(dir=path, prefix=".faststack-write-test-", delete=True): + pass return True except OSError: return False +def _can_create_directory(path: Path) -> bool: + """Return True when the target directory can be created without creating it yet.""" + parent = path.parent + while not parent.exists() and parent != parent.parent: + parent = parent.parent + return _is_writable_directory(parent) + + def get_app_data_dir() -> Path: - """Return a writable application data directory, with fallbacks.""" + """Return a writable application data directory path.""" candidates = [] override = os.getenv("FASTSTACK_APPDATA") @@ -36,15 +44,14 @@ def get_app_data_dir() -> Path: candidates.append(Path(local_app_data) / "faststack") candidates.append(Path.home() / ".faststack") + candidates.append(Path(gettempdir()) / "faststack") candidates.append(Path.cwd() / "var" / "appdata") for candidate in candidates: - if _is_writable_dir(candidate): + if _is_writable_directory(candidate) or _can_create_directory(candidate): return candidate - # Final fallback: return the first candidate even if unwritable so callers - # still get a deterministic location for error reporting. - return candidates[0] if candidates else Path.cwd() / "var" / "appdata" + return Path.cwd() / "var" / "appdata" def setup_logging(debug: bool = False): @@ -53,36 +60,34 @@ def setup_logging(debug: bool = False): Args: debug: If True, sets log level to DEBUG. Otherwise, sets to WARNING to reduce noise. """ - log_dir = get_app_data_dir() / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / "app.log" - - # File handler - file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10 * 1024 * 1024, backupCount=5 - ) formatter = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) - file_handler.setFormatter(formatter) - # Console handler (for seeing logs in terminal) console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) root_logger = logging.getLogger() - # Set log level based on debug flag root_logger.setLevel(logging.DEBUG if debug else logging.WARNING) root_logger.handlers.clear() - root_logger.addHandler(file_handler) root_logger.addHandler(console_handler) - # Configure logging for key modules + try: + log_dir = get_app_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "app.log" + file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10 * 1024 * 1024, backupCount=5 + ) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + except OSError as exc: + root_logger.warning("File logging disabled: %s", exc) + 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/tests/test_turbo.py b/faststack/tests/test_turbo.py new file mode 100644 index 0000000..37e4a66 --- /dev/null +++ b/faststack/tests/test_turbo.py @@ -0,0 +1,126 @@ +import importlib +import logging +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + + +def test_create_turbojpeg_prefers_explicit_env_path(monkeypatch): + turbo = importlib.import_module("faststack.imaging.turbo") + + calls = [] + + def fake_decoder(path=None): + calls.append(path) + if path == "C:/turbo/bin/turbojpeg.dll": + return SimpleNamespace(source=path) + raise RuntimeError(f"boom:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setenv("FASTSTACK_TURBOJPEG_LIB", "C:/turbo/bin/turbojpeg.dll") + + decoder, available = turbo.create_turbojpeg() + + assert available is True + assert decoder.source == "C:/turbo/bin/turbojpeg.dll" + assert calls == ["C:/turbo/bin/turbojpeg.dll"] + + +def test_create_turbojpeg_logs_failed_candidates(monkeypatch, caplog): + turbo = importlib.import_module("faststack.imaging.turbo") + + def fake_decoder(path=None): + raise RuntimeError(f"boom:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setattr( + turbo, + "_candidate_library_paths", + lambda: [None, "C:/one/turbojpeg.dll", "C:/two/turbojpeg.dll"], + ) + + with caplog.at_level(logging.WARNING): + decoder, available = turbo.create_turbojpeg() + + assert decoder is None + assert available is False + assert "default loader" in caplog.text + assert "C:/one/turbojpeg.dll" in caplog.text + assert "C:/two/turbojpeg.dll" in caplog.text + assert "Falling back to Pillow" in caplog.text + + +def test_get_app_data_dir_falls_back_when_appdata_is_not_creatable(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + home_dir = tmp_path / "home" + home_dir.mkdir() + fallback_dir = home_dir / ".faststack" + blocked_candidate = tmp_path / "blocked" / "faststack" + + monkeypatch.setenv("APPDATA", str(tmp_path / "blocked")) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home_dir) + monkeypatch.setattr( + logging_setup, + "_can_create_directory", + lambda path: False if path == blocked_candidate else True, + ) + + assert logging_setup.get_app_data_dir() == fallback_dir + + +def test_get_app_data_dir_falls_back_to_tempdir(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + home_dir = tmp_path / "home" + temp_dir = tmp_path / "tmp" + temp_dir.mkdir() + home_candidate = home_dir / ".faststack" + + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) + monkeypatch.delenv("APPDATA", raising=False) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home_dir) + monkeypatch.setattr(logging_setup, "gettempdir", lambda: str(temp_dir)) + monkeypatch.setattr( + logging_setup, + "_can_create_directory", + lambda path: ( + False + if path == home_candidate + else logging_setup._is_writable_directory(path.parent) + ), + ) + + assert logging_setup.get_app_data_dir() == temp_dir / "faststack" + + +def test_is_writable_directory_does_not_create_missing_dir(tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + missing = tmp_path / "missing" + + assert logging_setup._is_writable_directory(missing) is False + assert missing.exists() is False + + +def test_setup_logging_keeps_console_handler_when_file_logging_fails(monkeypatch): + logging_setup = importlib.import_module("faststack.logging_setup") + + monkeypatch.setattr(logging_setup, "get_app_data_dir", lambda: Path("/bad-path")) + monkeypatch.setattr( + logging_setup.logging.handlers, + "RotatingFileHandler", + Mock(side_effect=OSError("disk full")), + ) + + logging_setup.setup_logging(debug=True) + + root_logger = logging.getLogger() + try: + assert any( + isinstance(handler, logging.StreamHandler) for handler in root_logger.handlers + ) + finally: + root_logger.handlers.clear() From a38f055f5894ce32958728500f37c2119f24aa5e Mon Sep 17 00:00:00 2001 From: Andy Arijs <78924184+Dj-Shortcut@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:46:44 +0100 Subject: [PATCH 03/13] Fix PR53 follow-up review comments From e42a544552506f08e5e9b9940a98dc06e77d58fb Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Fri, 20 Mar 2026 17:35:53 +0100 Subject: [PATCH 04/13] preserve-root-logger-handlers-in-turbo-test --- faststack/tests/test_turbo.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 37e4a66..60dc782 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -115,12 +115,12 @@ def test_setup_logging_keeps_console_handler_when_file_logging_fails(monkeypatch Mock(side_effect=OSError("disk full")), ) - logging_setup.setup_logging(debug=True) - root_logger = logging.getLogger() + original_handlers = list(root_logger.handlers) try: + logging_setup.setup_logging(debug=True) assert any( isinstance(handler, logging.StreamHandler) for handler in root_logger.handlers ) finally: - root_logger.handlers.clear() + root_logger.handlers = original_handlers From a6dd99dc258603286c106094f1a57334ee16e369 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Fri, 20 Mar 2026 19:09:44 +0100 Subject: [PATCH 05/13] test: Windows pytest tempdir hardening (infra only) Force pytest to use a clean basetemp (tmp/pytest-clean) and ignore legacy temp/cache directories to avoid ACL-related PermissionError on Windows. No changes to application code or test logic. --- .gitignore | 4 ++++ pytest.ini | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 pytest.ini diff --git a/.gitignore b/.gitignore index 2b5dea3..5d026c2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ __pycache__/ .ruff_cache/ # Test caches / coverage +.pytest-local/ +.pytest-tmp/ .pytest_cache/ .coverage coverage.xml @@ -51,7 +53,9 @@ dist/ # ---------------------------- # Runtime / generated data # ---------------------------- +tmp/ var/ +var/pytest-temp/ # ---------------------------- # Logs diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5015a97 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --basetemp=tmp/pytest-clean +norecursedirs = tmp var .pytest-local .pytest-tmp .pytest_cache pytest-cache-files-* tmp-pytest-codex From 190e1e3c9542b286d97f6be9ae4aa07ba470960c Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Fri, 20 Mar 2026 19:34:10 +0100 Subject: [PATCH 06/13] pytest-basetemp-clean-checkout-fix --- pytest.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index 5015a97..d715d8d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -addopts = --basetemp=tmp/pytest-clean -norecursedirs = tmp var .pytest-local .pytest-tmp .pytest_cache pytest-cache-files-* tmp-pytest-codex +addopts = --basetemp=pytest-clean +norecursedirs = pytest-clean tmp var .pytest-local .pytest-tmp .pytest_cache pytest-cache-files-* tmp-pytest-codex From fad5da1ec30c6e9ae461dc2b06214d6e13eb14bf Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Fri, 20 Mar 2026 19:45:05 +0100 Subject: [PATCH 07/13] retry-default-turbojpeg-loader-after-bad-env-override --- faststack/imaging/turbo.py | 6 +----- faststack/tests/test_turbo.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py index defd06e..6fc0499 100644 --- a/faststack/imaging/turbo.py +++ b/faststack/imaging/turbo.py @@ -23,8 +23,7 @@ def _candidate_library_paths() -> list[Optional[str]]: explicit = os.getenv("FASTSTACK_TURBOJPEG_LIB") or os.getenv("TURBOJPEG_LIB") if explicit: candidates.append(explicit) - else: - candidates.append(None) + candidates.append(None) if os.name == "nt": common_roots = [ @@ -61,9 +60,6 @@ def _candidate_library_paths() -> list[Optional[str]]: if path_dir: candidates.append(str(Path(path_dir) / "turbojpeg.dll")) - if explicit: - candidates.append(None) - unique: list[Optional[str]] = [] seen: set[str] = set() for candidate in candidates: diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 60dc782..12f0ed5 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -26,6 +26,31 @@ def fake_decoder(path=None): assert calls == ["C:/turbo/bin/turbojpeg.dll"] +def test_create_turbojpeg_retries_default_loader_after_bad_env_override(monkeypatch): + turbo = importlib.import_module("faststack.imaging.turbo") + + calls = [] + + def fake_decoder(path=None): + calls.append(path) + if path == "/bad/turbojpeg.so": + raise RuntimeError("bad override") + if path is None: + return SimpleNamespace(source="default") + raise RuntimeError(f"unexpected path:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setattr(turbo.os, "name", "posix") + monkeypatch.setenv("FASTSTACK_TURBOJPEG_LIB", "/bad/turbojpeg.so") + monkeypatch.delenv("TURBOJPEG_LIB", raising=False) + + decoder, available = turbo.create_turbojpeg() + + assert available is True + assert decoder.source == "default" + assert calls == ["/bad/turbojpeg.so", None] + + def test_create_turbojpeg_logs_failed_candidates(monkeypatch, caplog): turbo = importlib.import_module("faststack.imaging.turbo") From 34d090daf9befce49a0469bb519f24ab21131c47 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 08:36:51 +0100 Subject: [PATCH 08/13] skip-existing-file-appdata-candidates --- faststack/logging_setup.py | 2 ++ faststack/tests/test_turbo.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index 57859b0..a35f8c7 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -21,6 +21,8 @@ def _is_writable_directory(path: Path) -> bool: def _can_create_directory(path: Path) -> bool: """Return True when the target directory can be created without creating it yet.""" + if path.exists(): + return False parent = path.parent while not parent.exists() and parent != parent.parent: parent = parent.parent diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 12f0ed5..009a0e4 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -121,6 +121,26 @@ def test_get_app_data_dir_falls_back_to_tempdir(monkeypatch, tmp_path): assert logging_setup.get_app_data_dir() == temp_dir / "faststack" +def test_get_app_data_dir_skips_existing_file_candidate(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + appdata_root = tmp_path / "appdata" + appdata_root.mkdir() + bad_candidate = appdata_root / "faststack" + bad_candidate.write_text("not a directory", encoding="utf-8") + + home_dir = tmp_path / "home" + home_dir.mkdir() + fallback_dir = home_dir / ".faststack" + + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) + monkeypatch.setenv("APPDATA", str(appdata_root)) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home_dir) + + assert logging_setup.get_app_data_dir() == fallback_dir + + def test_is_writable_directory_does_not_create_missing_dir(tmp_path): logging_setup = importlib.import_module("faststack.logging_setup") From c152607c1297576077e007a8ffda5112093c16f2 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 08:40:21 +0100 Subject: [PATCH 09/13] exercise-rotatingfilehandler-failure-branch --- faststack/tests/test_turbo.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 009a0e4..6174565 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -150,22 +150,35 @@ def test_is_writable_directory_does_not_create_missing_dir(tmp_path): assert missing.exists() is False -def test_setup_logging_keeps_console_handler_when_file_logging_fails(monkeypatch): +def test_setup_logging_keeps_console_handler_when_file_logging_fails(monkeypatch, tmp_path): logging_setup = importlib.import_module("faststack.logging_setup") - monkeypatch.setattr(logging_setup, "get_app_data_dir", lambda: Path("/bad-path")) + monkeypatch.setattr(logging_setup, "get_app_data_dir", lambda: tmp_path / "appdata") + rotating_file_handler = Mock(side_effect=OSError("disk full")) monkeypatch.setattr( logging_setup.logging.handlers, "RotatingFileHandler", - Mock(side_effect=OSError("disk full")), + rotating_file_handler, ) root_logger = logging.getLogger() original_handlers = list(root_logger.handlers) + original_root_level = root_logger.level + cache_logger = logging.getLogger("faststack.imaging.cache") + prefetch_logger = logging.getLogger("faststack.imaging.prefetch") + pil_logger = logging.getLogger("PIL") + original_cache_level = cache_logger.level + original_prefetch_level = prefetch_logger.level + original_pil_level = pil_logger.level try: logging_setup.setup_logging(debug=True) + rotating_file_handler.assert_called_once() assert any( isinstance(handler, logging.StreamHandler) for handler in root_logger.handlers ) finally: root_logger.handlers = original_handlers + root_logger.setLevel(original_root_level) + cache_logger.setLevel(original_cache_level) + prefetch_logger.setLevel(original_prefetch_level) + pil_logger.setLevel(original_pil_level) From 5ab4a42edfc1b38decdddb9e81c19f1c8fad9ca4 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 08:42:07 +0100 Subject: [PATCH 10/13] update-turbojpeg-troubleshooting-log-text --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a446ace..5a8fc7a 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,11 @@ venv\Scripts\python.exe -m faststack.app "C:\path\to\photos" If startup logs mention: ```text -Unable to locate turbojpeg library automatically. -You may specify the turbojpeg library path manually. +TurboJPEG load attempt failed: ... +TurboJPEG initialization failed for all attempted locations. Falling back to Pillow. ``` -that means the Python package is installed but the native `turbojpeg.dll` is not available in a location that `PyTurboJPEG` can find. +that means the Python package is installed but FastStack could not load the native `turbojpeg.dll` from the attempted locations. Fastest fixes: From 56b7106bf430b2f73203dd1209fb9c1a71de276d Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 09:11:49 +0100 Subject: [PATCH 11/13] lazily-resolve-appdata-candidates --- faststack/logging_setup.py | 32 ++++++++++++++++++++------------ faststack/tests/test_turbo.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index a35f8c7..96be84c 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -29,31 +29,39 @@ def _can_create_directory(path: Path) -> bool: return _is_writable_directory(parent) -def get_app_data_dir() -> Path: - """Return a writable application data directory path.""" - candidates = [] - +def _iter_app_data_candidates(): + """Yield app-data candidates in priority order, resolving expensive paths lazily.""" override = os.getenv("FASTSTACK_APPDATA") if override: - candidates.append(Path(override)) + yield Path(override) app_data = os.getenv("APPDATA") if app_data: - candidates.append(Path(app_data) / "faststack") + yield Path(app_data) / "faststack" local_app_data = os.getenv("LOCALAPPDATA") if local_app_data: - candidates.append(Path(local_app_data) / "faststack") + yield Path(local_app_data) / "faststack" - candidates.append(Path.home() / ".faststack") - candidates.append(Path(gettempdir()) / "faststack") - candidates.append(Path.cwd() / "var" / "appdata") + deferred_candidates = ( + lambda: Path.home() / ".faststack", + lambda: Path(gettempdir()) / "faststack", + lambda: Path.cwd() / "var" / "appdata", + ) + for factory in deferred_candidates: + try: + yield factory() + except (OSError, RuntimeError): + continue - for candidate in candidates: + +def get_app_data_dir() -> Path: + """Return a writable application data directory path.""" + for candidate in _iter_app_data_candidates(): if _is_writable_directory(candidate) or _can_create_directory(candidate): return candidate - return Path.cwd() / "var" / "appdata" + return Path(gettempdir()) / "faststack" def setup_logging(debug: bool = False): diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 6174565..f635b4f 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -141,6 +141,21 @@ def test_get_app_data_dir_skips_existing_file_candidate(monkeypatch, tmp_path): assert logging_setup.get_app_data_dir() == fallback_dir +def test_get_app_data_dir_uses_appdata_even_if_home_resolution_fails(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + appdata_root = tmp_path / "appdata" + appdata_root.mkdir() + expected = appdata_root / "faststack" + + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) + monkeypatch.setenv("APPDATA", str(appdata_root)) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: (_ for _ in ()).throw(RuntimeError("no home"))) + + assert logging_setup.get_app_data_dir() == expected + + def test_is_writable_directory_does_not_create_missing_dir(tmp_path): logging_setup = importlib.import_module("faststack.logging_setup") From 0ed99d976ca891bc6dfe1b21035c999c18952ea1 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 09:13:26 +0100 Subject: [PATCH 12/13] clear-faststack-appdata-in-appdata-fallback-test --- faststack/tests/test_turbo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index f635b4f..28ba8df 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -78,6 +78,7 @@ def fake_decoder(path=None): def test_get_app_data_dir_falls_back_when_appdata_is_not_creatable(monkeypatch, tmp_path): logging_setup = importlib.import_module("faststack.logging_setup") + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) home_dir = tmp_path / "home" home_dir.mkdir() fallback_dir = home_dir / ".faststack" From 359d9280375e5bd0590ab36f41af51cac74120ad Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Sat, 21 Mar 2026 09:34:50 +0100 Subject: [PATCH 13/13] prefer-cwd-appdata-before-tempdir --- faststack/logging_setup.py | 6 +++++- faststack/tests/test_turbo.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index 96be84c..74fa0ab 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -45,8 +45,8 @@ def _iter_app_data_candidates(): deferred_candidates = ( lambda: Path.home() / ".faststack", - lambda: Path(gettempdir()) / "faststack", lambda: Path.cwd() / "var" / "appdata", + lambda: Path(gettempdir()) / "faststack", ) for factory in deferred_candidates: try: @@ -61,6 +61,10 @@ def get_app_data_dir() -> Path: if _is_writable_directory(candidate) or _can_create_directory(candidate): return candidate + try: + return Path.cwd() / "var" / "appdata" + except OSError: + pass return Path(gettempdir()) / "faststack" diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 28ba8df..55b9782 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -157,6 +157,23 @@ def test_get_app_data_dir_uses_appdata_even_if_home_resolution_fails(monkeypatch assert logging_setup.get_app_data_dir() == expected +def test_get_app_data_dir_prefers_cwd_before_tempdir(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + cwd_path = tmp_path / "project" + cwd_path.mkdir() + expected = cwd_path / "var" / "appdata" + + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) + monkeypatch.delenv("APPDATA", raising=False) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: (_ for _ in ()).throw(RuntimeError("no home"))) + monkeypatch.setattr(logging_setup, "gettempdir", lambda: str(tmp_path / "tmp")) + monkeypatch.setattr(Path, "cwd", lambda: cwd_path) + + assert logging_setup.get_app_data_dir() == expected + + def test_is_writable_directory_does_not_create_missing_dir(tmp_path): logging_setup = importlib.import_module("faststack.logging_setup")