Time-related options are among the most common in CLIs — --since, --until, --timeout, --interval, --older-than — and among the most commonly mishandled. Accept only integer seconds and users must compute 9000 in their heads for two and a half hours. Accept free-form text with a fuzzy date library and --since "next friday" silently means something nobody expected. Parse a date without thinking about time zones and a report run on a laptop in Oslo disagrees with the same report run in CI. This guide builds two small, strict parsers — one for durations, one for points in time — that accept the formats people actually type, reject everything else with a message that shows valid examples, handle time zones deliberately, and plug into Typer and Click as proper parameter types. It belongs to the argument validation topic.
Prerequisites
- Python 3.11+ (for
datetime.fromisoformataccepting the full ISO 8601 profile, including a trailingZ). - Typer or Click; the ideas from writing custom Click parameter types.
Decide what to accept
Strict beats clever. For durations, a compact unit syntax covers nearly everything: 90s, 15m, 2h30m, 1d, 1.5h, plus bare integers as seconds for scripts. For points in time, ISO 8601 dates and date-times (2026-10-02, 2026-10-02T14:30, 2026-10-02T14:30+02:00), a handful of keywords (now, today, yesterday) and relative offsets into the past (7d, 3h ago). That set is small enough to document in one line of help, unambiguous across locales — no 10/02 that means October in one country and February in another — and easy to test exhaustively.
The recipe
# src/mytool/timeparse.py
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
_UNITS = {"w": "weeks", "d": "days", "h": "hours", "m": "minutes", "s": "seconds"}
_PART = re.compile(r"(\d+(?:\.\d+)?)([wdhms])")
_AGO = re.compile(r"^(\d+)([wdhm])(?:\s*ago)?$")
def parse_duration(text: str) -> timedelta:
"""'90s', '15m', '2h30m', '1d', '1.5h' or a bare number of seconds."""
s = text.strip().lower().replace(" ", "")
if s.isdigit():
return timedelta(seconds=int(s))
pos, total = 0, timedelta()
for m in _PART.finditer(s):
if m.start() != pos: # something unparsed between parts
break
total += timedelta(**{_UNITS[m.group(2)]: float(m.group(1))})
pos = m.end()
if not s or pos != len(s) or total <= timedelta():
raise ValueError(f"{text!r} is not a duration; use e.g. 90s, 15m, 2h30m or 1d")
return total
def parse_when(text: str, *, now: datetime | None = None) -> datetime:
"""An ISO date/time, 'now', 'today', 'yesterday', or '7d' / '3h ago'. Returns UTC."""
now = now or datetime.now(timezone.utc)
s = text.strip().lower()
if s == "now":
return now
local_midnight = now.astimezone().replace(hour=0, minute=0, second=0, microsecond=0)
if s == "today":
return local_midnight.astimezone(timezone.utc)
if s == "yesterday":
return (local_midnight - timedelta(days=1)).astimezone(timezone.utc)
if m := _AGO.match(s):
return now - timedelta(**{_UNITS[m.group(2)]: int(m.group(1))})
try:
value = datetime.fromisoformat(text.strip())
except ValueError:
raise ValueError(f"{text!r} is not a time; use 2026-10-02, 2026-10-02T14:30, "
"today, yesterday or 7d") from None
if value.tzinfo is None:
value = value.astimezone() # naive input means the user's local time
return value.astimezone(timezone.utc)
Both functions are plain Python with no framework imports, so they can be reused for configuration files and tested directly. The duration parser walks the string part by part and insists that the parts cover it completely, so 15x, m10 and 1h oops are rejected rather than half-parsed. The time parser returns timezone-aware UTC datetimes, always — the one representation that compares and serialises safely.
Time zones, deliberately
The single most important decision is what a time without an offset means. 2026-09-01T14:30 typed by a person almost always means 14:30 where they are, so the parser interprets naive input in the local time zone and converts to UTC. Keywords follow the same rule: today is local midnight. In Oslo in early October, today therefore becomes 22:00 UTC on the previous day — correct, and exactly the kind of thing to show users in verbose output. Times with an explicit offset or Z are taken as given. If your tool runs mostly on servers set to UTC, say so in the help; if users span time zones, offer a --utc flag that interprets naive input as UTC instead.
Plugging into Typer and Click
Typer accepts a parser= callable on an option and calls it with the raw string; raising typer.BadParameter produces a standard usage error:
# src/mytool/cli.py
from datetime import datetime, timedelta
from typing import Annotated, Optional
import typer
from mytool.timeparse import parse_duration, parse_when
app = typer.Typer()
def _duration(text: str) -> timedelta:
try:
return parse_duration(text)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
def _when(text: str) -> datetime:
try:
return parse_when(text)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
@app.callback()
def main() -> None:
"""Log tool."""
@app.command()
def logs(
since: Annotated[Optional[datetime], typer.Option(parser=_when, metavar="WHEN",
help="Start time: 2026-10-02, 2026-10-02T14:30, today, yesterday or 7d.")] = None,
timeout: Annotated[timedelta, typer.Option(parser=_duration, metavar="DURATION",
help="Give up after this long, e.g. 30s or 2m.")] = "30s",
) -> None:
"""Fetch logs."""
typer.echo(f"since={since.isoformat() if since else 'beginning'} "
f"timeout={timeout.total_seconds():.0f}s")
The default "30s" is written in the user-facing syntax and parsed like any other value, so the help shows [default: 30s] rather than 0:00:30. The metavar WHEN or DURATION tells users what kind of value is expected before they read the description. In Click, wrap the same functions in a click.ParamType subclass whose convert calls self.fail(str(exc), param, ctx) on ValueError.
UX considerations
- Show examples in the error. "
'15x' is not a duration; use e.g. 90s, 15m, 2h30m or 1d" fixes the problem in one read. - Echo the interpretation when it matters. At
--verbose, print "since 2026-10-01T22:00Z (today, Europe/Oslo)" so time-zone surprises are visible. - Reject the future where it makes no sense.
--since 2030-01-01for log retrieval is almost certainly a typo; check ranges after parsing. - Keep
since/untilconsistent. If both are given, validate thatsince < untiland say which was wrong — see validating dependent and conflicting options. - Output in the same formats you accept. JSON output should use ISO 8601 UTC strings, so users can feed one command's output into another's
--since.
Testing the behaviour
Pass now= explicitly so tests are deterministic, and pin the time zone with an environment variable for the local-time cases:
# tests/test_timeparse.py
import os
import time
from datetime import datetime, timedelta, timezone
import pytest
from mytool.timeparse import parse_duration, parse_when
NOW = datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc)
@pytest.mark.parametrize("text,seconds", [("90s", 90), ("2h30m", 9000), ("1d", 86400),
("1.5h", 5400), ("45", 45), (" 1h 5m ", 3900)])
def test_valid_durations(text, seconds):
assert parse_duration(text) == timedelta(seconds=seconds)
@pytest.mark.parametrize("text", ["", "15x", "m10", "1h oops", "0s", "-5m"])
def test_invalid_durations(text):
with pytest.raises(ValueError, match="is not a duration"):
parse_duration(text)
@pytest.fixture
def oslo(monkeypatch):
monkeypatch.setenv("TZ", "Europe/Oslo")
time.tzset()
yield
monkeypatch.undo()
time.tzset()
@pytest.mark.skipif(not hasattr(time, "tzset"), reason="POSIX only")
def test_local_interpretation(oslo):
assert parse_when("today", now=NOW).isoformat() == "2026-10-01T22:00:00+00:00"
assert parse_when("2026-09-01T14:30", now=NOW).isoformat() == "2026-09-01T12:30:00+00:00"
def test_explicit_offsets_and_relative_times():
assert parse_when("2026-09-01T14:30Z", now=NOW).hour == 14
assert parse_when("7d", now=NOW) == NOW - timedelta(days=7)
assert parse_when("3h ago", now=NOW) == NOW - timedelta(hours=3)
def test_garbage_is_rejected():
with pytest.raises(ValueError, match="is not a time"):
parse_when("next friday", now=NOW)
time.tzset() makes the process pick up the changed TZ variable; restoring it after the test keeps other tests unaffected. On Windows, where tzset does not exist, inject a time zone into the parser instead of relying on the process setting.
Conclusion
Time options deserve real types: parse durations from compact unit strings and points in time from ISO 8601 plus a few keywords and relative offsets, reject everything else with examples, interpret naive input as local time and return aware UTC datetimes, and plug the parsers into Typer with parser= or into Click as parameter types. Keep the parsers framework-free so configuration files use the same rules, and test them with an injected now and a pinned time zone.
Frequently asked questions
Should I use dateparser or dateutil for natural-language dates?
They are powerful and, for a CLI, often too lenient: they guess, and a wrong guess silently changes what a command does. If you want natural language, accept it only behind an explicit opt-in and echo the interpretation before acting.
What about ISO 8601 durations like PT2H30M?
Easy to add as another branch — some users paste them from APIs. Keep the compact form as the documented one; it is what people type.
Why not return naive datetimes in local time?
Naive datetimes cannot be compared safely with aware ones, break across DST changes and become ambiguous the moment the value leaves the machine. Aware UTC internally, local time only for display, is the robust rule.
How do I handle "yesterday" across a daylight-saving change?
Subtracting 24 hours from local midnight can land an hour off on the night clocks change. If that matters, compute the date first (local_today - timedelta(days=1)) and build midnight for that date with zoneinfo, which applies the correct offset for that day.
Can configuration files use the same syntax?
Yes, and they should: call parse_duration and parse_when when loading config values, so timeout = "2m" in a TOML file means exactly what --timeout 2m means on the command line, with the same error messages naming the bad value.