666ghj/MiroFish · error · StarHistoryError

clock must return a UTC datetime

Error message

clock must return a UTC datetime

What it means

_normalize_now (also used by _format_state_timestamp) received a datetime from the injected clock that was naive (tzinfo None) or had a non-zero UTC offset, and raised StarHistoryError('clock must return a UTC datetime'). Every timestamp the script writes into state goes through this, so the clock callable must supply tz-aware UTC.

Source

Thrown at scripts/star_history.py:334

def _parse_state_timestamp(value: Any, label: str) -> datetime:
    if not isinstance(value, str) or not STATE_TIMESTAMP_RE.fullmatch(value):
        raise StarHistoryError(f"{label} must use YYYY-MM-DDTHH:MM:SSZ")
    try:
        parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
    except ValueError as exc:
        raise StarHistoryError(f"{label} is not a valid UTC timestamp") from exc
    return parsed


def _format_state_timestamp(value: datetime) -> str:
    normalized = _normalize_now(value)
    return normalized.strftime("%Y-%m-%dT%H:%M:%SZ")


def _normalize_now(value: datetime) -> datetime:
    if value.tzinfo is None or value.utcoffset() != timedelta(0):
        raise StarHistoryError("clock must return a UTC datetime")
    return value.astimezone(UTC).replace(microsecond=0)


def _expect_keys(value: Mapping[str, Any], expected: set[str], label: str) -> None:
    actual = set(value)
    if actual != expected:
        raise StarHistoryError(f"{label} contains missing or unknown fields")


def validate_state(state: Any) -> None:
    if not isinstance(state, dict):
        raise StarHistoryError("history state must be a JSON object")
    _expect_keys(
        state,
        {
            "schema_version",
            "repository",
            "timezone",

View on GitHub (pinned to b5b53acc57)

Solutions

  1. Use a UTC-aware clock: datetime.now(timezone.utc) or datetime.now(UTC)
  2. In tests, inject a fixed UTC datetime like datetime(2024, 1, 15, tzinfo=timezone.utc)
  3. Audit every place the clock is constructed or injected and centralize it

Example fix

# before
clock = lambda: datetime.now()

# after
from datetime import datetime, timezone
clock = lambda: datetime.now(timezone.utc)
Defensive patterns

Strategy: type-guard

Validate before calling

from datetime import datetime, timezone

def clock() -> datetime:
    now = datetime.now(timezone.utc)
    assert now.tzinfo is timezone.utc and now.utcoffset() == timezone.utc.utcoffset(None)
    return now

Type guard

from datetime import datetime, timedelta, timezone

def is_utc_datetime(value: object) -> TypeGuard[datetime]:
    return (
        isinstance(value, datetime)
        and value.tzinfo is not None
        and value.utcoffset() == timedelta(0)
    )

Try / catch

try:
    stamp = _format_state_timestamp(clock())
except StarHistoryError as exc:
    if "clock must return a UTC datetime" in str(exc):
        raise RuntimeError("fix the injected clock: use datetime.now(timezone.utc)") from exc
    raise

Prevention

When it happens

Trigger: A clock function returning datetime.utcnow() (naive, deprecated but common), datetime.now() without tz, or datetime.now(ZoneInfo('Europe/Berlin')).

Common situations: Calling the script's internals from tests with datetime.now() (naive local time) as the clock; overriding the clock with datetime.now(timezone(timedelta(hours=2))); refactors that changed the clock's return type.

Related errors


AI-assisted analysis of 666ghj/MiroFish@b5b53acc57 (2026-08-14). Data as JSON: /api/errors/96cddb7e9e3b23bb. Report an issue: GitHub.