hanki

datetime

stdlib/extra/datetime.hk: a pure-Hanki calendar, clock, and duration library.

An offset-based datetime library in the spirit of Rust's chrono, JavaScript's Temporal, and Python's arrow. Everything here is pure arithmetic over the arbitrary-precision int tier. Howard Hinnant's civil-calendar algorithms make the day/month/year conversions total and exact (no overflow, no year bounds), int's // being floor division and its % taking the divisor's sign. The only effectful seam is the host, delegated to time: the wall clock via time.now_ms!(), and the machine's own UTC offset via time.local_offset_seconds_at!().

The model, smallest to largest:

Scope: fixed UTC offsets only. Named IANA zones (America/New_York) and their daylight-saving transitions need an embedded, yearly-updated tz database, and are left to a community library. What remains is the chrono-core equivalent, complete for RFC 3339 and ISO 8601. local_offset_at! is not an exception to that: it asks the host for its own offset, the one zone the machine already knows, and hands back a fixed Offset like any other. No zone can be named, and none is stored.

Internal precision is nanoseconds everywhere, even though the host clock resolves to milliseconds.

nanosper_second

_nanos_per_second: int = 1000000000

nanosper_minute

_nanos_per_minute: int = 60000000000

nanosper_hour

_nanos_per_hour: int = 3600000000000

nanosper_day

_nanos_per_day: int = 86400000000000

secondsper_day

_seconds_per_day: int = 86400

maxnanosecond

_max_nanosecond: int = 999999999

minseam_second

_min_seam_second: int = -9223372036854775808

The widest epoch second the time seam takes. An Instant counts unbounded int nanoseconds, and a second outside this band would truncate on the way through to_i64 and come back with a plausible offset for an instant nobody asked about.

maxseam_second

_max_seam_second: int = 9223372036854775807

RangeError

struct RangeError
  field: string
  value: int
  min: int
  max: int
end

A component handed to a validating constructor fell outside its allowed range. field names the component, value is what was supplied, and [min, max] is the inclusive band it had to land in.

impl Display<RangeError>

to_string

def to_string(self) -> string

Renders the failure as <field> <value> out of range <min>..=<max>.

RangeError(field="day", value=30, min=1, max=28).to_string() => "day 30 out of range 1..=28"

impl Eq<RangeError>

eq?

def eq?(self, other: Self) -> bool

Two range failures are equal when the field, the value, and both bounds match.

RangeError(field="day", value=30, min=1, max=28).eq?(RangeError(field="day", value=30, min=1, max=28)) => true

Weekday

type Weekday
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
  Sunday
end

A day of the week. number() follows ISO 8601 (Monday = 1 … Sunday = 7).

impl Weekday

number

prop number(self) -> int

The ISO 8601 weekday number: Monday = 1 … Sunday = 7.

Monday.number => 1
Sunday.number => 7

from_number

def from_number(n: int) -> Option<Weekday>

The Weekday for an ISO number (1 = Monday … 7 = Sunday); None outside 1..=7.

Weekday.from_number(6) => Some(Saturday)
Weekday.from_number(0) => None

name

prop name(self) -> string

The full English name.

Wednesday.name => "Wednesday"

short_name

prop short_name(self) -> string

The three-letter English abbreviation.

Wednesday.short_name => "Wed"

impl Display<Weekday>

to_string

def to_string(self) -> string

Renders the day's full English name.

Friday.to_string() => "Friday"

impl Eq<Weekday>

eq?

def eq?(self, other: Self) -> bool

Two weekdays are equal when they are the same day.

Monday.eq?(Monday) => true
Monday.eq?(Tuesday) => false

Month

type Month
  January
  February
  March
  April
  May
  June
  July
  August
  September
  October
  November
  December
end

A calendar month. number() is 1 (January) … 12 (December).

impl Month

number

prop number(self) -> int

The month number, 1 (January) … 12 (December).

January.number  => 1
December.number => 12

from_number

def from_number(n: int) -> Option<Month>

The Month for a number 1..=12; None otherwise.

Month.from_number(7)  => Some(July)
Month.from_number(13) => None

name

prop name(self) -> string

The full English name.

July.name => "July"

short_name

prop short_name(self) -> string

The three-letter English abbreviation.

September.short_name => "Sep"

days_in

def days_in(self, year: int) -> int

The number of days this month has in year (accounts for leap years in February).

February.days_in(2024) => 29
February.days_in(2026) => 28

impl Display<Month>

to_string

def to_string(self) -> string

Renders the month's full English name.

March.to_string() => "March"

impl Eq<Month>

eq?

def eq?(self, other: Self) -> bool

Two months are equal when they are the same month.

July.eq?(July) => true
July.eq?(May) => false

IsoWeek

struct IsoWeek
  week_year: int
  week: int
end

An ISO 8601 week date coordinate: the week-numbering year (which may differ from the calendar year at year boundaries) and the week number 1..=53.

Duration

opaque Duration
  nanos: int
end

A signed span of elapsed time, exact to the nanosecond. This is a time-based duration, a fixed count of nanoseconds, and no calendar-based one: a "day" here is 86 400 seconds, and there are no month or year spans at all, their length being variable. Use Date.add_months / add_years for calendar shifts.

impl Duration

zero

def zero() -> Duration

The zero duration.

Duration.zero().zero? => true

of_nanos

def of_nanos(n: int) -> Duration

A duration of n nanoseconds.

Duration.of_nanos(1500).total_nanos => 1500

of_micros

def of_micros(n: int) -> Duration

A duration of n microseconds.

Duration.of_micros(2).total_nanos => 2000

of_millis

def of_millis(n: int) -> Duration

A duration of n milliseconds.

Duration.of_millis(2).total_nanos => 2000000

of_seconds

def of_seconds(n: int) -> Duration

A duration of n seconds.

Duration.of_seconds(90).total_minutes => 1

of_minutes

def of_minutes(n: int) -> Duration

A duration of n minutes.

Duration.of_minutes(60).total_hours => 1

of_hours

def of_hours(n: int) -> Duration

A duration of n hours.

Duration.of_hours(25).total_days => 1

of_days

def of_days(n: int) -> Duration

A duration of n days, at 86 400 seconds each.

Duration.of_days(1).total_hours => 24

of_weeks

def of_weeks(n: int) -> Duration

A duration of n weeks, at 7 x 86 400 seconds each.

Duration.of_weeks(1).total_days => 7

total_nanos

prop total_nanos(self) -> int

The total signed nanoseconds.

Duration.of_millis(3).total_nanos => 3000000

total_micros

prop total_micros(self) -> int

The whole microseconds, truncated toward zero.

Duration.of_nanos(2500).total_micros => 2

total_millis

prop total_millis(self) -> int

The whole milliseconds, truncated toward zero.

Duration.of_nanos(2500000).total_millis => 2

total_seconds

prop total_seconds(self) -> int

The whole seconds, truncated toward zero.

Duration.of_millis(2500).total_seconds => 2
Duration.of_millis(-2500).total_seconds => -2

total_minutes

prop total_minutes(self) -> int

The whole minutes, truncated toward zero.

Duration.of_seconds(150).total_minutes => 2

total_hours

prop total_hours(self) -> int

The whole hours, truncated toward zero.

Duration.of_minutes(150).total_hours => 2

total_days

prop total_days(self) -> int

The whole days, truncated toward zero.

Duration.of_hours(50).total_days => 2

add

def add(self, other: Duration) -> Duration

Sum of two durations.

Duration.of_hours(1).add(Duration.of_minutes(30)).total_minutes => 90

sub

def sub(self, other: Duration) -> Duration

Difference of two durations.

Duration.of_hours(1).sub(Duration.of_minutes(30)).total_minutes => 30

negate

def negate(self) -> Duration

The additive inverse.

Duration.of_seconds(5).negate().total_seconds => -5

abs

def abs(self) -> Duration

The absolute (non-negative) magnitude.

Duration.of_seconds(-5).abs().total_seconds => 5

mul

def mul(self, factor: int) -> Duration

Scale by an integer factor.

Duration.of_seconds(3).mul(4).total_seconds => 12

div

def div(self, divisor: int) -> Option<Duration>

Divide by an integer, truncating toward zero; None on a zero divisor.

Duration.of_seconds(10).div(3).map(|d| d.total_millis) => Some(3333)
Duration.of_seconds(10).div(0) => None

zero?

prop zero?(self) -> bool

Whether the span is zero.

Duration.of_seconds(0).zero? => true

negative?

prop negative?(self) -> bool

Whether the span is negative (points backward in time).

Duration.of_seconds(-1).negative? => true

to_iso

def to_iso(self) -> string

The ISO 8601 duration string, e.g. PT1H30M, P2DT3H, PT0S. A negative span gets a leading -. Sub-second amounts render as a fraction of the seconds field. Days are the largest unit (calendar months/years are never emitted).

Duration.of_minutes(90).to_iso() => "PT1H30M"
Duration.zero().to_iso() => "PT0S"

ago!

def ago!(self) -> Instant [time]

The instant this duration before now (now!().sub(self)). The Rails ago, to pair with a fluent unit: 3.hours.ago!(). @no-doctest: reads the live clock; result is host-dependent

from_now!

def from_now!(self) -> Instant [time]

The instant this duration after now (now!().add(self)), e.g. 2.days.from_now!(). @no-doctest: reads the live clock; result is host-dependent

impl FromString<Duration>

parse

def parse(s: string) -> Result<Duration, ParseError>

Parse an ISO 8601 duration ([-]PnDTnHnMnS, or the week form PnW). Sub-nanosecond precision is truncated. Calendar Y/M-in-date fields are rejected (this type has no fixed month/year length).

Duration.parse("PT1H30M").map(|d| d.total_minutes) => Ok(90)
Duration.parse("P1W").map(|d| d.total_days) => Ok(7)
Duration.parse("nope").map(|d| d.to_iso()).unwrap_or("bad") => "bad"

impl Eq<Duration>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same signed nanosecond span, whatever units built it.

Duration.of_seconds(1).eq?(Duration.of_millis(1000)) => true

impl Ord<Duration>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by signed nanosecond span, which sorts a negative duration below zero.

Duration.of_seconds(1).cmp(Duration.of_seconds(2)) => Less

impl Display<Duration>

to_string

def to_string(self) -> string

Renders the span in ISO-8601 duration form.

Duration.of_seconds(5).to_string() => "PT5S"

Instant

opaque Instant
  _epoch_nanos: int
end

An absolute point on the UTC timeline, counted in nanoseconds since the Unix epoch (1970-01-01T00:00:00Z). An Instant has no calendar or offset by itself; view it through an Offset to read wall-clock fields.

impl Instant

fromepochseconds

def from_epoch_seconds(s: int) -> Instant

The instant s seconds after the Unix epoch.

Instant.from_epoch_seconds(1).epoch_nanos => 1000000000

fromepochmillis

def from_epoch_millis(ms: int) -> Instant

The instant ms milliseconds after the Unix epoch.

Instant.from_epoch_millis(1500).epoch_seconds => 1

fromepochnanos

def from_epoch_nanos(ns: int) -> Instant

The instant ns nanoseconds after the Unix epoch.

Instant.from_epoch_nanos(42).epoch_nanos => 42

epoch_seconds

prop epoch_seconds(self) -> int

Whole seconds since the epoch, flooring toward negative infinity (so the paired subsecond_nanos is always in [0, 1e9)).

Instant.from_epoch_millis(1500).epoch_seconds => 1
Instant.from_epoch_millis(-500).epoch_seconds => -1

epoch_millis

prop epoch_millis(self) -> int

Whole milliseconds since the epoch, flooring toward negative infinity.

Instant.from_epoch_nanos(1500000).epoch_millis => 1

epoch_nanos

prop epoch_nanos(self) -> int

Nanoseconds since the epoch.

Instant.from_epoch_seconds(2).epoch_nanos => 2000000000

subsecond_nanos

prop subsecond_nanos(self) -> int

The sub-second part, always in [0, 1_000_000_000).

Instant.from_epoch_millis(1500).subsecond_nanos => 500000000
Instant.from_epoch_millis(-500).subsecond_nanos => 500000000

add

def add(self, d: Duration) -> Instant

This instant advanced by a duration.

Instant.from_epoch_seconds(10).add(Duration.of_seconds(5)).epoch_seconds => 15

sub

def sub(self, d: Duration) -> Instant

This instant moved back by a duration.

Instant.from_epoch_seconds(10).sub(Duration.of_seconds(5)).epoch_seconds => 5

duration_until

def duration_until(self, other: Instant) -> Duration

The duration from self to other (positive when other is later).

Instant.from_epoch_seconds(3).duration_until(Instant.from_epoch_seconds(8)).total_seconds => 5

at_offset

def at_offset(self, offset: Offset) -> OffsetDateTime

View this instant through a fixed UTC offset, yielding an OffsetDateTime whose wall-clock fields read in that offset.

Instant.from_epoch_seconds(0).at_offset(Offset.utc()).to_rfc3339() => "1970-01-01T00:00:00Z"

to_rfc3339

def to_rfc3339(self) -> string

The RFC 3339 timestamp in UTC (always suffixed Z).

Instant.from_epoch_seconds(0).to_rfc3339() => "1970-01-01T00:00:00Z"

torfc3339numeric

def to_rfc3339_numeric(self) -> string

The same UTC timestamp ending +00:00 and never Z. See datetime.Offset.to_iso_numeric.

Instant.from_epoch_seconds(0).to_rfc3339_numeric() => "1970-01-01T00:00:00+00:00"

impl FromString<Instant>

parse

def parse(s: string) -> Result<Instant, ParseError>

Parse an RFC 3339 timestamp into the absolute instant it names (its offset is consumed to reach UTC and then discarded).

Instant.parse("1970-01-01T00:00:01Z").map(|i| i.epoch_seconds) => Ok(1)
Instant.parse("1970-01-01T01:00:00+01:00").map(|i| i.epoch_seconds) => Ok(0)

impl Eq<Instant>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same nanosecond on the UTC timeline.

Instant.from_epoch_seconds(1).eq?(Instant.from_epoch_millis(1000)) => true

impl Ord<Instant>

cmp

def cmp(self, other: Self) -> Ordering

Ordered along the UTC timeline: earlier is less.

Instant.from_epoch_seconds(1).cmp(Instant.from_epoch_seconds(2)) => Less

impl Display<Instant>

to_string

def to_string(self) -> string

Renders the instant as an RFC 3339 timestamp in UTC.

Instant.from_epoch_seconds(0).to_string() => "1970-01-01T00:00:00Z"

Offset

opaque Offset
  seconds: int
end

A fixed offset from UTC, held as whole seconds east of UTC. Bounded to strictly within ±24 hours (|seconds| < 86400), which spans every real civil offset. UTC is the zero offset.

impl Offset

utc

def utc() -> Offset

The zero offset (UTC).

Offset.utc().total_seconds => 0

of_seconds

def of_seconds(s: int) -> Result<Offset, RangeError>

An offset of s seconds east of UTC; Err if |s| >= 86400.

Offset.of_seconds(3600).map(|o| o.to_iso()) => Ok("+01:00")
Offset.of_seconds(90000).map(|o| o.to_iso()).unwrap_or("bad") => "bad"

ofhoursminutes

def of_hours_minutes(hours: int, minutes: int) -> Result<Offset, RangeError>

An offset from signed hours and (unsigned) minutes. The sign of hours sets the direction; a negative hours subtracts the minutes too (so of_hours_minutes(-5, 30) is -05:30). For an offset that is only minutes east/west of UTC, use of_seconds.

Offset.of_hours_minutes(5, 30).map(|o| o.to_iso()) => Ok("+05:30")
Offset.of_hours_minutes(-8, 0).map(|o| o.to_iso()) => Ok("-08:00")

total_seconds

prop total_seconds(self) -> int

The offset as whole seconds east of UTC.

Offset.of_hours_minutes(1, 0).map(|o| o.total_seconds) => Ok(3600)

hours

prop hours(self) -> int

The signed hours component.

Offset.of_hours_minutes(5, 30).map(|o| o.hours) => Ok(5)

minutes

prop minutes(self) -> int

The signed minutes component (the part beyond whole hours).

Offset.of_hours_minutes(5, 30).map(|o| o.minutes) => Ok(30)

utc?

prop utc?(self) -> bool

Whether this is UTC (the zero offset).

Offset.utc().utc? => true

to_iso

def to_iso(self) -> string

The ISO 8601 offset string: Z for UTC, else +HH:MM / -HH:MM (with a :SS suffix only when the offset has whole seconds).

Offset.utc().to_iso() => "Z"
Offset.of_hours_minutes(-8, 0).map(|o| o.to_iso()) => Ok("-08:00")

toisonumeric

def to_iso_numeric(self) -> string

The same offset always in the numeric form, which makes UTC +00:00 and never than Z. ISO 8601 and RFC 3339 permit both, but a great many systems write only the numeric one. SQLite, Postgres, Crystal and Rust are among them, and a program comparing its output against theirs byte for byte needs to spell it their way.

A sibling method and no setting on the value: two offsets that compare equal must not render differently.

Offset.utc().to_iso_numeric() => "+00:00"
Offset.of_hours_minutes(-8, 0).map(|o| o.to_iso_numeric()) => Ok("-08:00")

impl FromString<Offset>

parse

def parse(s: string) -> Result<Offset, ParseError>

Parse an ISO 8601 offset: Z/z, ±HH:MM, ±HH:MM:SS, or the compact ±HHMM / ±HH.

Offset.parse("+05:30").map(|o| o.total_seconds) => Ok(19800)
Offset.parse("Z").map(|o| o.total_seconds) => Ok(0)

impl Eq<Offset>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same number of seconds east of UTC.

Offset.utc().eq?(Offset.utc()) => true

impl Ord<Offset>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by seconds east of UTC, which sorts western offsets below eastern ones.

Offset.utc().cmp(Offset.utc()) => Equal

impl Display<Offset>

to_string

def to_string(self) -> string

Renders the offset in ISO-8601 form - Z at UTC, else +HH:MM.

Offset.utc().to_string() => "Z"

Time

opaque Time
  _nanos_of_day: int
end

A wall-clock time of day with nanosecond precision, in [00:00:00, 24:00:00). No date, no offset. Leap seconds are not represented (second tops out at 59).

impl Time

new

def new(hour: int, minute: int, second: int, nanosecond: int) -> Result<Time, RangeError>

A time from hour/minute/second/nanosecond; Err on any out-of-range component (hour 0..=23, minute/second 0..=59, nanosecond 0..=999_999_999).

Time.new(14, 30, 15, 0).map(|t| t.to_iso()) => Ok("14:30:15")
Time.new(24, 0, 0, 0).map(|t| t.to_iso()).unwrap_or("bad") => "bad"

hms

def hms(hour: int, minute: int, second: int) -> Result<Time, RangeError>

A time from hour/minute/second, nanoseconds zero.

Time.hms(9, 5, 0).map(|t| t.to_iso()) => Ok("09:05:00")

midnight

def midnight() -> Time

Midnight, 00:00:00.

Time.midnight().to_iso() => "00:00:00"

fromnanosof_day

def from_nanos_of_day(n: int) -> Result<Time, RangeError>

A time from raw nanoseconds since midnight; Err outside [0, 86_400_000_000_000).

Time.from_nanos_of_day(0).map(|t| t.to_iso()) => Ok("00:00:00")

hour

prop hour(self) -> int

The hour, 0..=23.

Time.hms(14, 30, 15).map(|t| t.hour) => Ok(14)

minute

prop minute(self) -> int

The minute, 0..=59.

Time.hms(14, 30, 15).map(|t| t.minute) => Ok(30)

second

prop second(self) -> int

The second, 0..=59.

Time.hms(14, 30, 15).map(|t| t.second) => Ok(15)

nanosecond

prop nanosecond(self) -> int

The sub-second nanoseconds, 0..=999_999_999.

Time.new(0, 0, 0, 500000000).map(|t| t.nanosecond) => Ok(500000000)

millisecond

prop millisecond(self) -> int

The sub-second milliseconds, 0..=999.

Time.new(0, 0, 0, 123000000).map(|t| t.millisecond) => Ok(123)

microsecond

prop microsecond(self) -> int

The sub-second microseconds, 0..=999_999.

Time.new(0, 0, 0, 123456000).map(|t| t.microsecond) => Ok(123456)

nanosofday

prop nanos_of_day(self) -> int

Nanoseconds since midnight.

Time.hms(0, 0, 1).map(|t| t.nanos_of_day) => Ok(1000000000)

add

def add(self, d: Duration) -> Time

This time advanced by a duration, wrapping around midnight (the date a wrap would imply is discarded; use DateTime.add to carry days).

Time.hms(23, 30, 0).map(|t| t.add(Duration.of_hours(1)).to_iso()) => Ok("00:30:00")

to_iso

def to_iso(self) -> string

The ISO 8601 time string HH:MM:SS, with a .fff… fraction only when there are sub-second nanoseconds.

Time.new(1, 2, 3, 500000000).map(|t| t.to_iso()) => Ok("01:02:03.5")

impl FromString<Time>

parse

def parse(s: string) -> Result<Time, ParseError>

Parse an ISO 8601 time HH:MM[:SS[.fff…]].

Time.parse("14:30:15").map(|t| t.hour) => Ok(14)
Time.parse("14:30").map(|t| t.to_iso()) => Ok("14:30:00")

impl Eq<Time>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same nanosecond of the day.

Time.midnight().eq?(Time.midnight()) => true

impl Ord<Time>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by nanosecond of the day: earlier is less.

Time.midnight().cmp(Time.midnight()) => Equal

impl Display<Time>

to_string

def to_string(self) -> string

Renders the wall-clock time in ISO-8601 form.

Time.hms(9, 5, 0).map(|t| t.to_string()) => Ok("09:05:00")

Date

opaque Date
  _year: int
  _month: int
  _day: int
end

A proleptic-Gregorian calendar day (year, month, day) with no time or offset. The year is an unbounded int (negative years run backward before 1 BCE per the proleptic convention). Comparison is chronological.

impl Date

new

def new(year: int, month: int, day: int) -> Result<Date, RangeError>

A date from year/month/day; Err if the month is not 1..=12 or the day is not valid for that month and year (leap Februaries included).

Date.new(2026, 7, 11).map(|d| d.to_iso()) => Ok("2026-07-11")
Date.new(2026, 2, 29).map(|d| d.to_iso()).unwrap_or("bad") => "bad"
Date.new(2024, 2, 29).map(|d| d.to_iso()) => Ok("2024-02-29")

fromepochday

def from_epoch_day(n: int) -> Date

The date n days after the Unix epoch (1970-01-01 is day 0).

Date.from_epoch_day(0).to_iso() => "1970-01-01"
Date.from_epoch_day(-1).to_iso() => "1969-12-31"

from_ordinal

def from_ordinal(year: int, day_of_year: int) -> Result<Date, RangeError>

A date from an ISO ordinal (year + day-of-year 1..=366); Err if the ordinal is out of range for the year.

Date.from_ordinal(2026, 192).map(|d| d.to_iso()) => Ok("2026-07-11")

year

prop year(self) -> int

The year.

Date.new(2026, 7, 11).map(|d| d.year) => Ok(2026)

month

prop month(self) -> int

The month, 1..=12.

Date.new(2026, 7, 11).map(|d| d.month) => Ok(7)

day

prop day(self) -> int

The day of the month, 1..=31.

Date.new(2026, 7, 11).map(|d| d.day) => Ok(11)

epoch_day

prop epoch_day(self) -> int

Days since the Unix epoch (1970-01-01 is 0).

Date.new(1970, 1, 2).map(|d| d.epoch_day) => Ok(1)

weekday

prop weekday(self) -> Weekday

The day of the week.

Date.new(2026, 7, 11).map(|d| d.weekday) => Ok(Saturday)

monthofyear

prop month_of_year(self) -> Month

The Month value for this date.

Date.new(2026, 7, 11).map(|d| d.month_of_year) => Ok(July)

dayofyear

prop day_of_year(self) -> int

The day of the year, 1..=366.

Date.new(2026, 1, 1).map(|d| d.day_of_year) => Ok(1)
Date.new(2026, 12, 31).map(|d| d.day_of_year) => Ok(365)

iso_week

prop iso_week(self) -> IsoWeek

The ISO 8601 week date (week-numbering year + week 1..=53).

Date.new(2026, 1, 1).map(|d| d.iso_week.week) => Ok(1)

leap_year?

prop leap_year?(self) -> bool

Whether this date's year is a leap year.

Date.new(2024, 1, 1).map(|d| d.leap_year?) => Ok(true)

daysinmonth

prop days_in_month(self) -> int

The number of days in this date's month.

Date.new(2024, 2, 1).map(|d| d.days_in_month) => Ok(29)

add_days

def add_days(self, n: int) -> Date

This date shifted by n days (negative goes back).

Date.new(2026, 12, 31).map(|d| d.add_days(1).to_iso()) => Ok("2027-01-01")

add_weeks

def add_weeks(self, n: int) -> Date

This date shifted by n weeks.

Date.new(2026, 7, 11).map(|d| d.add_weeks(1).to_iso()) => Ok("2026-07-18")

add_months

def add_months(self, n: int) -> Date

This date shifted by n calendar months, clamping the day to the end of the target month (so Jan 31 + 1 month is Feb 28/29).

Date.new(2026, 1, 31).map(|d| d.add_months(1).to_iso()) => Ok("2026-02-28")
Date.new(2026, 11, 15).map(|d| d.add_months(3).to_iso()) => Ok("2027-02-15")

add_years

def add_years(self, n: int) -> Date

This date shifted by n calendar years, clamping Feb 29 to Feb 28 in a non-leap target year.

Date.new(2024, 2, 29).map(|d| d.add_years(1).to_iso()) => Ok("2025-02-28")

add_period

def add_period(self, p: Period) -> Date

This date shifted by a calendar Period: years first, then months, clamping the day to the end of each intermediate month. The order is observable: 2024-02-29 + P1Y1M clamps to 2025-02-28, then +1M is 2025-03-28 (not 2025-03-29). Because a Period is normalized, a span past a year applies as its year/month breakdown, which can differ from raw month arithmetic by a day: add_period(13.months) is the P1Y1M case above, while add_months(13) clamps only once.

Date.new(2024, 2, 29).map(|d| d.add_period(Period.of(1, 1)).to_iso()) => Ok("2025-03-28")
Date.new(2026, 1, 15).map(|d| d.add_period(Period.of_months(2)).to_iso()) => Ok("2026-03-15")

days_until

def days_until(self, other: Date) -> int

The number of days from self to other (positive when other is later).

Date.new(2026, 1, 1).map(|d| d.days_until(Date.new(2026, 1, 11).unwrap_or(d))) => Ok(10)

to_iso

def to_iso(self) -> string

The ISO 8601 date string YYYY-MM-DD (years 0 to 9999 zero-padded to four digits; negative years carry a leading -).

Date.new(2026, 7, 11).map(|d| d.to_iso()) => Ok("2026-07-11")

leap?

def leap?(year: int) -> bool

Whether year is a leap year (static form).

Date.leap?(2000) => true
Date.leap?(1900) => false

month_length

def month_length(year: int, month: int) -> int

The number of days in month of year (static form).

Date.month_length(2024, 2) => 29

impl FromString<Date>

parse

def parse(s: string) -> Result<Date, ParseError>

Parse an ISO 8601 date YYYY-MM-DD (an optional leading - marks a negative year).

Date.parse("2026-07-11").map(|d| d.day) => Ok(11)
Date.parse("2026-13-01").map(|d| d.to_iso()).unwrap_or("bad") => "bad"

impl Eq<Date>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same calendar day.

Date.from_epoch_day(0).eq?(Date.from_epoch_day(0)) => true

impl Ord<Date>

cmp

def cmp(self, other: Self) -> Ordering

Ordered chronologically: year, then month, then day.

Date.from_epoch_day(0).cmp(Date.from_epoch_day(1)) => Less

impl Display<Date>

to_string

def to_string(self) -> string

Renders the day as ISO-8601 YYYY-MM-DD.

Date.new(2026, 7, 11).map(|d| d.to_string()) => Ok("2026-07-11")

DateTime

opaque DateTime
  _date: Date
  _time: Time
end

A calendar date paired with a wall-clock time, with no offset: a "local" datetime whose absolute instant is unknown until an Offset is supplied.

impl DateTime

new

def new(date: Date, time: Time) -> DateTime

A datetime from a date and a time.

DateTime.new(Date.new(2026, 7, 11).unwrap_or(Date.from_epoch_day(0)), Time.hms(14, 30, 0).unwrap_or(Time.midnight())).to_iso() => "2026-07-11T14:30:00"

of

def of(year: int, month: int, day: int, hour: int, minute: int, second: int) -> Result<DateTime, RangeError>

A datetime from calendar and clock components; Err on any out-of-range field.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.to_iso()) => Ok("2026-07-11T14:30:00")

date

prop date(self) -> Date

The date part.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.date.to_iso()) => Ok("2026-07-11")

time

prop time(self) -> Time

The time part.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.time.to_iso()) => Ok("14:30:00")

year

prop year(self) -> int

The year.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.year) => Ok(2026)

month

prop month(self) -> int

The month, 1..=12.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.month) => Ok(7)

day

prop day(self) -> int

The day of the month.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.day) => Ok(11)

hour

prop hour(self) -> int

The hour, 0..=23.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.hour) => Ok(14)

minute

prop minute(self) -> int

The minute, 0..=59.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.minute) => Ok(30)

second

prop second(self) -> int

The second, 0..=59.

DateTime.of(2026, 7, 11, 14, 30, 15).map(|dt| dt.second) => Ok(15)

nanosecond

prop nanosecond(self) -> int

The sub-second nanoseconds.

DateTime.of(2026, 7, 11, 0, 0, 0).map(|dt| dt.nanosecond) => Ok(0)

weekday

prop weekday(self) -> Weekday

The day of the week.

DateTime.of(2026, 7, 11, 0, 0, 0).map(|dt| dt.weekday) => Ok(Saturday)

add

def add(self, d: Duration) -> DateTime

This datetime advanced by a duration, carrying whole days across midnight.

DateTime.of(2026, 7, 11, 23, 0, 0).map(|dt| dt.add(Duration.of_hours(2)).to_iso()) => Ok("2026-07-12T01:00:00")

add_period

def add_period(self, p: Period) -> DateTime

This datetime's date part shifted by a calendar Period (see Date.add_period); the time of day is unchanged.

DateTime.of(2026, 1, 31, 9, 0, 0).map(|dt| dt.add_period(Period.of_months(1)).to_iso()) => Ok("2026-02-28T09:00:00")

duration_until

def duration_until(self, other: DateTime) -> Duration

The duration from self to other (positive when other is later), treating both as the same fictitious offset.

DateTime.of(2026, 7, 11, 0, 0, 0).map(|dt| dt.duration_until(DateTime.of(2026, 7, 12, 0, 0, 0).unwrap_or(dt)).total_hours) => Ok(24)

at_offset

def at_offset(self, offset: Offset) -> OffsetDateTime

Interpret these wall-clock fields as being in offset, producing the absolute OffsetDateTime.

DateTime.of(2026, 7, 11, 12, 0, 0).map(|dt| dt.at_offset(Offset.utc()).to_rfc3339()) => Ok("2026-07-11T12:00:00Z")

to_iso

def to_iso(self) -> string

The ISO 8601 datetime string YYYY-MM-DDTHH:MM:SS[.fff…].

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.to_iso()) => Ok("2026-07-11T14:30:00")

impl FromString<DateTime>

parse

def parse(s: string) -> Result<DateTime, ParseError>

Parse an ISO 8601 datetime YYYY-MM-DDThh:mm:ss (a T, t, or space separates the date and time; no offset).

DateTime.parse("2026-07-11T14:30:00").map(|dt| dt.hour) => Ok(14)

impl Eq<DateTime>

eq?

def eq?(self, other: Self) -> bool

Equal iff both the date and the time-of-day match.

DateTime.new(Date.from_epoch_day(0), Time.midnight()).eq?(DateTime.new(Date.from_epoch_day(0), Time.midnight())) => true

impl Ord<DateTime>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by date, then by time within the day.

DateTime.new(Date.from_epoch_day(0), Time.midnight()).cmp(DateTime.new(Date.from_epoch_day(1), Time.midnight())) => Less

impl Display<DateTime>

to_string

def to_string(self) -> string

Renders the local datetime in ISO-8601 form, with no offset.

DateTime.of(2026, 7, 11, 14, 30, 0).map(|dt| dt.to_string()) => Ok("2026-07-11T14:30:00")

OffsetDateTime

opaque OffsetDateTime
  _instant: Instant
  _offset: Offset
end

An absolute Instant viewed through a fixed Offset, which is the RFC 3339 timestamp. Its accessors read the wall-clock fields in that offset, while equality and ordering compare the underlying instant (two views of the same moment in different offsets are equal; compare .datetime to compare wall-clock fields instead).

impl OffsetDateTime

new

def new(instant: Instant, offset: Offset) -> OffsetDateTime

Pair an instant with an offset to view it through.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).to_rfc3339() => "1970-01-01T00:00:00Z"

from_fields

def from_fields(dt: DateTime, offset: Offset) -> OffsetDateTime

Interpret a wall-clock DateTime as being in offset, computing the absolute instant it names.

OffsetDateTime.from_fields(DateTime.of(2026, 7, 11, 12, 0, 0).unwrap_or(DateTime.new(Date.from_epoch_day(0), Time.midnight())), Offset.utc()).to_rfc3339() => "2026-07-11T12:00:00Z"

instant

prop instant(self) -> Instant

The underlying absolute instant.

OffsetDateTime.new(Instant.from_epoch_seconds(5), Offset.utc()).instant.epoch_seconds => 5

offset

prop offset(self) -> Offset

The offset these fields are viewed through.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).offset.utc? => true

datetime

prop datetime(self) -> DateTime

The wall-clock DateTime in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.of_hours_minutes(1, 0).unwrap_or(Offset.utc())).datetime.to_iso() => "1970-01-01T01:00:00"

date

prop date(self) -> Date

The wall-clock date in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).date.to_iso() => "1970-01-01"

time

prop time(self) -> Time

The wall-clock time in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).time.to_iso() => "00:00:00"

year

prop year(self) -> int

The wall-clock year in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).year => 1970

month

prop month(self) -> int

The wall-clock month in this offset, 1..=12.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).month => 1

day

prop day(self) -> int

The wall-clock day of month in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).day => 1

hour

prop hour(self) -> int

The wall-clock hour in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(3600), Offset.utc()).hour => 1

minute

prop minute(self) -> int

The wall-clock minute in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(90), Offset.utc()).minute => 1

second

prop second(self) -> int

The wall-clock second in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(5), Offset.utc()).second => 5

nanosecond

prop nanosecond(self) -> int

The sub-second nanoseconds.

OffsetDateTime.new(Instant.from_epoch_millis(500), Offset.utc()).nanosecond => 500000000

weekday

prop weekday(self) -> Weekday

The wall-clock weekday in this offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).weekday => Thursday

add

def add(self, d: Duration) -> OffsetDateTime

This timestamp advanced by a duration (the offset is unchanged, and the wall-clock shifts with the instant).

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).add(Duration.of_hours(1)).hour => 1

add_period

def add_period(self, p: Period) -> OffsetDateTime

This timestamp's calendar date shifted by a Period, keeping the wall time of day and the offset (see Date.add_period).

OffsetDateTime.from_fields(DateTime.of(2026, 1, 31, 9, 0, 0).unwrap_or(DateTime.new(Date.from_epoch_day(0), Time.midnight())), Offset.utc()).add_period(Period.of_months(1)).datetime.to_iso() => "2026-02-28T09:00:00"

sub

def sub(self, d: Duration) -> OffsetDateTime

This timestamp moved back by a duration.

OffsetDateTime.new(Instant.from_epoch_seconds(3600), Offset.utc()).sub(Duration.of_hours(1)).hour => 0

duration_until

def duration_until(self, other: OffsetDateTime) -> Duration

The duration from self to other (compares the absolute instants).

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).duration_until(OffsetDateTime.new(Instant.from_epoch_seconds(60), Offset.utc())).total_seconds => 60

with_offset

def with_offset(self, offset: Offset) -> OffsetDateTime

The same instant viewed through a different offset (the wall-clock fields change; the moment does not).

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).with_offset(Offset.of_hours_minutes(1, 0).unwrap_or(Offset.utc())).hour => 1

to_rfc3339

def to_rfc3339(self) -> string

The RFC 3339 timestamp, e.g. 2026-07-11T14:30:00+05:30 (or …Z for UTC).

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.of_hours_minutes(5, 30).unwrap_or(Offset.utc())).to_rfc3339() => "1970-01-01T05:30:00+05:30"

torfc3339numeric

def to_rfc3339_numeric(self) -> string

The same timestamp with the offset always numeric, which ends UTC +00:00 and never Z. See datetime.Offset.to_iso_numeric for why that spelling is worth having.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).to_rfc3339_numeric() => "1970-01-01T00:00:00+00:00"

impl FromString<OffsetDateTime>

parse

def parse(s: string) -> Result<OffsetDateTime, ParseError>

Parse an RFC 3339 timestamp, preserving its offset.

OffsetDateTime.parse("2026-07-11T14:30:00+05:30").map(|o| o.to_rfc3339()) => Ok("2026-07-11T14:30:00+05:30")
OffsetDateTime.parse("2026-07-11T14:30:00Z").map(|o| o.hour) => Ok(14)

impl Eq<OffsetDateTime>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same underlying instant: two views of one moment in different offsets are equal.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).eq?(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => true

impl Ord<OffsetDateTime>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by the underlying instant and never by wall-clock fields.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).cmp(OffsetDateTime.new(Instant.from_epoch_seconds(1), Offset.utc())) => Less

impl Display<OffsetDateTime>

to_string

def to_string(self) -> string

Renders the value as an RFC 3339 timestamp carrying its offset.

OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()).to_string() => "1970-01-01T00:00:00Z"

Period

opaque Period
  total: int
end

A calendar-variable span of whole months. The fixed-length Duration cannot express one: a month is 28 to 31 days and a year 365 or 366. A Period is a signed count of months where a year is 12; it is normalized by duration, so Period.of_years(1) and Period.of_months(12) are the same value, and it compares by total months (1.years > 11.months, 1.years < 13.months). Duration and Period are not comparable: 30.days is not 1.months.

impl Period

zero

def zero() -> Period

The zero period.

Period.zero().zero? => true

of_years

def of_years(n: int) -> Period

A period of n whole years (12 months each).

Period.of_years(2).to_iso() => "P2Y"

of_months

def of_months(n: int) -> Period

A period of n whole months.

Period.of_months(18).to_iso() => "P1Y6M"

of

def of(years: int, months: int) -> Period

A period of years years plus months months (summed into one span, so of(1, -2) is ten months).

Period.of(1, 2).to_iso() => "P1Y2M"
Period.of(1, -2).to_iso() => "P10M"

years

prop years(self) -> int

The whole-years part of the normalized (year, month) breakdown, truncated toward zero so a negative period's parts share its sign.

Period.of(3, 5).years => 3

months

prop months(self) -> int

The remaining months after the whole years (total_months - years*12).

Period.of(3, 5).months => 5

total_months

prop total_months(self) -> int

This period as a whole number of months (the canonical value).

Period.of(1, 6).total_months => 18

zero?

prop zero?(self) -> bool

True for the zero period.

Period.of(0, 0).zero? => true
Period.of_months(1).zero? => false

negate

def negate(self) -> Period

This period negated.

Period.of(1, 2).negate().to_iso() => "-P1Y2M"

to_iso

def to_iso(self) -> string

ISO 8601 period text over the normalized (year, month) breakdown: P<years>Y<months>M, omitting a zero part, P0D for zero, and a single leading minus for a negative period (-P1Y2M).

Period.of(1, 2).to_iso() => "P1Y2M"
Period.of_years(1).to_iso() => "P1Y"
Period.of_months(18).to_iso() => "P1Y6M"
Period.of(0, 0).to_iso() => "P0D"
Period.of(-1, -2).to_iso() => "-P1Y2M"

after

def after(self, d: Date) -> Date

This period applied to d: years first, then months, clamping the day at each step (see Date.add_period).

Period.of_months(1).after(Date.new(2026, 1, 31).unwrap_or(Date.from_epoch_day(0))).to_iso() => "2026-02-28"

before

def before(self, d: Date) -> Date

This period subtracted from d (d.add_period(self.negate())). Not a true inverse of after: month-end clamping is lossy, so p.before(p.after(jan31)) lands on the 28th and never back on the 31st.

Period.of_years(1).before(Date.new(2025, 6, 15).unwrap_or(Date.from_epoch_day(0))).to_iso() => "2024-06-15"

impl Eq<Period>

eq?

def eq?(self, other: Self) -> bool

Equal iff the same total number of months, which makes P1Y equal P12M.

Period.of_years(1).eq?(Period.of_months(12)) => true
Period.of(1, 1).eq?(Period.of_months(12)) => false

impl Ord<Period>

cmp

def cmp(self, other: Self) -> Ordering

Ordered by total months, which compares a period by its duration: 1.years > 11.months and 1.years < 13.months.

Period.of_years(1).cmp(Period.of_months(11)) => Greater
Period.of_years(1).cmp(Period.of_months(13)) => Less

impl Display<Period>

to_string

def to_string(self) -> string

Renders the calendar span in ISO-8601 period form.

Period.of(1, 2).to_string() => "P1Y2M"

DurationUnits

trait DurationUnits

Fixed-length units (Duration) as properties on int: open datetime, then 5.minutes, 2.days. Read bare: 2.days is a property, and 2.days() is a property error.

nanos

prop nanos(self) -> Duration

That many nanoseconds, as a Duration.

500.nanos.total_nanos => 500

micros

prop micros(self) -> Duration

That many microseconds, as a Duration.

2.micros.total_nanos => 2000

millis

prop millis(self) -> Duration

That many milliseconds, as a Duration.

3.millis.total_nanos => 3000000

seconds

prop seconds(self) -> Duration

That many seconds, as a Duration.

90.seconds.total_minutes => 1

minutes

prop minutes(self) -> Duration

That many minutes, as a Duration.

60.minutes.total_hours => 1

hours

prop hours(self) -> Duration

That many hours, as a Duration.

24.hours.total_days => 1

days

prop days(self) -> Duration

That many days, as a Duration of 24 hours each.

2.days.total_hours => 48

weeks

prop weeks(self) -> Duration

That many weeks, as a Duration of seven days each.

2.weeks.total_days => 14

impl DurationUnits<int>

nanos

prop nanos(self) -> Duration

That many nanoseconds, as a Duration.

500.nanos.total_nanos => 500

micros

prop micros(self) -> Duration

That many microseconds, as a Duration.

2.micros.total_nanos => 2000

millis

prop millis(self) -> Duration

That many milliseconds, as a Duration.

3.millis.total_nanos => 3000000

seconds

prop seconds(self) -> Duration

That many seconds, as a Duration.

90.seconds.total_minutes => 1

minutes

prop minutes(self) -> Duration

That many minutes, as a Duration.

60.minutes.total_hours => 1

hours

prop hours(self) -> Duration

That many hours, as a Duration.

24.hours.total_days => 1

days

prop days(self) -> Duration

That many days, as a Duration of 24 hours each.

2.days.total_hours => 48

weeks

prop weeks(self) -> Duration

That many weeks, as a Duration of seven days each.

2.weeks.total_days => 14

PeriodUnits

trait PeriodUnits

Calendar units (Period) as properties on int: 2.months, 1.years. Distinct from DurationUnits because months and years are calendar-variable and no fixed number of nanoseconds.

months

prop months(self) -> Period

That many calendar months, as a Period.

2.months.to_iso() => "P2M"

years

prop years(self) -> Period

That many calendar years, as a Period of twelve months each.

3.years.to_iso() => "P3Y"

impl PeriodUnits<int>

months

prop months(self) -> Period

That many calendar months, as a Period.

2.months.to_iso() => "P2M"

years

prop years(self) -> Period

That many calendar years, as a Period of twelve months each.

3.years.to_iso() => "P3Y"

impl Add<Instant, Duration>

Output

type Output = Instant

add

def add(self, rhs: Duration) -> Instant

Shifts the instant forward along the UTC timeline.

(Instant.from_epoch_seconds(0) + Duration.of_days(2)).epoch_seconds => 172800

impl Sub<Instant, Duration>

Output

type Output = Instant

sub

def sub(self, rhs: Duration) -> Instant

Shifts the instant backward along the UTC timeline.

(Instant.from_epoch_seconds(120) - Duration.of_minutes(1)).epoch_seconds => 60

impl Sub<Instant, Instant>

Output

type Output = Duration

sub

def sub(self, rhs: Instant) -> Duration

a - b is the duration from b to a: positive when a is later.

(Instant.from_epoch_seconds(90) - Instant.from_epoch_seconds(30)).total_seconds => 60
(Instant.from_epoch_seconds(30) - Instant.from_epoch_seconds(90)).total_seconds => -60

impl Add<Duration>

Output

type Output = Duration

add

def add(self, rhs: Duration) -> Duration

Adds two spans.

(1.hours + 30.minutes).total_minutes => 90

impl Sub<Duration>

Output

type Output = Duration

sub

def sub(self, rhs: Duration) -> Duration

Subtracts one span from another; the result may be negative.

(1.hours - 30.minutes).total_minutes => 30

impl Add<DateTime, Duration>

Output

type Output = DateTime

add

def add(self, rhs: Duration) -> DateTime

Shifts the local datetime forward by a fixed-length span.

DateTime.of(2026, 7, 12, 9, 0, 0).map(|dt| (dt + Duration.of_hours(3)).to_iso()) => Ok("2026-07-12T12:00:00")

impl Sub<DateTime, Duration>

Output

type Output = DateTime

sub

def sub(self, rhs: Duration) -> DateTime

Shifts the local datetime backward by a fixed-length span.

DateTime.of(2026, 7, 12, 9, 0, 0).map(|dt| (dt - Duration.of_hours(10)).to_iso()) => Ok("2026-07-11T23:00:00")

impl Sub<DateTime, DateTime>

Output

type Output = Duration

sub

def sub(self, rhs: DateTime) -> Duration

The span from rhs to self, positive when self is later.

DateTime.of(2026, 7, 12, 0, 0, 0).map(|a| (a - DateTime.of(2026, 7, 11, 0, 0, 0).unwrap_or(a)).total_hours) => Ok(24)

impl Add<OffsetDateTime, Duration>

Output

type Output = OffsetDateTime

add

def add(self, rhs: Duration) -> OffsetDateTime

Shifts the instant forward, keeping the offset it is viewed through.

(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc()) + Duration.of_minutes(1)).instant.epoch_seconds => 60

impl Sub<OffsetDateTime, Duration>

Output

type Output = OffsetDateTime

sub

def sub(self, rhs: Duration) -> OffsetDateTime

Shifts the instant backward, keeping the offset it is viewed through.

(OffsetDateTime.new(Instant.from_epoch_seconds(120), Offset.utc()) - Duration.of_minutes(1)).instant.epoch_seconds => 60

impl Sub<OffsetDateTime, OffsetDateTime>

Output

type Output = Duration

sub

def sub(self, rhs: OffsetDateTime) -> Duration

Positive when self is later, matching Instant - Instant.

(OffsetDateTime.new(Instant.from_epoch_seconds(90), Offset.utc()) - OffsetDateTime.new(Instant.from_epoch_seconds(30), Offset.utc())).total_seconds => 60

impl Add<Date, Period>

Output

type Output = Date

add

def add(self, rhs: Period) -> Date

Shifts the day by whole calendar months, clamping to the target month's end.

Date.new(2026, 1, 15).map(|d| (d + 2.months).to_iso()) => Ok("2026-03-15")

impl Sub<Date, Period>

Output

type Output = Date

sub

def sub(self, rhs: Period) -> Date

Shifting back by a period is adding its negation; month-end clamping applies the same way (sub is not add's inverse near month ends).

Date.new(2026, 3, 15).map(|d| (d - 2.months).to_iso()) => Ok("2026-01-15")

impl Add<Period>

Output

type Output = Period

add

def add(self, rhs: Period) -> Period

Total-months arithmetic: 1.years + 2.months is P1Y2M.

(1.years + 2.months).to_iso() => "P1Y2M"

impl Sub<Period>

Output

type Output = Period

sub

def sub(self, rhs: Period) -> Period

Subtracts one calendar span from another; the result may be negative.

(1.years - 2.months).to_iso() => "P10M"

_FormatPart

type _FormatPart
  FmtYear
  FmtMonth
  FmtDay
  FmtHour
  FmtMinute
  FmtSecond
  FmtWeekdayShortName
  FmtOffsetPart
  FmtOffsetNumericPart
  FmtLiteral(string)
end

The pieces a Format renders, one variant per part. Private: the public surface is the part constants, literal, and Format.of.

Format

opaque Format
  _parts: List<_FormatPart>
end

A timestamp display format: a sequence of parts rendered in order against an OffsetDateTime. Built from the part constants below (year, month, hour, ...), literal(text), and the iso_date / iso_time fragments, composed with Format.of([...]) or +. In operator position a plain string on the right is a literal (iso_date + "T" means literal("T") there); a format's leading token still needs literal(...) (impl Add<string, Format> is barred, a builtin-primitive Self H0573), and so does an element of a Format.of list (List is homogeneous, and no implicit coercion). A top-level Format binding folds at compile time like any other constant.

year

year: Format = Format(_parts=[FmtYear])

The zero-padded four-digit year (wider years keep all their digits).

year.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "1970"

month

month: Format = Format(_parts=[FmtMonth])

The zero-padded two-digit month, 01..=12.

month.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "01"

day

day: Format = Format(_parts=[FmtDay])

The zero-padded two-digit day of month.

day.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "01"

hour

hour: Format = Format(_parts=[FmtHour])

The zero-padded two-digit hour, 00..=23.

hour.render(OffsetDateTime.new(Instant.from_epoch_seconds(3600), Offset.utc())) => "01"

minute

minute: Format = Format(_parts=[FmtMinute])

The zero-padded two-digit minute.

minute.render(OffsetDateTime.new(Instant.from_epoch_seconds(90), Offset.utc())) => "01"

second

second: Format = Format(_parts=[FmtSecond])

The zero-padded two-digit whole second (fractions never render; a format needing them arrives additively, the way to_iso and to_iso_numeric are siblings).

second.render(OffsetDateTime.new(Instant.from_epoch_seconds(5), Offset.utc())) => "05"

weekdayshortname

weekday_short_name: Format = Format(_parts=[FmtWeekdayShortName])

The three-letter English weekday abbreviation (Weekday.short_name).

weekday_short_name.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "Thu"

offset

offset: Format = Format(_parts=[FmtOffsetPart])

The offset in Offset.to_iso form: Z at zero, +02:00 otherwise.

offset.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "Z"

offset_numeric

offset_numeric: Format = Format(_parts=[FmtOffsetNumericPart])

The offset always in numeric form (Offset.to_iso_numeric), which makes UTC renders +00:00 - the spelling SQLite, Postgres, Crystal and Rust write.

offset_numeric.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "+00:00"

literal

def literal(text: string) -> Format

A format rendering text verbatim. Required for a format's leading token and inside Format.of lists; after a leading Format, + "text" says the same thing.

(literal("[") + hour + literal("]")).render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "[00]"

iso_date

iso_date: Format = Format.of([year, literal("-"), month, literal("-"), day])

year-month-day, the date half of ISO 8601 / RFC 3339.

iso_date.render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "1970-01-01"

iso_time

iso_time: Format = Format.of([hour, literal(":"), minute, literal(":"), second])

hour:minute:second, the whole-second time half of ISO 8601. Unlike Time.to_iso, which appends a fraction when the time has one, this fragment never renders fractions.

iso_time.render(OffsetDateTime.new(Instant.from_epoch_seconds(3661), Offset.utc())) => "01:01:01"

impl Format

of

def of(parts: List<Format>) -> Format

The concatenation of parts, in order: the canonical base the + composition is sugar over.

Format.of([hour, literal("h")]).render(OffsetDateTime.new(Instant.from_epoch_seconds(7200), Offset.utc())) => "02h"

render

def render(self, dt: OffsetDateTime) -> string

Renders dt through this format, part by part. OffsetDateTime is the render target because it alone has every part's accessor; a Date/DateTime render would force a Result on offset-bearing formats.

(iso_date + "T" + iso_time + offset_numeric).render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "1970-01-01T00:00:00+00:00"

renderpart

def _render_part(p: _FormatPart, dt: OffsetDateTime) -> string

One part's text against dt, the padding matching the to_iso family.

impl Add<Format>

Output

type Output = Format

add

def add(self, rhs: Format) -> Format

Concatenates two formats.

(iso_date + iso_time).render(OffsetDateTime.new(Instant.from_epoch_seconds(0), Offset.utc())) => "1970-01-0100:00:00"

impl Add<Format, string>

Output

type Output = Format

add

def add(self, rhs: string) -> Format

Appends a literal: in operator position a plain string IS a literal, so iso_date + "T" needs no literal(...).

(hour + "h").render(OffsetDateTime.new(Instant.from_epoch_seconds(7200), Offset.utc())) => "02h"

now!

def now!() -> Instant [time]

The current instant, read from the wall clock. Under --deterministic this reads the gate's virtual clock and replays. @no-doctest: reads the live clock; result is host-dependent

now_at!

def now_at!(offset: Offset) -> OffsetDateTime [time]

The current moment as an OffsetDateTime in offset. @no-doctest: reads the live clock; result is host-dependent

today!

def today!(offset: Offset) -> Date [time]

Today's date in offset. @no-doctest: reads the live clock; result is host-dependent

localoffsetat!

def local_offset_at!(at: Instant) -> Option<Offset> [time]

The host machine's own UTC offset at at, which is what a user of this machine would call local time. Asked per instant and never once, a zone observes daylight saving answers differently either side of a transition: the same machine is +02:00 in January and +03:00 in July.

None when the host cannot place at on its local calendar: an instant far enough out that the platform has no answer for it. A machine with no timezone configured answers UTC, which is an answer and no failure, which leaves None meaning the instant and never the setup.

This reads one offset from the host; it does not open a zone database. Converting between arbitrary named zones remains out of scope (see the module header). @no-doctest: reads the host's timezone; result is host-dependent

local_now!

def local_now!() -> Option<OffsetDateTime> [time]

The current moment as an OffsetDateTime in the host's own offset: now_at! without having to know the offset first. None on the same terms as local_offset_at!. @no-doctest: reads the live clock; result is host-dependent

offsetof_seconds

def _offset_of_seconds(seconds: int) -> Option<Offset>

Offset.of_seconds as an Option: on the seam path an out-of-band value is no answer, with no second failure to tell apart.

daysfrom_civil

def _days_from_civil(year: int, month: int, day: int) -> int

Days since 1970-01-01 for a proleptic-Gregorian y/m/d. Exact for any int year: floor // folds Hinnant's era conditional away (era = y // 400, yoe = y % 400).

civilfrom_days

def _civil_from_days(epoch_day: int) -> Date

The inverse: the Date for a day count since 1970-01-01.

_weekday

def _weekday(epoch_day: int) -> Weekday

Weekday from an epoch-day count. (epoch_day + 4) % 7 is 0 = Sunday … 6 = Saturday (1970-01-01 was a Thursday); floor % leaves it total.

isleap?

def _is_leap?(year: int) -> bool

daysin_month

def _days_in_month(year: int, month: int) -> int

isop

def _iso_p(y: int) -> int

ISO week-numbering helpers.

weeksinisoyear

def _weeks_in_iso_year(y: int) -> int

divtrunc

def _div_trunc(a: int, b: int) -> int

Integer division truncated toward zero (the bare // floors).

_pad2

def _pad2(n: int) -> string

_pad4

def _pad4(n: int) -> string

Left-pad a non-negative integer to at least four digits (for years).

formatyear

def _format_year(y: int) -> string

formatfrac

def _format_frac(nanos: int) -> string

The .fff… fractional-seconds suffix for a sub-second nanosecond count (empty when zero; trailing zeros trimmed).

_pad9

def _pad9(n: int) -> string

trimtrailing_zeros

def _trim_trailing_zeros(s: string) -> string

alldigits?

def _all_digits?(s: string) -> bool

parsedigits

def _parse_digits(s: string) -> Option<int>

Parse a run of ASCII digits into a non-negative int (None if empty or containing a non-digit).

fracto_nanos

def _frac_to_nanos(frac: string) -> Option<int>

A fractional-second digit string to nanoseconds (padded/truncated to 9 digits). None on a non-digit.

padright9

def _pad_right9(s: string) -> string

rangeto_parse

def _range_to_parse<T>(r: Result<T, RangeError>, input: string) -> Result<T, ParseError>

Rewrap a Result's range error as a parse error carrying input.

parsedate_str

def _parse_date_str(s: string) -> Result<Date, ParseError>

parsetime_str

def _parse_time_str(s: string) -> Result<Time, ParseError>

parseoffset_str

def _parse_offset_str(s: string) -> Result<Offset, ParseError>

parsedatetime_str

def _parse_datetime_str(s: string) -> Result<DateTime, ParseError>

parseoffset_datetime

def _parse_offset_datetime(s: string) -> Result<OffsetDateTime, ParseError>

finddatetimesep

def _find_date_time_sep(s: string) -> Option<int>

findoffset_start

def _find_offset_start(s: string) -> Option<int>

durationto_iso

def _duration_to_iso(nanos: int) -> string

parseduration

def _parse_duration(s: string) -> Result<Duration, ParseError>

scanduration

def _scan_duration(body: string) -> Option<int>

Scan the post-P body, accumulating unsigned nanoseconds. Tracks whether the cursor has passed T (past it, M means minutes and no months).

durunit_nanos

def _dur_unit_nanos(c: string, in_time: bool) -> int

fixednanos

def _fixed_nanos(num: string, mult: int) -> Option<int>

A component number (possibly fractional) times a nanosecond multiplier. Sub-nanosecond remainders truncate.

FOLDPROBE_OF

_FOLD_PROBE_OF: Format = Format.of([weekday_short_name, literal(" "), hour, literal(":"), minute])

Top-level bindings are compile-time-evaluated, and this module does not compile at all unless both composition forms fold (HANKI.md section 16) - these two bindings are the probes, one for the Format.of list form and one for the trait-dispatched + form.

FOLDPROBE_ADD

_FOLD_PROBE_ADD: Format = iso_date + "T" + iso_time + offset_numeric