230 lines
8.1 KiB
Python
230 lines
8.1 KiB
Python
"""Part image lookup with on-disk cache.
|
|
|
|
Lookup order, per part:
|
|
1. SMB share (if `smb_image_dir` is configured) — looks for `{SKU}.{ext}`
|
|
across common extensions.
|
|
2. On-disk cache keyed by SKU.
|
|
3. The CSV's Image URL (downloaded, then cached).
|
|
4. Bundled placeholder image.
|
|
|
|
All network work happens off the UI thread. The UI passes a callback to
|
|
:meth:`ImageCache.load_async` and receives the rendered Pillow image when
|
|
ready.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
import requests
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_SMB_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
|
|
_SAFE = re.compile(r"[^A-Za-z0-9._-]")
|
|
|
|
|
|
def _safe_filename(sku: str, suffix: str) -> str:
|
|
"""Make a filesystem-safe filename from a SKU."""
|
|
clean = _SAFE.sub("_", sku.strip())
|
|
if not clean:
|
|
clean = hashlib.sha1(sku.encode()).hexdigest()[:12]
|
|
return f"{clean}{suffix}"
|
|
|
|
|
|
def _bundle_path(relpath: str) -> Path:
|
|
"""Resolve a path relative to either the source tree or the PyInstaller bundle."""
|
|
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
|
return base / relpath
|
|
|
|
|
|
class ImageCache:
|
|
def __init__(self, cache_dir: str, smb_dir: str = "", placeholder_size: tuple[int, int] = (320, 240)):
|
|
self.cache_dir = Path(cache_dir) if cache_dir else None
|
|
self.smb_dir = Path(smb_dir) if smb_dir else None
|
|
self.placeholder_size = placeholder_size
|
|
|
|
if self.cache_dir:
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
self._placeholder: Image.Image | None = None
|
|
# SKU -> list of pending callbacks. When a fetch is in progress for a
|
|
# SKU, additional load_async calls for the same SKU just queue their
|
|
# callback here instead of starting a duplicate fetch. All queued
|
|
# callbacks fire with the same final image when the worker completes.
|
|
self._inflight: dict[str, list] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
# ---------- placeholder ----------
|
|
|
|
def placeholder(self) -> Image.Image:
|
|
if self._placeholder is not None:
|
|
return self._placeholder.copy()
|
|
|
|
# Try bundled asset first.
|
|
bundled = _bundle_path("assets/placeholder.png")
|
|
if bundled.exists():
|
|
self._placeholder = Image.open(bundled).convert("RGBA")
|
|
else:
|
|
# Draw one on the fly so the app never crashes for lack of an asset.
|
|
w, h = self.placeholder_size
|
|
img = Image.new("RGBA", (w, h), (235, 235, 240, 255))
|
|
draw = ImageDraw.Draw(img)
|
|
try:
|
|
font = ImageFont.truetype("arial.ttf", 22)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
text = "No image"
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
draw.text(((w - tw) / 2, (h - th) / 2), text, fill=(120, 120, 130), font=font)
|
|
self._placeholder = img
|
|
return self._placeholder.copy()
|
|
|
|
# ---------- lookup paths ----------
|
|
|
|
def _smb_path_for(self, sku: str) -> Path | None:
|
|
if not self.smb_dir:
|
|
return None
|
|
for ext in _SMB_EXTS:
|
|
p = self.smb_dir / _safe_filename(sku, ext)
|
|
if p.exists():
|
|
return p
|
|
return None
|
|
|
|
def _cache_path_for(self, sku: str, suffix: str = ".png") -> Path | None:
|
|
if not self.cache_dir:
|
|
return None
|
|
return self.cache_dir / _safe_filename(sku, suffix)
|
|
|
|
# ---------- synchronous load (used internally + for tests) ----------
|
|
|
|
def load(self, sku: str, url: str | None) -> Image.Image:
|
|
# 1. SMB.
|
|
smb = self._smb_path_for(sku)
|
|
if smb is not None:
|
|
try:
|
|
return Image.open(smb).convert("RGBA")
|
|
except Exception as e:
|
|
log.warning("SMB image failed for %s: %s", sku, e)
|
|
|
|
# 2. Disk cache.
|
|
if self.cache_dir:
|
|
for ext in _SMB_EXTS:
|
|
p = self.cache_dir / _safe_filename(sku, ext)
|
|
if p.exists():
|
|
try:
|
|
return Image.open(p).convert("RGBA")
|
|
except Exception as e:
|
|
log.warning("Cached image failed for %s: %s", sku, e)
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
# 3. URL.
|
|
if url:
|
|
try:
|
|
resp = requests.get(url, timeout=8, stream=True)
|
|
resp.raise_for_status()
|
|
# Pick extension from URL if we can; default to .png.
|
|
lower = url.lower().split("?", 1)[0]
|
|
suffix = ".png"
|
|
for ext in _SMB_EXTS:
|
|
if lower.endswith(ext):
|
|
suffix = ext
|
|
break
|
|
|
|
if self.cache_dir:
|
|
dest = self.cache_dir / _safe_filename(sku, suffix)
|
|
with open(dest, "wb") as f:
|
|
for chunk in resp.iter_content(8192):
|
|
f.write(chunk)
|
|
return Image.open(dest).convert("RGBA")
|
|
|
|
# No cache dir — load straight from bytes.
|
|
from io import BytesIO
|
|
|
|
return Image.open(BytesIO(resp.content)).convert("RGBA")
|
|
except Exception as e:
|
|
log.warning("Image download failed for %s (%s): %s", sku, url, e)
|
|
|
|
# 4. Fallback.
|
|
return self.placeholder()
|
|
|
|
# ---------- async load ----------
|
|
|
|
def load_async(
|
|
self,
|
|
sku: str,
|
|
url: str | None,
|
|
callback: Callable[[str, Image.Image], None],
|
|
) -> None:
|
|
"""Fetch the image on a worker thread; call `callback(sku, image)` when done.
|
|
|
|
The callback runs on the worker thread, so the UI layer should marshal
|
|
it back to the Tk thread (we use Tk's `.after(0, ...)` for that).
|
|
"""
|
|
# Quick path: if it's already on disk, return synchronously to avoid a
|
|
# visible flash of the placeholder.
|
|
cached = self._fast_local(sku)
|
|
if cached is not None:
|
|
callback(sku, cached)
|
|
return
|
|
|
|
# De-duplicate concurrent fetches: if another caller is already
|
|
# fetching this SKU, just queue our callback to fire alongside theirs.
|
|
# Without this, the second caller's callback would be silently dropped
|
|
# and the UI would stay stuck on "Loading image..." because each
|
|
# _show_part call captures its own stale-detection token.
|
|
with self._lock:
|
|
if sku in self._inflight:
|
|
self._inflight[sku].append(callback)
|
|
return
|
|
self._inflight[sku] = [callback]
|
|
|
|
def worker() -> None:
|
|
try:
|
|
img = self.load(sku, url)
|
|
except Exception as e:
|
|
log.warning("Image fetch failed for %s: %s", sku, e)
|
|
img = self.placeholder()
|
|
# Snapshot and clear the callback list under the lock, then fire
|
|
# them outside the lock so a slow callback can't block other SKUs.
|
|
with self._lock:
|
|
callbacks = self._inflight.pop(sku, [])
|
|
for cb in callbacks:
|
|
try:
|
|
cb(sku, img)
|
|
except Exception:
|
|
log.exception("Image callback raised for %s", sku)
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _fast_local(self, sku: str) -> Image.Image | None:
|
|
# SMB.
|
|
smb = self._smb_path_for(sku)
|
|
if smb is not None:
|
|
try:
|
|
return Image.open(smb).convert("RGBA")
|
|
except Exception:
|
|
pass
|
|
# Cache.
|
|
if self.cache_dir:
|
|
for ext in _SMB_EXTS:
|
|
p = self.cache_dir / _safe_filename(sku, ext)
|
|
if p.exists():
|
|
try:
|
|
return Image.open(p).convert("RGBA")
|
|
except Exception:
|
|
pass
|
|
return None
|