Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""D-34 IANA timezone handling (Plan 02-01, global-product mandate).
|
|
|
|
Every global-ready product must respect user timezone. We store all runtime
|
|
timestamps (events table, BudgetLedger, record created_at, etc.) in UTC and
|
|
render CLI output in the user's LOCAL timezone.
|
|
|
|
The user's timezone lives in ~/.iai-mcp/config.json under `user.timezone`
|
|
as an IANA string (e.g. "America/Los_Angeles", "Europe/Moscow", "Asia/Tokyo",
|
|
"UTC"). On first run we auto-detect from the system and seed the config file;
|
|
thereafter the user can edit config.json to override.
|
|
|
|
The sleep-cycle scheduler interprets `quiet_window` (22:00-06:00) in the
|
|
user's LOCAL time, not UTC. Multi-tenant architecture-ready: Phase 3+ deployments
|
|
can carry per-user_id tz maps.
|
|
|
|
Public surface:
|
|
- detect_tz() -> str -- best-effort IANA key from system
|
|
- load_user_tz() -> ZoneInfo -- read config.json + auto-seed
|
|
- to_local(dt, tz=None) -- convert UTC (or naive) to local TZ
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
CONFIG_FILENAME = "config.json"
|
|
|
|
|
|
def _config_path() -> Path:
|
|
"""Return the path to the user's config.json.
|
|
|
|
Honours IAI_MCP_STORE env var so test isolation + multi-tenant layouts
|
|
can redirect away from ~/.iai-mcp/.
|
|
"""
|
|
env = os.environ.get("IAI_MCP_STORE")
|
|
root = Path(env) if env else Path.home() / ".iai-mcp"
|
|
return root / CONFIG_FILENAME
|
|
|
|
|
|
def detect_tz() -> str:
|
|
"""Auto-detect IANA timezone from the system. Falls back to "UTC"."""
|
|
try:
|
|
tz = datetime.now().astimezone().tzinfo
|
|
if tz is None:
|
|
return "UTC"
|
|
# ZoneInfo has .key; plain datetime.timezone does not.
|
|
key = getattr(tz, "key", None)
|
|
if key:
|
|
return str(key)
|
|
return "UTC"
|
|
except Exception:
|
|
return "UTC"
|
|
|
|
|
|
def _seed_config(cfg_path: Path, tz_key: str) -> None:
|
|
"""Atomically write user.timezone into config.json.
|
|
|
|
Preserves any existing keys in the file; only mutates user.timezone.
|
|
Writes to a .tmp file first and os.replace()s over the target so a
|
|
crashed process can never leave a half-written config.
|
|
"""
|
|
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
existing: dict = {}
|
|
if cfg_path.exists():
|
|
try:
|
|
with open(cfg_path) as f:
|
|
existing = json.load(f)
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
except (json.JSONDecodeError, OSError):
|
|
existing = {}
|
|
existing.setdefault("user", {})
|
|
if not isinstance(existing["user"], dict):
|
|
existing["user"] = {}
|
|
existing["user"]["timezone"] = tz_key
|
|
tmp = cfg_path.with_suffix(".tmp")
|
|
with open(tmp, "w") as f:
|
|
json.dump(existing, f, indent=2)
|
|
os.replace(tmp, cfg_path)
|
|
|
|
|
|
def load_user_tz() -> ZoneInfo:
|
|
"""Read user.timezone from config.json, auto-seed on first run.
|
|
|
|
Behaviour:
|
|
- config.json missing or malformed -> detect_tz() + write seed; return ZoneInfo.
|
|
- config.json present + user.timezone is a valid IANA string -> return ZoneInfo.
|
|
- config.json present + user.timezone is an INVALID IANA string -> raise
|
|
zoneinfo.ZoneInfoNotFoundError. We refuse to silently override the user's
|
|
edit; a hard error surfaces the typo.
|
|
"""
|
|
cfg_path = _config_path()
|
|
if cfg_path.exists():
|
|
try:
|
|
with open(cfg_path) as f:
|
|
cfg = json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
cfg = None
|
|
if cfg is not None and isinstance(cfg, dict):
|
|
user = cfg.get("user")
|
|
if isinstance(user, dict):
|
|
tz_key = user.get("timezone")
|
|
if isinstance(tz_key, str) and tz_key.strip():
|
|
# Raises ZoneInfoNotFoundError on invalid IANA -- by design.
|
|
return ZoneInfo(tz_key)
|
|
|
|
# No config (or config present but no user.timezone) -> detect + seed.
|
|
detected = detect_tz()
|
|
try:
|
|
zi = ZoneInfo(detected)
|
|
except Exception:
|
|
detected = "UTC"
|
|
zi = ZoneInfo("UTC")
|
|
_seed_config(cfg_path, detected)
|
|
return zi
|
|
|
|
|
|
def to_local(
|
|
utc_dt: datetime,
|
|
tz: ZoneInfo | None = None,
|
|
) -> datetime:
|
|
"""Convert a UTC (or naive-UTC-assumed) datetime into the target ZoneInfo.
|
|
|
|
When tz is None, falls through to load_user_tz() -- but callers in hot paths
|
|
should cache the ZoneInfo instance and pass it explicitly to avoid the
|
|
per-call config.json read.
|
|
"""
|
|
if tz is None:
|
|
tz = load_user_tz()
|
|
if utc_dt.tzinfo is None:
|
|
utc_dt = utc_dt.replace(tzinfo=timezone.utc)
|
|
return utc_dt.astimezone(tz)
|