reachy-mini-time-tool / time_tool.py
Alina Lozowski
refactor: compute time offline and resolve local time via keyless geo-IP
4a835ef
Raw
History Blame Contribute Delete
6.39 kB
"""Timezone helper for the Reachy Mini Time Tool Space: offline compute, keyless geo-IP for local time."""
import json
import logging
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from functools import lru_cache
from ipaddress import ip_address
from urllib.error import URLError, HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
import gradio as gr
logger = logging.getLogger(__name__)
IPWHOIS_URL = "https://ipwho.is"
TIMEOUT_S = 3.0
USER_AGENT = "reachy-mini-time-tool/2.0 (+https://huggingface.co/spaces/pollen-robotics/reachy-mini-time-tool)"
CLIENT_IP_HEADERS = (
"cf-connecting-ip",
"x-real-ip",
"x-forwarded-for",
"x-client-ip",
"fastly-client-ip",
)
UTC = ZoneInfo("UTC")
def get_time(
timezone: str = "",
compare_timezone: str = "",
request: gr.Request | None = None,
) -> dict[str, object]:
"""Return the current time for an IANA timezone (empty resolves the caller's local zone), with an optional difference."""
if not isinstance(timezone, str) or not isinstance(compare_timezone, str):
return {"error": "timezone and compare_timezone must be strings"}
base_name = timezone.strip()
if base_name:
base_zone = _load_timezone(base_name)
if base_zone is None:
return {"error": f"unknown timezone '{base_name}'; pass an IANA name like 'Europe/Paris'"}
else:
base_name, base_zone = _local_timezone(request)
logger.info("get_time timezone=%s compare=%s", base_name, compare_timezone.strip())
now_utc = datetime.now(UTC)
base_now = now_utc.astimezone(base_zone)
result = _format_time(base_now, base_name)
compare_name = compare_timezone.strip()
if compare_name:
compare_zone = _load_timezone(compare_name)
if compare_zone is None:
return {"error": f"unknown timezone '{compare_name}'; pass an IANA name like 'Asia/Tokyo'"}
compare_now = now_utc.astimezone(compare_zone)
difference_minutes = _offset_minutes(compare_now) - _offset_minutes(base_now)
result["compare"] = _format_time(compare_now, compare_name)
result["time_difference_minutes"] = difference_minutes
result["time_difference_hours"] = difference_minutes / 60
result["time_difference_summary"] = _difference_summary(compare_name, base_name, difference_minutes)
return result
def _local_timezone(request: gr.Request | None) -> tuple[str, ZoneInfo]:
"""Resolve the caller's local timezone by keyless geo-IP, falling back to UTC when it cannot be determined."""
caller_ip = _caller_ip(request)
if caller_ip is None:
logger.warning("caller public IP was not provided; local time falls back to UTC")
return "UTC", UTC
name = _timezone_for_ip(caller_ip)
zone = _load_timezone(name) if name else None
if zone is None:
return "UTC", UTC
return name, zone
@lru_cache(maxsize=1024)
def _timezone_for_ip(caller_ip: str) -> str | None:
"""Look up an IANA timezone for a public IP via keyless ipwho.is, cached per IP to stay under the shared quota."""
url = f"{IPWHOIS_URL}/{quote(caller_ip, safe='.:')}?fields=success,timezone"
try:
request = Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
with urlopen(request, timeout=TIMEOUT_S) as response:
payload = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, ValueError) as exc:
logger.warning("geo-IP timezone lookup failed for %s: %s", caller_ip, exc)
return None
if not payload.get("success", True):
logger.warning("geo-IP lookup unsuccessful for %s: %s", caller_ip, payload.get("message"))
return None
timezone = payload.get("timezone")
if not isinstance(timezone, dict):
return None
return str(timezone.get("id", "")).strip() or None
def _caller_ip(request: gr.Request | None) -> str | None:
if request is None:
return None
headers = getattr(request, "headers", {}) or {}
for header_name in CLIENT_IP_HEADERS:
header_value = headers.get(header_name)
if isinstance(header_value, str):
public_ip = _first_public_ip(header_value)
if public_ip is not None:
return public_ip
host = getattr(getattr(request, "client", None), "host", None)
return _first_public_ip(host) if isinstance(host, str) else None
def _first_public_ip(value: str) -> str | None:
for raw_candidate in value.split(","):
candidate = raw_candidate.strip()
if candidate.count(":") == 1:
candidate = candidate.rsplit(":", maxsplit=1)[0]
try:
parsed_ip = ip_address(candidate)
except ValueError:
continue
if not (
parsed_ip.is_private
or parsed_ip.is_loopback
or parsed_ip.is_link_local
or parsed_ip.is_multicast
or parsed_ip.is_unspecified
):
return str(parsed_ip)
return None
def _load_timezone(name: str) -> ZoneInfo | None:
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return None
def _format_time(now: datetime, name: str) -> dict[str, object]:
return {
"iso": now.isoformat(timespec="seconds"),
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M"),
"weekday": now.strftime("%A"),
"timezone": name,
"utc_offset_minutes": _offset_minutes(now),
"summary": now.strftime("%A, %d %B %Y, %H:%M"),
}
def _offset_minutes(now: datetime) -> int:
offset = now.utcoffset()
return int(offset.total_seconds() // 60) if offset else 0
def _difference_summary(compared_name: str, base_name: str, difference_minutes: int) -> str:
if difference_minutes == 0:
return f"{compared_name} has the same current UTC offset as {base_name}."
direction = "ahead of" if difference_minutes > 0 else "behind"
hours, remaining_minutes = divmod(abs(difference_minutes), 60)
duration = f"{hours} hour{'s' if hours != 1 else ''}"
if remaining_minutes:
duration = f"{duration} {remaining_minutes} minute{'s' if remaining_minutes != 1 else ''}"
return f"{compared_name} is {duration} {direction} {base_name}."