Files
aiturk-hermes-ide/hermes_time.py

160 lines
5.9 KiB
Python

"""
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()