190 lines
No EOL
6.5 KiB
Python
190 lines
No EOL
6.5 KiB
Python
# 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 |