# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Timezone utilities for consistent timestamp handling across the gateway. Ensures all timestamps are properly handled as UTC. """ from contextvars import ContextVar from datetime import datetime, timezone from typing import Optional, Any import time import logging try: from zoneinfo import ZoneInfo, ZoneInfoNotFoundError except ImportError: ZoneInfo = None ZoneInfoNotFoundError = Exception logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Per-request user timezone (set by middleware from X-User-Timezone header) # # Mirrors the i18n language ContextVar pattern in modules.shared.i18nRegistry: # the browser knows its IANA timezone (Intl.DateTimeFormat().resolvedOptions().timeZone), # the frontend axios interceptor sends it as X-User-Timezone, and the gateway # middleware writes it into _CURRENT_TIMEZONE for any handler/agent to read. # # Storage stays UTC everywhere (getUtcTimestamp / getIsoTimestamp). Only # user-visible "what is now?" decisions (AI-agent prompts, formatted display # strings) should consult getRequestTimezone() / getRequestNow(). # --------------------------------------------------------------------------- _DEFAULT_REQUEST_TZ = "UTC" _CURRENT_TIMEZONE: ContextVar[str] = ContextVar("user_tz", default=_DEFAULT_REQUEST_TZ) def _setRequestTimezone(tzName: str) -> None: """Set the current request's user timezone (called by gateway middleware). Validates against zoneinfo; falls back to UTC for unknown/invalid names so a malicious or stale header cannot break downstream code. """ if not tzName or not isinstance(tzName, str): _CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ) return if ZoneInfo is None: _CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ) return try: ZoneInfo(tzName) except (ZoneInfoNotFoundError, ValueError, OSError) as e: logger.warning( "Invalid timezone in X-User-Timezone header: %r (%s); falling back to %s", tzName, type(e).__name__, _DEFAULT_REQUEST_TZ, ) _CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ) return _CURRENT_TIMEZONE.set(tzName) def getRequestTimezone() -> str: """Return the IANA timezone name for the current request (browser-supplied). Defaults to ``UTC`` outside of an HTTP request context (e.g. scheduler) or when the frontend did not send the header. """ return _CURRENT_TIMEZONE.get() def getRequestNow() -> datetime: """Return current time as a timezone-aware datetime in the request's user TZ. Use this for **user-visible** time values (agent prompts, formatted strings). Use ``getUtcNow()`` / ``getUtcTimestamp()`` for storage and DB writes. """ tzName = getRequestTimezone() if ZoneInfo is None: return datetime.now(timezone.utc) try: return datetime.now(ZoneInfo(tzName)) except (ZoneInfoNotFoundError, ValueError, OSError): return datetime.now(timezone.utc) def getUtcNow() -> datetime: """ Get current time in UTC with timezone info. Returns: datetime: Current UTC time with timezone info """ return datetime.now(timezone.utc) def getUtcTimestamp() -> float: """ Get current UTC timestamp (seconds since epoch with millisecond precision). Returns: float: Current UTC timestamp in seconds with millisecond precision """ return time.time() def getIsoTimestamp() -> str: """ Get current UTC timestamp as ISO 8601 string. Use this for fields declared as 'ISO timestamp' strings (e.g. Pydantic str fields that will be parsed by JavaScript's new Date()). JavaScript cannot parse epoch floats from getUtcTimestamp() as dates. Returns: str: Current UTC time in ISO 8601 format (e.g. "2026-02-15T00:08:32.070000+00:00") """ return datetime.now(timezone.utc).isoformat() def createExpirationTimestamp(expiresInSeconds: int) -> float: """ Create a new expiration timestamp from seconds until expiration. Args: expiresInSeconds (int): Seconds until expiration Returns: float: UTC timestamp in seconds """ return getUtcTimestamp() + expiresInSeconds def parseTimestamp(value: Any, default: Optional[float] = None) -> Optional[float]: """ Parse a timestamp value from various source formats and return as float. Handles conversion from: - float: Returns as-is - int: Converts to float - str: Attempts to parse as numeric string (e.g., "1234567890.123") - None: Returns default value (or None if no default) This function is useful when database connectors (e.g., psycopg2) may return numeric fields as strings in some environments (e.g., Azure PostgreSQL). Args: value: The timestamp value to parse (can be float, int, str, or None) default: Optional default value to return if value is None or invalid. If None and value is invalid, returns None. Returns: float: Parsed timestamp as float, or default value if provided, or None Examples: >>> parseTimestamp(1234567890.123) 1234567890.123 >>> parseTimestamp("1234567890.123") 1234567890.123 >>> parseTimestamp(None, default=0.0) 0.0 >>> parseTimestamp("invalid", default=getUtcTimestamp()) # Returns current timestamp """ if value is None: return default # Already a float if isinstance(value, float): return value # Integer - convert to float if isinstance(value, int): return float(value) # String - try to parse as numeric if isinstance(value, str): # Empty string if not value.strip(): logger.warning(f"parseTimestamp: Received empty string, returning default={default}") return default try: return float(value) except (ValueError, TypeError) as e: # Invalid string format logger.warning(f"parseTimestamp: Failed to parse string '{value}' as float: {type(e).__name__}: {str(e)}, returning default={default}") return default # Unknown type - try to convert anyway try: return float(value) except (ValueError, TypeError) as e: logger.warning(f"parseTimestamp: Failed to convert value of type {type(value).__name__} '{value}' to float: {type(e).__name__}: {str(e)}, returning default={default}") return default