Skip to content

Dates and time

rigour.dates

Compare prefix dates without pretending they are exact instants.

A prefix date such as 2026 or 2026-06 represents an interval, rather than the first instant of that year or month. Use the string wrappers for one-off comparisons, or parse a DateInterval once when comparing the same value repeatedly.

Age and recency checks use the same comparisons with a shifted reference:

cutoff = now - max_age
is_recent = not ended_before(value, cutoff)

DateInterval dataclass

Represent every possible instant described by an imprecise date.

Use this parsed form when applying multiple comparisons to the same prefix date. Both bounds are timezone-aware UTC datetimes and end is exclusive.

Attributes:

Name Type Description
start datetime

Earliest instant represented by the date.

end datetime

First instant after the represented interval.

Source code in rigour/dates.py
@dataclass(frozen=True)
class DateInterval:
    """Represent every possible instant described by an imprecise date.

    Use this parsed form when applying multiple comparisons to the same prefix
    date. Both bounds are timezone-aware UTC datetimes and ``end`` is exclusive.

    Attributes:
        start: Earliest instant represented by the date.
        end: First instant after the represented interval.
    """

    start: datetime
    end: datetime

ended_before(value, reference)

Check whether a prefix date has completely elapsed.

Use this string wrapper for one-off end-date and age-cutoff checks. Parse once with prefix_interval when making several comparisons against the same value.

Parameters:

Name Type Description Default
value str

Canonical prefix date, from year through second precision.

required
reference datetime

UTC point in time. Legacy naive values are interpreted as UTC; aware values are converted to UTC.

required

Returns:

Type Description
bool

True if every instant represented by the date precedes the

bool

reference.

Source code in rigour/dates.py
def ended_before(value: str, reference: datetime) -> bool:
    """Check whether a prefix date has completely elapsed.

    Use this string wrapper for one-off end-date and age-cutoff checks. Parse
    once with [prefix_interval][rigour.dates.prefix_interval] when making
    several comparisons against the same value.

    Args:
        value: Canonical prefix date, from year through second precision.
        reference: UTC point in time. Legacy naive values are interpreted as
            UTC; aware values are converted to UTC.

    Returns:
        ``True`` if every instant represented by the date precedes the
        reference.
    """
    return interval_ended_before(prefix_interval(value), _ensure_utc(reference))

interval_ended_before(value, reference)

Check whether an interval has completely elapsed before a point in time.

Use this for end dates and age cutoffs when the prefix date has already been parsed.

Parameters:

Name Type Description Default
value DateInterval

Interval to compare.

required
reference datetime

Timezone-aware UTC point in time.

required

Returns:

Type Description
bool

True if every instant represented by the interval precedes the

bool

reference.

Source code in rigour/dates.py
def interval_ended_before(value: DateInterval, reference: datetime) -> bool:
    """Check whether an interval has completely elapsed before a point in time.

    Use this for end dates and age cutoffs when the prefix date has already been
    parsed.

    Args:
        value: Interval to compare.
        reference: Timezone-aware UTC point in time.

    Returns:
        ``True`` if every instant represented by the interval precedes the
        reference.
    """
    return value.end <= reference

interval_starts_after(value, reference)

Check whether an interval begins strictly after a point in time.

Use this for start dates and future-date guardrails when the prefix date has already been parsed.

Parameters:

Name Type Description Default
value DateInterval

Interval to compare.

required
reference datetime

Timezone-aware UTC point in time.

required

Returns:

Type Description
bool

True if the earliest instant in the interval follows the reference.

Source code in rigour/dates.py
def interval_starts_after(value: DateInterval, reference: datetime) -> bool:
    """Check whether an interval begins strictly after a point in time.

    Use this for start dates and future-date guardrails when the prefix date has
    already been parsed.

    Args:
        value: Interval to compare.
        reference: Timezone-aware UTC point in time.

    Returns:
        ``True`` if the earliest instant in the interval follows the reference.
    """
    return value.start > reference

parse_utc(value)

Parse an exact timestamp as an aware UTC datetime.

Use this at a prefix-date boundary where an exact timestamp may carry an offset. Naive timestamps use the ecosystem convention of implicit UTC.

Parameters:

Name Type Description Default
value str

Canonical second-precision timestamp.

required

Returns:

Type Description
datetime

The represented instant as a timezone-aware UTC datetime.

Raises:

Type Description
ValueError

The timestamp is invalid or is not precise to the second.

Source code in rigour/dates.py
def parse_utc(value: str) -> datetime:
    """Parse an exact timestamp as an aware UTC datetime.

    Use this at a prefix-date boundary where an exact timestamp may carry an
    offset. Naive timestamps use the ecosystem convention of implicit UTC.

    Args:
        value: Canonical second-precision timestamp.

    Returns:
        The represented instant as a timezone-aware UTC datetime.

    Raises:
        ValueError: The timestamp is invalid or is not precise to the second.
    """
    if _SECOND_TIMESTAMP_RE.fullmatch(value) is None:
        raise ValueError(f"Invalid exact timestamp: {value!r}")
    try:
        timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as exc:
        raise ValueError(f"Invalid timestamp: {value!r}") from exc
    return _ensure_utc(timestamp)

prefix_interval(value) cached

Expand a canonical prefix date into its represented UTC interval.

Use this at the boundary between prefix-date strings and interval comparison. Feed it normalized property values only, not uncleaned source strings. Exact timestamps with offsets are converted to UTC before their one-second interval is constructed.

Parameters:

Name Type Description Default
value str

Canonical prefix date, from year through second precision.

required

Returns:

Type Description
DateInterval

The timezone-aware UTC, half-open interval represented by the value.

Raises:

Type Description
ValueError

The value is invalid or non-canonical, or an imprecise value carries a timezone.

Source code in rigour/dates.py
@lru_cache(maxsize=MEMO_SMALL)
def prefix_interval(value: str) -> DateInterval:
    """Expand a canonical prefix date into its represented UTC interval.

    Use this at the boundary between prefix-date strings and interval comparison.
    Feed it normalized property values only, not uncleaned source strings. Exact
    timestamps with offsets are converted to UTC before their one-second interval
    is constructed.

    Args:
        value: Canonical prefix date, from year through second precision.

    Returns:
        The timezone-aware UTC, half-open interval represented by the value.

    Raises:
        ValueError: The value is invalid or non-canonical, or an imprecise value
            carries a timezone.
    """
    if _SECOND_TIMESTAMP_RE.fullmatch(value) is not None:
        start = parse_utc(value)
        return DateInterval(start=start, end=start + timedelta(seconds=1))
    has_timezone = value.endswith("Z") or (
        "T" in value and _TIMEZONE_SUFFIX_RE.search(value) is not None
    )
    if has_timezone:
        raise ValueError("timezone suffixes require second precision")

    prefix = parse(value)
    if prefix.dt is None or prefix.text is None or prefix.precision == Precision.EMPTY:
        raise ValueError(f"Invalid prefix date: {value!r}")
    if prefix.text != value:
        raise ValueError(f"Prefix date is not canonical: {value!r}")

    start = prefix.dt.replace(tzinfo=timezone.utc)
    precision = prefix.precision
    if precision == Precision.YEAR:
        end = start.replace(year=start.year + 1)
    elif precision == Precision.MONTH:
        if start.month == 12:
            end = start.replace(year=start.year + 1, month=1)
        else:
            end = start.replace(month=start.month + 1)
    elif precision == Precision.DAY:
        end = start + timedelta(days=1)
    elif precision == Precision.HOUR:
        end = start + timedelta(hours=1)
    elif precision == Precision.MINUTE:
        end = start + timedelta(minutes=1)
    elif precision == Precision.SECOND:
        end = start + timedelta(seconds=1)
    else:
        raise ValueError(f"Unsupported prefix date precision: {value!r}")
    return DateInterval(start=start, end=end)

starts_after(value, reference)

Check whether a prefix date begins after a point in time.

Use this string wrapper for one-off start-date and future-date checks. Parse once with prefix_interval when reusing the same value.

Parameters:

Name Type Description Default
value str

Canonical prefix date, from year through second precision.

required
reference datetime

UTC point in time. Legacy naive values are interpreted as UTC; aware values are converted to UTC.

required

Returns:

Type Description
bool

True if the earliest instant represented by the date follows the

bool

reference.

Source code in rigour/dates.py
def starts_after(value: str, reference: datetime) -> bool:
    """Check whether a prefix date begins after a point in time.

    Use this string wrapper for one-off start-date and future-date checks. Parse
    once with [prefix_interval][rigour.dates.prefix_interval] when reusing the
    same value.

    Args:
        value: Canonical prefix date, from year through second precision.
        reference: UTC point in time. Legacy naive values are interpreted as
            UTC; aware values are converted to UTC.

    Returns:
        ``True`` if the earliest instant represented by the date follows the
        reference.
    """
    return interval_starts_after(prefix_interval(value), _ensure_utc(reference))

rigour.time

datetime_iso(dt)

Convert a datetime object or string to an ISO 8601 formatted string. If the input is None, it returns None. If the input is a string, it is returned as is. Otherwise, the datetime object is converted to a string in the format 'YYYY-MM-DDTHH:MM:SS' (with timezone suffix if present).

The function expects datetime objects to have UTC timezone. If a datetime with a different timezone is provided, a warning is emitted.

Source code in rigour/time.py
def datetime_iso(dt: datetime) -> Optional[str]:
    """Convert a datetime object or string to an ISO 8601 formatted string. If the input
    is None, it returns None. If the input is a string, it is returned as is. Otherwise,
    the datetime object is converted to a string in the format 'YYYY-MM-DDTHH:MM:SS'
    (with timezone suffix if present).

    The function expects datetime objects to have UTC timezone. If a datetime with a
    different timezone is provided, a warning is emitted."""
    if dt is None:
        return dt
    try:
        # Check if the datetime has timezone info and if it's UTC
        if dt.tzinfo != timezone.utc:
            warnings.warn(
                f"datetime_iso expects UTC timezone, but got {dt.tzinfo}. "
                "Consider using utc_now() or converting to UTC first.",
                UserWarning,
                stacklevel=2,
            )

        return dt.isoformat(sep="T", timespec="seconds")
    except AttributeError:
        if isinstance(dt, str):
            warnings.warn(
                "datetime_iso received a string, supports only datetime objects.",
                UserWarning,
                stacklevel=2,
            )
        outvalue = str(dt)
        return outvalue.replace(" ", "T")

iso_datetime(value) cached

Parse datetime from standardized date string. This expects an ISO 8601 formatted string, e.g. '2023-10-01T12:00:00'. Any additional characters after the seconds will be ignored. The string is converted to a datetime object with UTC timezone. This is not designed to parse all possible ISO 8601 formats, but rather a specific convention used in the context of the FollowTheMoney ecosystem.

Source code in rigour/time.py
@lru_cache(maxsize=MEMO_SMALL)
def iso_datetime(value: Optional[str]) -> Optional[datetime]:
    """Parse datetime from standardized date string. This expects an ISO 8601 formatted
    string, e.g. '2023-10-01T12:00:00'. Any additional characters after the seconds will
    be ignored. The string is converted to a datetime object with UTC timezone. This is
    not designed to parse all possible ISO 8601 formats, but rather a specific convention
    used in the context of the FollowTheMoney ecosystem."""
    if value is None or len(value) == 0:
        return None
    value = value[:19].replace(" ", "T")
    dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S")
    return dt.replace(tzinfo=timezone.utc)

naive_now()

Return the current datetime as a naive datetime.

Source code in rigour/time.py
def naive_now() -> datetime:
    """Return the current datetime as a naive datetime."""
    return datetime.now(timezone.utc).replace(tzinfo=None)

utc_date()

Return the current date in UTC.

Source code in rigour/time.py
def utc_date() -> date:
    """Return the current date in UTC."""
    return utc_now().date()

utc_now()

Return the current datetime in UTC.

Source code in rigour/time.py
def utc_now() -> datetime:
    """Return the current datetime in UTC."""
    return datetime.now(timezone.utc)