| from __future__ import annotations |
|
|
| import logging |
| import os |
| import sys |
| import tempfile |
| from pathlib import Path |
| from typing import Any |
|
|
| from PIL import Image |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _DEFAULT_STORAGE_PATH = "granite-cache" |
|
|
| _SUBDIRS = ("tmp", "sessions", "cache", "uploads", "models") |
|
|
| _MODEL_CACHE_ENV_VARS = { |
| "HF_HOME": "models/huggingface", |
| "DOCLING_CACHE_DIR": "models/docling", |
| "TORCH_HOME": "models/torch", |
| "EASYOCR_MODULE_PATH": "models/easyocr", |
| } |
|
|
|
|
| def get_storage_path(sub_path: str = "") -> Path: |
| base = os.environ.get("STORAGE_PATH", "").strip() |
| root = Path(base).resolve() if base else Path(tempfile.gettempdir()) |
| return root / sub_path if sub_path else root |
|
|
|
|
| def get_temp_dir() -> Path: |
| return get_storage_path("tmp") |
|
|
|
|
| def init_storage() -> None: |
| base = os.environ.get("STORAGE_PATH", "").strip() |
| if base: |
| root = Path(base).resolve() |
| logger.info("STORAGE_PATH set: %s", root) |
| else: |
| root = Path(tempfile.gettempdir()) |
| logger.info("STORAGE_PATH not set — using system temp dir: %s", root) |
|
|
| for sub in _SUBDIRS: |
| d = root / sub |
| try: |
| d.mkdir(parents=True, exist_ok=True) |
| except OSError as e: |
| logger.error("Cannot create storage dir %s: %s", d, e) |
| sys.exit(1) |
|
|
| if base: |
| for env_var, sub_path in _MODEL_CACHE_ENV_VARS.items(): |
| if env_var not in os.environ: |
| cache_dir = root / sub_path |
| cache_dir.mkdir(parents=True, exist_ok=True) |
| os.environ[env_var] = str(cache_dir) |
| logger.info("Set %s=%s", env_var, cache_dir) |
|
|
| probe = root / "tmp" / ".write_test" |
| try: |
| probe.touch() |
| probe.unlink() |
| except OSError as e: |
| logger.error("Storage path %s is not writable: %s", root, e) |
| sys.exit(1) |
|
|
| logger.info("Storage ready: %s (subdirs: %s)", root, ", ".join(_SUBDIRS)) |
|
|
|
|
| def get_sessions_path() -> Path: |
| return get_storage_path("sessions") |
|
|
|
|
| def parse_cache_per_session() -> bool: |
| """Return True when Docling parse results should be stored per-session. |
| |
| Controlled by ``PARSE_CACHE_PER_SESSION`` env var (default ``"true"``). |
| Only meaningful in disk mode. |
| """ |
| return os.environ.get("PARSE_CACHE_PER_SESSION", "true").strip().lower() != "false" |
|
|
|
|
| def use_disk_images() -> bool: |
| """Return True when bulky session images should be externalized to disk. |
| |
| Controlled by ``SESSION_IMAGES`` env var: ``"disk"`` (default) saves |
| PIL Images to disk and stores LazyImage proxies in session state; |
| ``"memory"`` keeps real PIL Images in-memory (original behaviour). |
| """ |
| return os.environ.get("SESSION_IMAGES", "disk").strip().lower() != "memory" |
|
|
|
|
| class LazyImage: |
| """PIL Image proxy that loads from disk on every access. |
| |
| Stored in figure dict ``"image"`` slots after upload processing. |
| Any PIL attribute access (e.g. ``.convert()``, ``.save()``, ``.size``) |
| reads the PNG from disk each time so no PIL data stays in memory. |
| """ |
|
|
| __slots__ = ("_path",) |
|
|
| def __init__(self, path: Path) -> None: |
| object.__setattr__(self, "_path", path) |
|
|
| def _load(self) -> Image.Image: |
| path = object.__getattribute__(self, "_path") |
| return Image.open(path).copy() |
|
|
| @property |
| def disk_path(self) -> Path: |
| return object.__getattribute__(self, "_path") |
|
|
| def __getattr__(self, name: str) -> Any: |
| return getattr(self._load(), name) |
|
|
| def __repr__(self) -> str: |
| path = object.__getattribute__(self, "_path") |
| return f"<LazyImage {path.name}>" |
|
|
|
|
| def save_image(session_id: str, category: str, index: int, img: Image.Image) -> LazyImage | Image.Image: |
| """Save a PIL Image to disk and return a LazyImage proxy, or pass through. |
| |
| When ``SESSION_STORAGE=memory``, returns *img* unchanged (original behaviour). |
| Otherwise saves to ``STORAGE_PATH/sessions/<session_id>/<category>/`` and |
| returns a :class:`LazyImage`. |
| """ |
| if not use_disk_images(): |
| return img |
| cat_dir = get_sessions_path() / session_id / category |
| cat_dir.mkdir(parents=True, exist_ok=True) |
| path = cat_dir / f"{index:04d}.png" |
| img.save(path, format="PNG") |
| return LazyImage(path) |
|
|
|
|
| def resolve_for_gradio(img: "LazyImage | Image.Image | None") -> "Path | Image.Image | None": |
| """Return a value Gradio's gr.Image component can postprocess. |
| |
| LazyImage is an internal proxy unknown to Gradio; hand it the disk path |
| instead (Gradio accepts str/Path for image files). PIL Images and None |
| pass through unchanged. |
| """ |
| if isinstance(img, LazyImage): |
| return img.disk_path |
| return img |
|
|
|
|
| def _parse_cache_dir(file_hash: str, session_id: str | None) -> Path: |
| if session_id and parse_cache_per_session(): |
| return get_sessions_path() / session_id |
| return get_storage_path("cache") / file_hash |
|
|
|
|
| def load_parse_cache(file_hash: str, session_id: str | None = None) -> dict | None: |
| """Load a previously persisted Docling parse result from disk. |
| |
| Returns a dict with ``html``, ``text``, and an empty ``figures`` list, |
| or ``None`` if no cached result exists or disk mode is off. |
| """ |
| if not use_disk_images(): |
| return None |
| import json |
| path = _parse_cache_dir(file_hash, session_id) / "result.json" |
| if not path.exists(): |
| return None |
| data = json.loads(path.read_text()) |
| data["figures"] = [] |
| return data |
|
|
|
|
| def save_parse_cache(file_hash: str, result: dict, session_id: str | None = None) -> None: |
| """Persist the html and text from a Docling parse result to disk. |
| |
| No-op when disk mode is off. Figures (PIL Images) are excluded. |
| """ |
| if not use_disk_images(): |
| return |
| import json |
| cache_dir = _parse_cache_dir(file_hash, session_id) |
| cache_dir.mkdir(parents=True, exist_ok=True) |
| payload = {"html": result.get("html", ""), "text": result.get("text", "")} |
| (cache_dir / "result.json").write_text(json.dumps(payload)) |
|
|
|
|
| def save_upload(session_id: str, data: bytes, suffix: str) -> Path: |
| """Save the original uploaded file into the session directory. |
| |
| Returns the path to the saved file. When ``SESSION_IMAGES=memory``, |
| returns ``None`` (caller should fall back to a temp file). |
| """ |
| if not use_disk_images(): |
| return None |
| session_dir = get_sessions_path() / session_id |
| session_dir.mkdir(parents=True, exist_ok=True) |
| path = session_dir / f"upload{suffix}" |
| path.write_bytes(data) |
| return path |
|
|
|
|
| def v1_mode() -> bool: |
| """Return True when browser storage mode is enabled (v1 API).""" |
| return os.environ.get("BROWSER_MEMORY_MODE", "").strip().lower() in ("1", "true", "yes") |
|
|
|
|
| def debug_keep_files() -> bool: |
| """Return True when intermediate files should be preserved for debugging.""" |
| return os.environ.get("DEBUG_KEEP_FILES", "").strip().lower() in ("1", "true", "yes") |
|
|
|
|
| def cleanup_session_files(session_id: str) -> None: |
| """Remove all files for a session from disk. |
| |
| No-op when DEBUG_KEEP_FILES is set or disk mode is off. |
| """ |
| if debug_keep_files() or not use_disk_images(): |
| return |
| import shutil |
|
|
| session_dir = get_sessions_path() / session_id |
| if session_dir.exists(): |
| shutil.rmtree(session_dir, ignore_errors=True) |
|
|