Source code for zooui.objects.mediaobjects.mediaobjectsutils.svg.svgcache.svgcache

## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.

"""SVG cache management with hashed storage."""

import contextlib
import hashlib
import time
from pathlib import Path

from zooui.logger import get_logger
from zooui.utils._xdg import get_cache_dir


[docs] class SVGCache: """ Manages hashed SVG cache with ``svg_`` prefix and flat directory structure. Hash format: ``svg_{8_char_sha1}`` Directory: XDG cache dir / svg (flat, no subdirectories) """ def __init__(self, cache_root: str | None = None): """ Initialize SVG cache. Args: cache_root: Root directory for cache (default: XDG cache dir / svg) """ if cache_root is None: cache_root = str(get_cache_dir() / "svg") self.cache_root = Path(cache_root) self.cache_root.mkdir(parents=True, exist_ok=True) self._logger = get_logger("SVGCache") self._logger.debug(f"Initialized SVG cache at {self.cache_root}")
[docs] def _compute_hash(self, svg_content: str) -> str: """ Compute 8-character SHA1 hash with ``svg_`` prefix. Args: svg_content: SVG XML content as UTF-8 string Returns: Hash in format: ``svg_{8_char_hex}`` """ content_hash = hashlib.sha1(svg_content.encode("utf-8")).hexdigest()[:8] return f"svg_{content_hash}"
[docs] def store_svg(self, svg_content: str, max_retries: int = 3) -> str: """ Store SVG content in cache with retry logic on write failure. Args: svg_content: SVG XML content as UTF-8 string max_retries: Maximum number of retry attempts (default: 3) Returns: Cache hash (``svg_{8_char_hex}``) Raises: ValueError: If svg_content is empty or invalid Exception: If cache write fails after all retry attempts """ # Validate input if not svg_content or not isinstance(svg_content, str): raise ValueError(f"Invalid SVG content: {type(svg_content)}") if not svg_content.strip(): raise ValueError("Empty SVG content") if not svg_content.startswith("<"): self._logger.warning(f"SVG content doesn't start with '<': {svg_content[:100]}...") original_content = svg_content for attempt in range(max_retries): # Compute hash for current content content_hash = self._compute_hash(svg_content) cache_path = self.cache_root / f"{content_hash}.svg" # Check if already exists (content-based deduplication) if cache_path.exists(): # Verify file is not empty/corrupted try: if cache_path.stat().st_size == 0: self._logger.warning(f"Cache file exists but is empty: {content_hash}, overwriting") else: self._logger.debug(f"Cache hit for hash: {content_hash}") return content_hash except Exception as e: self._logger.warning(f"Error checking cache file {content_hash}: {e}, overwriting") try: # Write to cache cache_path.write_text(svg_content, encoding="utf-8") # Verify write succeeded if not cache_path.exists() or cache_path.stat().st_size == 0: raise OSError("Cache file write failed: file empty or doesn't exist") self._logger.debug(f"Stored SVG to cache: {content_hash} (attempt {attempt + 1})") return content_hash except Exception as e: if attempt < max_retries - 1: # Retry with new hash by modifying content slightly svg_content = original_content + f"<!-- retry_{attempt} -->" self._logger.debug(f"Cache write failed, retrying with new hash: {e}") else: self._logger.error(f"Cache write failed after {max_retries} attempts: {e}") raise # Should never reach here due to raise in loop raise RuntimeError("Cache write failed unexpectedly")
[docs] def get_cache_path(self, content_hash: str) -> Path: """ Get file path for given cache hash. Args: content_hash: Cache hash in format svg_{8_char_hex} Returns: Path to cached SVG file """ return self.cache_root / f"{content_hash}.svg"
[docs] def has_hash(self, content_hash: str) -> bool: """ Check if hash exists in cache. Args: content_hash: Cache hash to check Returns: True if hash exists in cache """ return self.get_cache_path(content_hash).exists()
[docs] def get_svg_content(self, content_hash: str) -> str | None: """ Get SVG content from cache. Args: content_hash: Cache hash to retrieve Returns: SVG content as string, or None if not found """ cache_path = self.get_cache_path(content_hash) if not cache_path.exists(): self._logger.warning(f"Cache miss for hash: {content_hash}") return None try: content = cache_path.read_text(encoding="utf-8") self._logger.debug(f"Retrieved SVG from cache: {content_hash}") return content except Exception as e: self._logger.error(f"Failed to read cache file {content_hash}: {e}") return None
[docs] def cleanup_old_files(self, max_age_days: int = 14) -> tuple[int, int]: """ Remove cache files older than specified days. Args: max_age_days: Maximum age in days (default: 14) Returns: Tuple of (files_removed, bytes_freed) """ cutoff_time = time.time() - (max_age_days * 86400) files_removed = 0 bytes_freed = 0 self._logger.debug(f"Cleaning up cache files older than {max_age_days} days") for file_path in self.cache_root.glob("*.svg"): try: if file_path.stat().st_mtime < cutoff_time: file_size = file_path.stat().st_size file_path.unlink() files_removed += 1 bytes_freed += file_size self._logger.debug(f"Removed old cache file: {file_path.name}") except Exception as e: self._logger.warning(f"Failed to remove cache file {file_path.name}: {e}") # Record cleanup time timestamp_file = self.cache_root / "cleanup_timestamp.txt" try: timestamp_file.write_text(str(time.time())) except Exception as e: self._logger.warning(f"Failed to write cleanup timestamp: {e}") self._logger.info(f"Cache cleanup: removed {files_removed} files, freed {bytes_freed} bytes") return files_removed, bytes_freed
[docs] def cleanup_on_exit(self) -> None: """ Perform cleanup on program exit. Currently removes all cache files. Could be modified to keep recent files if needed. """ self._logger.debug("Performing exit cleanup") files_removed = 0 for file_path in self.cache_root.glob("*.svg"): try: file_path.unlink() files_removed += 1 self._logger.debug(f"Removed cache file on exit: {file_path.name}") except Exception as e: self._logger.warning(f"Failed to remove cache file on exit {file_path.name}: {e}") # Try to remove cleanup timestamp file timestamp_file = self.cache_root / "cleanup_timestamp.txt" if timestamp_file.exists(): with contextlib.suppress(Exception): timestamp_file.unlink() self._logger.info(f"Exit cleanup: removed {files_removed} cache files")
[docs] def get_cache_stats(self) -> dict: """ Get cache statistics. Returns: Dictionary with cache statistics """ svg_files = list(self.cache_root.glob("*.svg")) total_size = sum(f.stat().st_size for f in svg_files if f.exists()) return { "cache_root": str(self.cache_root), "file_count": len(svg_files), "total_size_bytes": total_size, "total_size_mb": total_size / (1024 * 1024), }
# Global cache instance for easy access _svg_cache_instance: SVGCache | None = None
[docs] def get_svg_cache() -> SVGCache: """ Get global SVG cache instance (singleton pattern). Returns: Global SVGCache instance """ global _svg_cache_instance if _svg_cache_instance is None: _svg_cache_instance = SVGCache() return _svg_cache_instance
[docs] def compute_svg_hash(svg_content: str) -> str: """ Compute SVG hash without storing in cache. Args: svg_content: SVG XML content as UTF-8 string Returns: Hash in format: ``svg_{8_char_hex}`` """ return f"svg_{hashlib.sha1(svg_content.encode('utf-8')).hexdigest()[:8]}"