mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
fix(profile): resolve import-time path globals per-call to honor profile override
In single-process multi-profile runtimes (desktop tui_gateway), profile scoping is a context-local ContextVar override, not a process env var. Three subsystems froze their HERMES_HOME-derived paths at import time (or read os.environ directly), pinning every later profile to whichever profile first imported the module — a cross-profile data leak. - tools/skills_hub.py: SKILLS_DIR/HUB_DIR/LOCK_FILE/etc. were module constants frozen at import. Replace with per-call resolver functions; add a PEP 562 module __getattr__ so external 'from tools.skills_hub import SKILLS_DIR' callers (all function-local) resolve dynamically with no call-site changes. Convert default-arg bindings (HubLockFile/TapsManager) and the derived HERMES_INDEX_CACHE_FILE constant too. - gateway/platforms/base.py: image/audio/video/document cache-dir getters now re-resolve via get_hermes_dir() per call, falling back to the module constant when a test has monkeypatched it (preserves the existing test seam). Media-delivery safe-roots already enumerate all profiles' cache dirs (#31733), so per-profile resolution does not break delivery. - gateway/rich_sent_store.py: _store_path() read os.environ['HERMES_HOME'] directly, bypassing the override entirely; route through get_hermes_home().
This commit is contained in:
parent
7b12753948
commit
10e60060d9
3 changed files with 181 additions and 55 deletions
|
|
@ -565,8 +565,37 @@ async def _ssrf_redirect_guard(response):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/)
|
||||
#
|
||||
# NOTE: These module-level constants are the import-time DEFAULTS. They exist
|
||||
# for two reasons: (1) backward-compatible references elsewhere, and (2) tests
|
||||
# monkeypatch them (e.g. ``monkeypatch.setattr("...IMAGE_CACHE_DIR", tmp)``).
|
||||
# The ``get_*_cache_dir()`` getters below re-resolve through ``get_hermes_dir()``
|
||||
# on every call so the context-local profile override
|
||||
# (``set_hermes_home_override``) is honored — freezing the resolved path at
|
||||
# import pinned every profile to whichever one first imported this module
|
||||
# (cross-profile leak in single-process multi-profile desktop runtime). When a
|
||||
# test has monkeypatched the constant away from its import-time default, that
|
||||
# override wins (preserves the existing test seam).
|
||||
IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")
|
||||
|
||||
|
||||
def _resolve_cache_dir(constant_name: str, new_subpath: str, old_name: str) -> Path:
|
||||
"""Resolve a cache dir, honoring profile override and test monkeypatches.
|
||||
|
||||
Precedence:
|
||||
1. If the module constant ``constant_name`` was monkeypatched away from
|
||||
its import-time default, return the patched value (test seam).
|
||||
2. Otherwise resolve fresh via ``get_hermes_dir`` so the active profile's
|
||||
``set_hermes_home_override`` is reflected per-call.
|
||||
"""
|
||||
fresh = get_hermes_dir(new_subpath, old_name)
|
||||
current = globals().get(constant_name)
|
||||
default = _CACHE_DIR_IMPORT_DEFAULTS.get(constant_name)
|
||||
if current is not None and default is not None and current != default:
|
||||
# A test (or caller) replaced the module constant — respect it.
|
||||
return Path(current)
|
||||
return fresh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound media size cap (#13145)
|
||||
#
|
||||
|
|
@ -660,8 +689,9 @@ async def _read_httpx_body_with_limit(response, *, media_type: str) -> bytes:
|
|||
|
||||
def get_image_cache_dir() -> Path:
|
||||
"""Return the image cache directory, creating it if it doesn't exist."""
|
||||
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return IMAGE_CACHE_DIR
|
||||
d = _resolve_cache_dir("IMAGE_CACHE_DIR", "cache/images", "image_cache")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _looks_like_image(data: bytes) -> bool:
|
||||
|
|
@ -806,8 +836,9 @@ AUDIO_CACHE_DIR = get_hermes_dir("cache/audio", "audio_cache")
|
|||
|
||||
def get_audio_cache_dir() -> Path:
|
||||
"""Return the audio cache directory, creating it if it doesn't exist."""
|
||||
AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return AUDIO_CACHE_DIR
|
||||
d = _resolve_cache_dir("AUDIO_CACHE_DIR", "cache/audio", "audio_cache")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
|
||||
|
|
@ -912,8 +943,9 @@ SUPPORTED_VIDEO_TYPES = {
|
|||
|
||||
def get_video_cache_dir() -> Path:
|
||||
"""Return the video cache directory, creating it if it doesn't exist."""
|
||||
VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return VIDEO_CACHE_DIR
|
||||
d = _resolve_cache_dir("VIDEO_CACHE_DIR", "cache/videos", "video_cache")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
||||
|
|
@ -935,6 +967,19 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
|||
|
||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
|
||||
|
||||
# Import-time defaults for the cache-dir constants. ``_resolve_cache_dir``
|
||||
# compares the live module value against these to detect a test monkeypatch
|
||||
# (in which case the patched value wins) vs. an unmodified constant (in which
|
||||
# case it re-resolves through the active profile override).
|
||||
_CACHE_DIR_IMPORT_DEFAULTS = {
|
||||
"IMAGE_CACHE_DIR": IMAGE_CACHE_DIR,
|
||||
"AUDIO_CACHE_DIR": AUDIO_CACHE_DIR,
|
||||
"VIDEO_CACHE_DIR": VIDEO_CACHE_DIR,
|
||||
"DOCUMENT_CACHE_DIR": DOCUMENT_CACHE_DIR,
|
||||
"SCREENSHOT_CACHE_DIR": SCREENSHOT_CACHE_DIR,
|
||||
}
|
||||
|
||||
_HERMES_HOME = get_hermes_home()
|
||||
_HERMES_ROOT = get_default_hermes_root()
|
||||
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||
|
|
@ -1491,8 +1536,9 @@ def _strip_media_tag_directives(text: str) -> str:
|
|||
|
||||
def get_document_cache_dir() -> Path:
|
||||
"""Return the document cache directory, creating it if it doesn't exist."""
|
||||
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return DOCUMENT_CACHE_DIR
|
||||
d = _resolve_cache_dir("DOCUMENT_CACHE_DIR", "cache/documents", "document_cache")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def cache_document_from_bytes(data: bytes, filename: str) -> str:
|
||||
|
|
|
|||
|
|
@ -25,8 +25,15 @@ _MAX_TEXT_CHARS = 2000
|
|||
|
||||
|
||||
def _store_path() -> str:
|
||||
home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
|
||||
return os.path.join(home, "state", "rich_sent_index.json")
|
||||
# Resolve through get_hermes_home() so the context-local profile override
|
||||
# (set_hermes_home_override) is honored. Reading os.environ["HERMES_HOME"]
|
||||
# directly bypassed the override and leaked every profile's rich-sent index
|
||||
# into the launch/default profile in single-process multi-profile runtimes
|
||||
# (desktop tui_gateway).
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
return os.path.join(str(home), "state", "rich_sent_index.json")
|
||||
|
||||
|
||||
def _key(chat_id, message_id) -> str:
|
||||
|
|
|
|||
|
|
@ -46,18 +46,78 @@ logger = logging.getLogger(__name__)
|
|||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These directories MUST resolve through ``get_hermes_home()`` on every access
|
||||
# so the context-local profile override (``set_hermes_home_override``) is
|
||||
# honored. Freezing them as module-level constants at import time pinned every
|
||||
# later profile to whichever profile first imported this module — a cross-
|
||||
# profile data leak in single-process multi-profile runtimes (desktop
|
||||
# ``tui_gateway``). See the profile-isolation fix.
|
||||
#
|
||||
# Backward compatibility: external callers do ``from tools.skills_hub import
|
||||
# SKILLS_DIR`` (always inside a function body, re-evaluated per call). The
|
||||
# module-level ``__getattr__`` below makes those names resolve dynamically, so
|
||||
# no external call site needs to change.
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
SKILLS_DIR = HERMES_HOME / "skills"
|
||||
HUB_DIR = SKILLS_DIR / ".hub"
|
||||
LOCK_FILE = HUB_DIR / "lock.json"
|
||||
QUARANTINE_DIR = HUB_DIR / "quarantine"
|
||||
AUDIT_LOG = HUB_DIR / "audit.log"
|
||||
TAPS_FILE = HUB_DIR / "taps.json"
|
||||
INDEX_CACHE_DIR = HUB_DIR / "index-cache"
|
||||
INDEX_CACHE_TTL = 3600 # 1 hour (defined early; referenced below)
|
||||
|
||||
# Cache duration for remote index fetches
|
||||
INDEX_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
return get_hermes_home()
|
||||
|
||||
|
||||
def _skills_dir() -> Path:
|
||||
return _hermes_home() / "skills"
|
||||
|
||||
|
||||
def _hub_dir() -> Path:
|
||||
return _skills_dir() / ".hub"
|
||||
|
||||
|
||||
def _lock_file() -> Path:
|
||||
return _hub_dir() / "lock.json"
|
||||
|
||||
|
||||
def _quarantine_dir() -> Path:
|
||||
return _hub_dir() / "quarantine"
|
||||
|
||||
|
||||
def _audit_log() -> Path:
|
||||
return _hub_dir() / "audit.log"
|
||||
|
||||
|
||||
def _taps_file() -> Path:
|
||||
return _hub_dir() / "taps.json"
|
||||
|
||||
|
||||
def _index_cache_dir() -> Path:
|
||||
return _hub_dir() / "index-cache"
|
||||
|
||||
|
||||
_DYNAMIC_PATH_RESOLVERS = {
|
||||
"HERMES_HOME": _hermes_home,
|
||||
"SKILLS_DIR": _skills_dir,
|
||||
"HUB_DIR": _hub_dir,
|
||||
"LOCK_FILE": _lock_file,
|
||||
"QUARANTINE_DIR": _quarantine_dir,
|
||||
"AUDIT_LOG": _audit_log,
|
||||
"TAPS_FILE": _taps_file,
|
||||
"INDEX_CACHE_DIR": _index_cache_dir,
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Resolve the legacy path constants dynamically per access.
|
||||
|
||||
PEP 562 module ``__getattr__``: only called for names NOT found as real
|
||||
module attributes, so it does not slow down ordinary lookups. This lets
|
||||
``tools.skills_hub.SKILLS_DIR`` (and the rest) reflect the active profile
|
||||
override instead of an import-time snapshot.
|
||||
"""
|
||||
resolver = _DYNAMIC_PATH_RESOLVERS.get(name)
|
||||
if resolver is not None:
|
||||
return resolver()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||||
_MAX_SKILL_FETCH_REDIRECTS = 5
|
||||
|
|
@ -176,9 +236,10 @@ def _resolve_lock_install_path(install_path: str, skill_name: str) -> Path:
|
|||
and ``rmtree(SKILLS_DIR)`` would wipe every installed skill.
|
||||
"""
|
||||
normalized = _normalize_lock_install_path(install_path, skill_name)
|
||||
skills_root = SKILLS_DIR.resolve()
|
||||
skills_dir = _skills_dir()
|
||||
skills_root = skills_dir.resolve()
|
||||
|
||||
target = SKILLS_DIR
|
||||
target = skills_dir
|
||||
for part in normalized.split("/"):
|
||||
target = target / part
|
||||
if _is_path_redirect(target):
|
||||
|
|
@ -973,7 +1034,7 @@ class GitHubSource(SkillSource):
|
|||
|
||||
def _read_cache(self, key: str) -> Optional[list]:
|
||||
"""Read cached index if not expired."""
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
cache_file = _index_cache_dir() / f"{key}.json"
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
try:
|
||||
|
|
@ -986,8 +1047,9 @@ class GitHubSource(SkillSource):
|
|||
|
||||
def _write_cache(self, key: str, data: list) -> None:
|
||||
"""Write index data to cache."""
|
||||
INDEX_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
index_cache_dir = _index_cache_dir()
|
||||
index_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = index_cache_dir / f"{key}.json"
|
||||
try:
|
||||
cache_file.write_text(json.dumps(data, ensure_ascii=False))
|
||||
except OSError as e:
|
||||
|
|
@ -3157,7 +3219,7 @@ class OptionalSkillSource(SkillSource):
|
|||
|
||||
def _read_index_cache(key: str) -> Optional[Any]:
|
||||
"""Read cached data if not expired."""
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
cache_file = _index_cache_dir() / f"{key}.json"
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
try:
|
||||
|
|
@ -3171,17 +3233,18 @@ def _read_index_cache(key: str) -> Optional[Any]:
|
|||
|
||||
def _write_index_cache(key: str, data: Any) -> None:
|
||||
"""Write data to cache."""
|
||||
INDEX_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
index_cache_dir = _index_cache_dir()
|
||||
index_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Ensure .ignore exists so ripgrep (and tools respecting .ignore) skip
|
||||
# this directory. Cache files contain unvetted community content that
|
||||
# could include adversarial text (prompt injection via catalog entries).
|
||||
ignore_file = HUB_DIR / ".ignore"
|
||||
ignore_file = _hub_dir() / ".ignore"
|
||||
if not ignore_file.exists():
|
||||
try:
|
||||
ignore_file.write_text("# Exclude hub internals from search tools\n*\n")
|
||||
except OSError:
|
||||
pass
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
cache_file = index_cache_dir / f"{key}.json"
|
||||
try:
|
||||
cache_file.write_text(json.dumps(data, ensure_ascii=False, default=str))
|
||||
except OSError as e:
|
||||
|
|
@ -3210,8 +3273,8 @@ def _skill_meta_to_dict(meta: SkillMeta) -> dict:
|
|||
class HubLockFile:
|
||||
"""Manages skills/.hub/lock.json — tracks provenance of installed hub skills."""
|
||||
|
||||
def __init__(self, path: Path = LOCK_FILE):
|
||||
self.path = path
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
self.path = path if path is not None else _lock_file()
|
||||
|
||||
def load(self) -> dict:
|
||||
if not self.path.exists():
|
||||
|
|
@ -3282,8 +3345,8 @@ class HubLockFile:
|
|||
class TapsManager:
|
||||
"""Manages the taps.json file — custom GitHub repo sources."""
|
||||
|
||||
def __init__(self, path: Path = TAPS_FILE):
|
||||
self.path = path
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
self.path = path if path is not None else _taps_file()
|
||||
|
||||
def load(self) -> List[dict]:
|
||||
if not self.path.exists():
|
||||
|
|
@ -3327,14 +3390,15 @@ class TapsManager:
|
|||
def append_audit_log(action: str, skill_name: str, source: str,
|
||||
trust_level: str, verdict: str, extra: str = "") -> None:
|
||||
"""Append a line to the audit log."""
|
||||
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
audit_log = _audit_log()
|
||||
audit_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
parts = [timestamp, action, skill_name, f"{source}:{trust_level}", verdict]
|
||||
if extra:
|
||||
parts.append(extra)
|
||||
line = " ".join(parts) + "\n"
|
||||
try:
|
||||
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
|
||||
with open(audit_log, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except OSError as e:
|
||||
logger.debug("Could not write audit log: %s", e)
|
||||
|
|
@ -3346,15 +3410,19 @@ def append_audit_log(action: str, skill_name: str, source: str,
|
|||
|
||||
def ensure_hub_dirs() -> None:
|
||||
"""Create the .hub directory structure if it doesn't exist."""
|
||||
HUB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
QUARANTINE_DIR.mkdir(exist_ok=True)
|
||||
INDEX_CACHE_DIR.mkdir(exist_ok=True)
|
||||
if not LOCK_FILE.exists():
|
||||
LOCK_FILE.write_text('{"version": 1, "installed": {}}\n')
|
||||
if not AUDIT_LOG.exists():
|
||||
AUDIT_LOG.touch()
|
||||
if not TAPS_FILE.exists():
|
||||
TAPS_FILE.write_text('{"taps": []}\n')
|
||||
hub_dir = _hub_dir()
|
||||
lock_file = _lock_file()
|
||||
audit_log = _audit_log()
|
||||
taps_file = _taps_file()
|
||||
hub_dir.mkdir(parents=True, exist_ok=True)
|
||||
_quarantine_dir().mkdir(exist_ok=True)
|
||||
_index_cache_dir().mkdir(exist_ok=True)
|
||||
if not lock_file.exists():
|
||||
lock_file.write_text('{"version": 1, "installed": {}}\n')
|
||||
if not audit_log.exists():
|
||||
audit_log.touch()
|
||||
if not taps_file.exists():
|
||||
taps_file.write_text('{"taps": []}\n')
|
||||
|
||||
|
||||
def quarantine_bundle(bundle: SkillBundle) -> Path:
|
||||
|
|
@ -3366,7 +3434,7 @@ def quarantine_bundle(bundle: SkillBundle) -> Path:
|
|||
safe_rel_path = _validate_bundle_rel_path(rel_path)
|
||||
validated_files.append((safe_rel_path, file_content))
|
||||
|
||||
dest = QUARANTINE_DIR / skill_name
|
||||
dest = _quarantine_dir() / skill_name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True)
|
||||
|
|
@ -3393,7 +3461,7 @@ def install_from_quarantine(
|
|||
safe_skill_name = _validate_skill_name(skill_name)
|
||||
safe_category = _validate_install_parent_path(category) if category else ""
|
||||
quarantine_resolved = quarantine_path.resolve()
|
||||
quarantine_root = QUARANTINE_DIR.resolve()
|
||||
quarantine_root = _quarantine_dir().resolve()
|
||||
if not quarantine_resolved.is_relative_to(quarantine_root):
|
||||
raise ValueError(f"Unsafe quarantine path: {quarantine_path}")
|
||||
|
||||
|
|
@ -3453,7 +3521,7 @@ def install_from_quarantine(
|
|||
trust_level=bundle.trust_level,
|
||||
scan_verdict=scan_result.verdict,
|
||||
skill_hash=content_hash(install_dir),
|
||||
install_path=str(install_dir.relative_to(SKILLS_DIR)),
|
||||
install_path=str(install_dir.relative_to(_skills_dir())),
|
||||
files=list(bundle.files.keys()),
|
||||
metadata=bundle.metadata,
|
||||
)
|
||||
|
|
@ -3582,10 +3650,13 @@ def check_for_skill_updates(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
HERMES_INDEX_URL = "https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
|
||||
HERMES_INDEX_CACHE_FILE = INDEX_CACHE_DIR / "hermes-index.json"
|
||||
HERMES_INDEX_TTL = 6 * 3600 # 6 hours
|
||||
|
||||
|
||||
def _hermes_index_cache_file() -> Path:
|
||||
return _index_cache_dir() / "hermes-index.json"
|
||||
|
||||
|
||||
def _load_hermes_index() -> Optional[dict]:
|
||||
"""Fetch the centralized skills index, with local cache.
|
||||
|
||||
|
|
@ -3594,11 +3665,12 @@ def _load_hermes_index() -> Optional[dict]:
|
|||
downloads within a session.
|
||||
"""
|
||||
# Check local cache
|
||||
if HERMES_INDEX_CACHE_FILE.exists():
|
||||
hermes_index_cache_file = _hermes_index_cache_file()
|
||||
if hermes_index_cache_file.exists():
|
||||
try:
|
||||
age = time.time() - HERMES_INDEX_CACHE_FILE.stat().st_mtime
|
||||
age = time.time() - hermes_index_cache_file.stat().st_mtime
|
||||
if age < HERMES_INDEX_TTL:
|
||||
return json.loads(HERMES_INDEX_CACHE_FILE.read_text())
|
||||
return json.loads(hermes_index_cache_file.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
|
|
@ -3619,8 +3691,8 @@ def _load_hermes_index() -> Optional[dict]:
|
|||
|
||||
# Cache locally
|
||||
try:
|
||||
HERMES_INDEX_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HERMES_INDEX_CACHE_FILE.write_text(json.dumps(data))
|
||||
hermes_index_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
hermes_index_cache_file.write_text(json.dumps(data))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
|
@ -3629,9 +3701,10 @@ def _load_hermes_index() -> Optional[dict]:
|
|||
|
||||
def _load_stale_index_cache() -> Optional[dict]:
|
||||
"""Fall back to stale cache when the network fetch fails."""
|
||||
if HERMES_INDEX_CACHE_FILE.exists():
|
||||
hermes_index_cache_file = _hermes_index_cache_file()
|
||||
if hermes_index_cache_file.exists():
|
||||
try:
|
||||
return json.loads(HERMES_INDEX_CACHE_FILE.read_text())
|
||||
return json.loads(hermes_index_cache_file.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue