""" Timezone-aware clock for Hermes. Provides a single ``now()`` helper that returns a timezone-aware datetime based on the user's configured IANA timezone (e.g. ``Asia/Kolkata``). Resolution order: 1. ``HERMES_TIMEZONE`` environment variable 2. ``timezone`` key in ``~/.hermes/config.yaml`` 3. Falls back to the server's local time (``datetime.now().astimezone()``) Invalid timezone values log a warning and fall back safely — Hermes never crashes due to a bad timezone string. """ import logging import os import threading from datetime import datetime from hermes_constants import get_config_path from typing import Dict, Optional, Tuple logger = logging.getLogger(__name__) try: from zoneinfo import ZoneInfo except ImportError: # Python 3.8 fallback (shouldn't be needed — Hermes requires 3.9+) from backports.zoneinfo import ZoneInfo # type: ignore[no-redef] # Cached state, keyed to the active timezone source. This process can multiplex # profiles by switching HERMES_HOME (context override or env), so a single # unkeyed process-global value would leak the first profile's timezone into # later profile-scoped work (e.g. the desktop multiplex cron ticker persisting # another profile's ``next_run_at`` under the backend's own timezone). # # Entries are published atomically under ``_cache_lock`` as one # ``identity -> (name, ZoneInfo | None)`` mapping, so two profile-scoped # threads racing through resolution can never publish a mixed # identity/value pair. Each profile's resolved zone stays hot across # multiplex switches. Call reset_cache() after in-place config changes. _cache_lock = threading.Lock() _tz_cache: Dict[Tuple[str, str], Tuple[str, Optional[ZoneInfo]]] = {} def _timezone_cache_identity() -> Tuple[str, str]: """Return the active source identity for the timezone cache.""" tz_env = os.getenv("HERMES_TIMEZONE", "").strip() if tz_env: return ("environment", tz_env) return ("config", str(get_config_path())) def _resolve_timezone_name() -> str: """Read the configured IANA timezone string (or empty string). This does file I/O when falling through to config.yaml, so callers should cache the result rather than calling on every ``now()``. """ # 1. Environment variable (highest priority — set by Supervisor, etc.) tz_env = os.getenv("HERMES_TIMEZONE", "").strip() if tz_env: return tz_env # 2. config.yaml ``timezone`` key try: # Prefer the shared cached raw-config reader (mtime/size-keyed cache + # libyaml C loader) — a direct yaml.safe_load of a large config.yaml # costs ~100ms+ and this used to run inside the FIRST system prompt # build, on the time-to-first-token critical path. try: from hermes_cli.config import read_raw_config cfg = read_raw_config() or {} except Exception: import yaml config_path = get_config_path() if config_path.exists(): with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} else: cfg = {} if cfg: # Managed scope: an administrator can pin ``timezone`` too. Overlay # via the shared helper (fail-open) since this reads config.yaml directly. try: from hermes_cli import managed_scope cfg = managed_scope.apply_managed_overlay(cfg) except Exception: pass tz_cfg = cfg.get("timezone", "") if isinstance(tz_cfg, str) and tz_cfg.strip(): return tz_cfg.strip() except Exception: pass return "" def _get_zoneinfo(name: str) -> Optional[ZoneInfo]: """Validate and return a ZoneInfo, or None if invalid.""" if not name: return None try: return ZoneInfo(name) except (KeyError, Exception) as exc: logger.warning( "Invalid timezone '%s': %s. Falling back to server local time.", name, exc, ) return None def get_timezone() -> Optional[ZoneInfo]: """Return the active profile's configured ZoneInfo, or None (server-local). The cache is isolated by the active timezone source — the explicit ``HERMES_TIMEZONE`` override or the active profile's config path — so a process that multiplexes profiles (desktop cron ticker, multiplex gateway) never reuses another profile's timezone. Call ``reset_cache()`` after editing the active config in place. """ cache_identity = _timezone_cache_identity() with _cache_lock: entry = _tz_cache.get(cache_identity) if entry is not None: return entry[1] # Resolve outside the lock (config file I/O); publish atomically below. name = _resolve_timezone_name() tz = _get_zoneinfo(name) with _cache_lock: # First writer wins so concurrent resolvers of the SAME identity # converge on one ZoneInfo object; a different identity's write can # never be mixed into this one — the (name, tz) pair is one value. return _tz_cache.setdefault(cache_identity, (name, tz))[1] def reset_cache() -> None: """Clear the cached timezone so the next call re-resolves it. Call this after the configured timezone may have changed (e.g. after a config edit or ``HERMES_TIMEZONE`` update) to force ``get_timezone()`` / ``now()`` to read the new value instead of the value cached at first use. """ with _cache_lock: _tz_cache.clear() def now() -> datetime: """ Return the current time as a timezone-aware datetime. If a valid timezone is configured, returns wall-clock time in that zone. Otherwise returns the server's local time (via ``astimezone()``). """ tz = get_timezone() if tz is not None: return datetime.now(tz) # No timezone configured — use server-local (still tz-aware) return datetime.now().astimezone()