paperclipai/paperclip · error · ValueError

History date must include a timezone

Error message

History date must include a timezone

What it means

display_date() parses an ISO-8601 timestamp with datetime.fromisoformat and requires it to carry timezone information so it can convert to UTC for display. A naive datetime (no offset/Z) would make the rendered date ambiguous, so the script raises ValueError.

Solutions

  1. Fix the producer to serialize timezone-aware ISO timestamps (e.g. datetime.now(timezone.utc).isoformat(), which includes +00:00).
  2. If the source is known-UTC, append an offset before parsing: value + "+00:00" when no Z/offset is present.
  3. In fixtures, write "2026-09-18T12:00:00Z" or "+00:00"-suffixed timestamps.
  4. If naive dates must be tolerated, decide a policy (assume UTC) and normalize in display_date.

Example fix

# before
measured = "2026-09-18T12:00:00"  # naive
# after
measured = "2026-09-18T12:00:00Z"  # or +00:00 suffix
Defensive patterns

Strategy: validation

Validate before calling

from datetime import datetime
def is_aware_iso(value):
    d = datetime.fromisoformat(value.replace("Z", "+00:00"))
    return d.tzinfo is not None

Type guard

def has_timezone(value):
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00")).tzinfo is not None
    except ValueError:
        return False

Try / catch

try:
    values = summarize(history, kind)
except ValueError as e:
    if str(e) == "History date must include a timezone":
        history = normalize_dates_to_utc(history)  # append +00:00 to naive stamps
        values = summarize(history, kind)
    else:
        raise

Prevention

When it happens

Trigger: Calling display_date (via summarize or the refreshed timestamp) with a string like "2026-09-18T12:00:00" — no 'Z' suffix and no UTC offset. Note the code only replaces a trailing 'Z' with '+00:00'; strings with neither Z nor offset stay naive and trigger the error.

Common situations: History feeds written by a producer that serialized naive local datetimes; hand-edited fixture dates; dates generated with datetime.now() (naive) instead of datetime.now(timezone.utc).

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/615ad1d2499613cc. Report an issue: GitHub.

Appendix: source

Thrown at scripts/evals-hub/build.py:46

    if not candidates:
        raise ValueError("Latest campaign is absent from history")
    # Report refreshes replace presentation, never the measurement date.
    return max(candidates, key=lambda c: c.get("reportRevision", {}).get("renderedAt", c["generatedAt"]))


def public_report(url, prefix):
    parsed = urlsplit(url)
    if (parsed.scheme != "https" or parsed.netloc != urlsplit(ORIGIN).netloc
            or not parsed.path.startswith(f"/{prefix}/campaigns/")
            or parsed.query or parsed.fragment):
        raise ValueError("Unexpected public report URL")
    return html.escape(url, quote=True)


def display_date(value):
    date = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if date.tzinfo is None:
        raise ValueError("History date must include a timezone")
    return date.astimezone(timezone.utc).strftime("%d %b %Y · %H:%M UTC")


def summarize(history, kind):
    prefix, schema = SYSTEMS[kind]
    campaign = latest(history, schema)
    totals = campaign["totals"] if kind == "runner" else campaign
    passed, selected = totals["passed"], totals["selected"]
    if any(type(n) is not int for n in (passed, selected)) or not 0 <= passed <= selected:
        raise ValueError("Invalid campaign counts")
    if type(campaign.get("complete")) is not bool:
        raise ValueError("Missing campaign coverage flag")
    revision = campaign.get("reportRevision", {})
    if revision and not isinstance(revision.get("sourceGeneratedAt"), str):
        raise ValueError("Report refresh has no reliable measurement date")
    measured = revision.get("sourceGeneratedAt", campaign["generatedAt"])
    return {
        "url": public_report(campaign["publicUrl"], prefix),

View on GitHub (pinned to 3f1d897a7c)