Deploybot
Deploy from stable branch
082393b
Raw
History Blame Contribute Delete
7.35 kB
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)