Initial commit
This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user