File size: 7,349 Bytes
082393b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | 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)
|