From 8d583f300284c9cb7ef1619d65870b2ae7eeb9d5 Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Tue, 17 Mar 2026 17:40:35 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 fc05bc458bf02a18b3c42918efcd40b8a8da1e5a Mon Sep 17 00:00:00 2001 From: Andy Arijs Date: Fri, 20 Mar 2026 19:45:05 +0100 Subject: [PATCH 5/5] 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")