| """ |
| 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 |
| from datetime import datetime, timezone as _tz |
| from pathlib import Path |
| from typing import Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
| try: |
| from zoneinfo import ZoneInfo |
| except ImportError: |
| |
| from backports.zoneinfo import ZoneInfo |
|
|
| |
| |
| _cached_tz: Optional[ZoneInfo] = None |
| _cached_tz_name: Optional[str] = None |
| _cache_resolved: bool = False |
|
|
|
|
| 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()``. |
| """ |
| |
| tz_env = os.getenv("HERMES_TIMEZONE", "").strip() |
| if tz_env: |
| return tz_env |
|
|
| |
| try: |
| import yaml |
| hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) |
| config_path = hermes_home / "config.yaml" |
| if config_path.exists(): |
| with open(config_path) as f: |
| cfg = yaml.safe_load(f) or {} |
| 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 user's configured ZoneInfo, or None (meaning server-local). |
| |
| Resolved once and cached. Call ``reset_cache()`` after config changes. |
| """ |
| global _cached_tz, _cached_tz_name, _cache_resolved |
| if not _cache_resolved: |
| _cached_tz_name = _resolve_timezone_name() |
| _cached_tz = _get_zoneinfo(_cached_tz_name) |
| _cache_resolved = True |
| return _cached_tz |
|
|
|
|
| def get_timezone_name() -> str: |
| """Return the IANA name of the configured timezone, or empty string.""" |
| global _cached_tz_name, _cache_resolved |
| if not _cache_resolved: |
| get_timezone() |
| return _cached_tz_name or "" |
|
|
|
|
| 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) |
| |
| return datetime.now().astimezone() |
|
|
|
|
| def reset_cache() -> None: |
| """Clear the cached timezone. Used by tests and after config changes.""" |
| global _cached_tz, _cached_tz_name, _cache_resolved |
| _cached_tz = None |
| _cached_tz_name = None |
| _cache_resolved = False |
|
|