Input & UX

Validating Dates and Durations in Python CLI Arguments

Accept --since 7d, --until 2026-10-02T14:30 and --timeout 2h30m safely: duration and time parsers for Typer and Click, time zones, clear errors, help metavars and tests.

Updated

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

Decide what to accept

What the time options accept The duration and point-in-time formats a command line tool accepts, with examples and the value each produces. What the time options accept Input Kind Becomes 90s, 2h30m, 1.5h duration timedelta 45 duration (seconds) timedelta(seconds=45) 2026-10-02T14:30 local time aware UTC datetime today, yesterday local midnight aware UTC datetime 7d, 3h ago relative to now aware UTC datetime A small, strict set is easier to document — and to trust — than fuzzy parsing.

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.

From typed text to UTC A typed time without an offset is interpreted in the local time zone and converted to UTC; a time with an offset is converted directly. From typed text to UTC 2026-09-01T14:30 no offset Local zone Europe/Oslo, +02:00 Aware 14:30+02:00 UTC 12:30Z, stored assume attach convert Aware UTC inside the program; local time only for display.

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.

Time options in use Terminal session using relative and duration options successfully and failing with an example-rich error for an invalid duration. Time options in use bash $ mytool logs --since 7d --timeout 2m since=2026-09-25T12:00:00+00:00 timeout=120s $ mytool logs --timeout 15x Invalid value for '--timeout': '15x' is not a duration; use e.g. 90s, 15m, 2h30m or 1d The error shows valid examples, so the second attempt succeeds.

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-01 for log retrieval is almost certainly a typo; check ranges after parsing.
  • Keep since/until consistent. If both are given, validate that since < until and 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.