moondate

    moondate — dates, times, instants, durations and cron expressions for MoonBit. The proleptic Gregorian calendar with the arithmetic that goes with it; RFC 3339, the four shapes TOML names, HTTP's IMF-fixdate and ASN.1's two time forms, read and written; and a cron package that answers when an expression fires next.

    date
    time
    duration
    cron
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    267

    #moondate

    Dates, times, instants, durations and cron expressions. The proleptic Gregorian calendar with the arithmetic that goes with it, the text forms four specifications define, and a package that answers when a schedule fires next.

    let stamp = @moondate.parse("2026-09-22T14:30:00+08:00")
    stamp.epoch() // seconds since 1970, for a moment that names one
    stamp.astimezone(Utc) // the same instant, read elsewhere
    stamp.strftime("%Y-%m-%d %H:%M %Z") // written the way Python writes it
    stamp.plus(@moondate.Span::new(days=3L))

    @cron.parse("0 9 * * MON-FRI").next(stamp) // nine o'clock on the next weekday

    #The four shapes

    A moment is one of four things, and the difference is not decoration:

    What it isWritten
    ZonedAn instant. Has an epoch, can be read in another zone.2026-09-22T14:30:00Z
    PlainA wall clock. An alarm, an opening time.2026-09-22T14:30:00
    DayA date with no time.2026-09-22
    ClockA time with no date.14:30:00

    Turning a Plain into an instant needs a zone nobody has supplied, so epoch and astimezone answer None for it rather than guessing. These are exactly the four TOML 1.0.0 names; RFC 3339 defines only the first.

    #What it reads and writes

    FormWhere it is used
    RFC 3339 / ISO 8601TOML, JSON Schema's date-time, most of the wire
    IMF-fixdate, RFC 850, asctimeHTTP's Date header (RFC 9110 §5.6.7) — all three read, only the first written
    UTCTime, GeneralizedTimeASN.1, so X.509 validity (ITU-T X.680 §47)
    strftime / strptimeThe % directives C89 defines and Python implements

    The two-digit-year windows differ between HTTP and X.509 — 68/69 against 49/50 — and each is applied where its specification says, because a certificate read by HTTP's rule expires a century early.

    #Python's datetime, in this family's words

    Everything in that module's __all__ has a counterpart: date is Date, time is Time, datetime is Moment, timedelta is Span, timezone is Zone. toordinal, isocalendar, isoweekday, combine, replace, fromtimestamp, timestamp, total_seconds, timetuple, ctime, isoformat, strftime and strptime are all here under those names or the obvious one.

    Two deliberate differences. The resolution is a nanosecond rather than a microsecond, because a protocol timestamp wants it. And there is no now(): reading the clock is I/O, this package has none, so a caller passes what its own clock said to Moment::of_epoch.

    tzinfo in its full sense — named zones, daylight saving, the IANA database — is not here. Zone is Python's timezone, a fixed offset. A zone database is a different thing with a different update cadence, and %Z writes UTC or nothing rather than inventing a name it cannot recover.

    #cron

    @cron.parse("*/15 9-17 * * MON-FRI") // every quarter hour in working hours
    @cron.parse("@daily")
    @cron.parse("30 2 1 * *").next(now) // half past two on the first

    Five fields as crontab(5) defines them, or six with seconds in front. *, n, a-b, */n, a-b/n, lists of those, month and day names, ? as Quartz writes it, and the @-shorthands. Both 0 and 7 are Sunday.

    Day-of-month and day-of-week are or-ed when both are restricted, which is Vixie cron's rule: 0 0 13 * 5 is the thirteenth or any Friday, not Friday the thirteenth. When one is * they are and-ed. This surprises everyone once, so it has a test that says so.

    An expression naming a day that does not exist — 0 0 30 2 * — answers None rather than searching forever.

    A package rather than a repository of its own: cron's whole output is a Moment, so a caller wanting it would have to download this module anyway, and package-level isolation already keeps it out of a build that does not import it.

    #What is checked

    The calendar against the leap rule's three cases, the ordinal and the epoch against the numbers Python prints, the ISO week date against the years where the first of January belongs to the week before, and every text form against the example printed in the specification that defines it. strftime and strptime are checked against each other on every directive pair.

    #Install

    moon add moonbitstack/moondate

    #Licence

    Apache-2.0. See LICENSE.

    Refused

    pub(all) suberror Refused {
    Range(field~ : String, got~ : Int, low~ : Int, high~ : Int)
    Malformed(String)
    Truncated(String)
    } derive(Eq,
    Debug
    )

    Why a date, a time or a text form was refused.

    A calendar that accepted the thirty-first of February would be no use as a check, so every constructor here validates and says what was wrong.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Date

    pub(all) struct Date {
    year : Int
    month : Int
    day : Int
    } derive(Compare, Eq,
    Debug
    )

    A day on the proleptic Gregorian calendar.

    Proleptic means the Gregorian rules are applied before 1582 as well, which is what every interchange format in reach assumes and what makes arithmetic over the whole range uniform.
    impl Show for Date

    Date::compare

    fn Date::compare(Date, Date) -> Int

    Date::days

    fn Date::days(self : Date) -> Int

    Days since 1970-01-01, negative before it.

    Howard Hinnant's days_from_civil: shift the year to start in March so the leap day falls at the end, then the month lengths follow a pattern with no table.

    Date::diff

    fn Date::diff(self : Date, other : Date) -> Span

    One date less another, as a span of whole days (Python's date - date).

    Date::equal

    fn Date::equal(Date, Date) -> Bool

    Date::isocalendar

    fn Date::isocalendar(self : Date) -> (Int, Int, Int)

    The ISO 8601 week date: the year the week belongs to, the week number, and the day of the week with Monday as 1 (Python's date.isocalendar).

    The year is not always the calendar year. A week belongs to the year that holds its Thursday, so the first of January can fall in the last week of the year before — which is exactly the case a hand-rolled week number gets wrong.

    Date::isoweekday

    fn Date::isoweekday(self : Date) -> Int

    The day of the week with Monday as 1 and Sunday as 7 (Python's isoweekday).

    [Date::weekday] counts from Sunday as 0, which is what cron and C's tm_wday use; ISO counts from Monday as 1. Both are here because both are asked for, and picking one silently is how an off-by-one gets into a schedule.

    Date::new

    fn Date::new(year : Int, month : Int, day : Int) -> Date raise Refused

    A date, checked against the calendar.

    Date::not_equal

    fn Date::not_equal(x : Date, y : Date) -> Bool

    Date::of_days

    fn Date::of_days(n : Int) -> Date

    The date n days after 1970-01-01, the inverse of [Date::days].

    Date::of_isocalendar

    fn Date::of_isocalendar(year : Int, week : Int, weekday : Int) -> Date raise Refused

    The date named by an ISO week date (Python's date.fromisocalendar).

    Date::of_ordinal

    fn Date::of_ordinal(n : Int) -> Date

    The date with that ordinal (Python's date.fromordinal).

    Date::op_ge

    fn Date::op_ge(x : Date, y : Date) -> Bool

    Date::op_gt

    fn Date::op_gt(x : Date, y : Date) -> Bool

    Date::op_le

    fn Date::op_le(x : Date, y : Date) -> Bool

    Date::op_lt

    fn Date::op_lt(x : Date, y : Date) -> Bool

    Date::ordinal

    fn Date::ordinal(self : Date) -> Int

    Days since 0001-01-01, that day being 1 (Python's date.toordinal).

    The proleptic Gregorian ordinal, which is what makes two dates subtractable without going through an epoch.

    Date::output

    fn Date::output(self : Date, logger : &Logger) -> Unit

    Date::plus_days

    fn Date::plus_days(self : Date, n : Int) -> Date

    The date n days later, or earlier when n is negative.

    Date::plus_months

    fn Date::plus_months(self : Date, n : Int) -> Date

    The date n months later, clamped to the end of the month it lands in.

    The thirty-first of January plus one month is the twenty-eighth or twenty-ninth of February, because there is no thirty-first. Every calendar library makes this choice and they all make the same one.

    Date::replace

    fn Date::replace(self : Date, year? : Int, month? : Int, day? : Int) -> Date raise Refused

    The same date with some parts replaced (Python's date.replace).

    Date::to_repr

    Date::to_string

    fn Date::to_string(self : Date) -> String

    Date::weekday

    fn Date::weekday(self : Date) -> Int

    The day of the week, Sunday being 0.

    1970-01-01 was a Thursday, which is where the 4 comes from.

    Date::yearday

    fn Date::yearday(self : Date) -> Int

    The day of the year, the first of January being 1.

    Moment

    pub(all) enum Moment {
    Zoned(date~ : Date, time~ : Time, zone~ : Zone)
    Plain(date~ : Date, time~ : Time)
    Day(date~ : Date)
    Clock(time~ : Time)
    } derive(Eq,
    Debug
    )

    A point in time, in one of the four shapes a format names.

    TOML 1.0.0 has exactly these four and gives them these meanings; RFC 3339 defines only Zoned. The distinction is not decoration: Plain is a wall clock, which is what an alarm or an opening time is, and turning it into an instant needs a zone nobody has supplied.
    impl Show for Moment

    Moment::asn1_text

    fn Moment::asn1_text(self : Moment) -> String raise Refused

    A moment as an ASN.1 GeneralizedTime, which is the form DER uses from 2050 on and the one that needs no windowing rule to read back.

    Moment::astimezone

    fn Moment::astimezone(self : Moment, zone : Zone) -> Moment?

    The same instant read in another zone (Python's astimezone).

    None for the three shapes that are not instants: moving a wall clock to another zone would be inventing the zone it was written in.

    Moment::combine

    fn Moment::combine(date : Date, time : Time, zone? : Zone) -> Moment

    A date and a time as one wall-clock moment (Python's datetime.combine).

    zone makes it an instant instead; leaving it off keeps it a wall clock, which is what an opening time or an alarm is.

    Moment::ctime

    fn Moment::ctime(self : Moment) -> String

    The moment in C's ctime form: Sun Nov 6 08:49:37 1994.

    The day is space-padded rather than zero-padded, which is the one thing that distinguishes this form from every other.

    Moment::date

    fn Moment::date(self : Moment) -> Date?

    The date out of a moment (Python's datetime.date()).

    None for a bare time, which has no date to give.

    Moment::diff

    fn Moment::diff(self : Moment, other : Moment) -> Span?

    One moment less another, for the two shapes that can be subtracted.

    None when they are not comparable: an instant and a wall clock differ by an amount that depends on a zone nobody supplied, and a bare time and a bare date are not the same kind of thing.

    Moment::epoch

    fn Moment::epoch(self : Moment) -> Int64?

    Seconds since 1970-01-01T00:00:00Z, for a moment that names one.

    None for the three shapes that do not: a wall clock, a bare date and a bare time are not instants until someone supplies the zone they are read in.

    Moment::equal

    fn Moment::equal(Moment, Moment) -> Bool

    Moment::http_text

    fn Moment::http_text(self : Moment) -> String raise Refused

    A moment as an HTTP date (RFC 9110 §5.6.7): Sun, 06 Nov 1994 08:49:37 GMT.

    This is the one form a Date header may take. The two obsolete forms are read by [http] and never written, which is what §5.6.7 asks for.

    Moment::isocalendar

    fn Moment::isocalendar(self : Moment) -> (Int, Int, Int)?

    The ISO week date of the moment's date.

    Moment::isoformat

    fn Moment::isoformat(self : Moment, sep? : Char) -> String

    The moment as ISO 8601 text, which is what [Show] writes (Python's isoformat).

    sep is the character between the date and the time, T by default; Python allows any, and a space is what a log line wants.

    Moment::not_equal

    fn Moment::not_equal(x : Moment, y : Moment) -> Bool

    Moment::of_epoch

    fn Moment::of_epoch(seconds : Int64, nano? : Int, zone? : Zone) -> Moment

    The moment seconds after 1970-01-01T00:00:00Z (Python's datetime.fromtimestamp, with tz always supplied).

    Reading the clock is not this package's job — it has no I/O — so a caller that wants "now" passes what its own clock said.

    Moment::ordinal

    fn Moment::ordinal(self : Moment) -> Int?

    Days since 0001-01-01 of the moment's date (Python's toordinal).

    Moment::output

    fn Moment::output(self : Moment, logger : &Logger) -> Unit

    Moment::plus

    fn Moment::plus(self : Moment, span : Span) -> Moment

    The moment span later.

    A bare date moves by whole days and a bare time wraps within the day, because that is all each of them can mean.

    Moment::replace

    fn Moment::replace(self : Moment, year? : Int, month? : Int, day? : Int, hour? : Int, minute? : Int, second? : Int, nano? : Int, zone? : Zone) -> Moment raise Refused

    The same moment with some parts replaced (Python's datetime.replace).

    Moment::strftime

    fn Moment::strftime(self : Moment, format : StringView) -> String

    A moment written by a format string of % directives (Python's strftime).

    The directives are the ones C89 defines and Python implements, less the two that need a locale (%c, %x, %X are the C locale's) and the one that needs a zone database (%Z writes UTC or the offset, never a name like CEST, because a name cannot be recovered from an offset).

    %Y %y %m %dyear, two-digit year, month, day
    %H %I %M %S %fhour, twelve-hour, minute, second, microseconds
    %p %j %a %A %b %BAM/PM, day of year, day and month names
    %z %Zoffset as +HHMM, and UTC
    %G %V %u %wISO year, ISO week, ISO weekday, weekday from Sunday
    %%a per cent sign

    Moment::time

    fn Moment::time(self : Moment) -> Time

    The time out of a moment (Python's datetime.time()).

    Midnight for a bare date, which is the time it names.

    Moment::timestamp

    fn Moment::timestamp(self : Moment) -> Double?

    Seconds since the epoch as a Double, fraction included (Python's datetime.timestamp).

    Moment::timetuple

    fn Moment::timetuple(self : Moment) -> (Int, Int, Int, Int, Int, Int, Int, Int, Int)

    The nine fields C's struct tm holds, in its order (Python's timetuple): year, month, day, hour, minute, second, weekday from Monday as 0, day of the year, and whether daylight saving applies — always -1 here, because that needs a zone database this package does not carry.

    Moment::to_repr

    Moment::to_string

    fn Moment::to_string(self : Moment) -> String

    Moment::utcoffset

    fn Moment::utcoffset(self : Moment) -> Span?

    How far the moment's clock is from UTC (Python's utcoffset).

    Moment::weekday

    fn Moment::weekday(self : Moment) -> Int?

    The day of the week of the moment's date, Sunday being 0.

    Moment::zone

    fn Moment::zone(self : Moment) -> Zone?

    The zone a moment carries, or None when it carries none (Python's tzinfo).

    Span

    pub(all) struct Span {
    nanos : Int64
    } derive(Compare, Eq,
    Debug
    )

    A length of time, to the nanosecond, with no calendar attached.

    A span is not a number of months: a month is not a fixed length, so adding one is a calendar operation (Date::plus_months) rather than arithmetic.

    Span::abs

    fn Span::abs(self : Span) -> Span

    The span with its sign dropped.

    Span::add

    fn Span::add(self : Span, other : Span) -> Span

    Two spans added.

    Span::compare

    fn Span::compare(Span, Span) -> Int

    Span::equal

    fn Span::equal(Span, Span) -> Bool

    Span::neg

    fn Span::neg(self : Span) -> Span

    The span negated.

    Span::new

    fn Span::new(days? : Int64, hours? : Int64, minutes? : Int64, seconds? : Int64, millis? : Int64, micros? : Int64, nanos? : Int64) -> Span

    A span made of the parts Python's timedelta constructor takes.

    Span::not_equal

    fn Span::not_equal(x : Span, y : Span) -> Bool

    Span::of

    fn Span::of(unit : Span, n : Int64) -> Span

    A span of n of these.

    Span::op_ge

    fn Span::op_ge(x : Span, y : Span) -> Bool

    Span::op_gt

    fn Span::op_gt(x : Span, y : Span) -> Bool

    Span::op_le

    fn Span::op_le(x : Span, y : Span) -> Bool

    Span::op_lt

    fn Span::op_lt(x : Span, y : Span) -> Bool

    Span::parts

    fn Span::parts(self : Span) -> (Int, Int, Int)

    The whole days in a span, and the seconds and nanoseconds left over — the three fields Python's timedelta normalises to.

    Span::seconds

    fn Span::seconds(self : Span) -> Int64

    The span as whole seconds, rounded towards zero.

    Span::sub

    fn Span::sub(self : Span, other : Span) -> Span

    One span less another.

    Span::to_repr

    Span::total_seconds

    fn Span::total_seconds(self : Span) -> Double

    The span as seconds, fraction included (Python's timedelta.total_seconds).

    Time

    pub(all) struct Time {
    hour : Int
    minute : Int
    second : Int
    nano : Int
    } derive(Compare, Eq,
    Debug
    )

    A time on a twenty-four hour clock, to the nanosecond.

    A second of 60 is allowed: RFC 3339 §5.6 permits it for a leap second, and a timestamp that records one is a real timestamp that a reader should not throw away.
    impl Show for Time

    Time::compare

    fn Time::compare(Time, Time) -> Int

    Time::equal

    fn Time::equal(Time, Time) -> Bool

    Time::nanos

    fn Time::nanos(self : Time) -> Int64

    Nanoseconds since midnight.

    Time::new

    fn Time::new(hour : Int, minute : Int, second? : Int, nano? : Int) -> Time raise Refused

    A time, checked against the clock.

    Time::not_equal

    fn Time::not_equal(x : Time, y : Time) -> Bool

    Time::op_ge

    fn Time::op_ge(x : Time, y : Time) -> Bool

    Time::op_gt

    fn Time::op_gt(x : Time, y : Time) -> Bool

    Time::op_le

    fn Time::op_le(x : Time, y : Time) -> Bool

    Time::op_lt

    fn Time::op_lt(x : Time, y : Time) -> Bool

    Time::output

    fn Time::output(self : Time, logger : &Logger) -> Unit

    Time::replace

    fn Time::replace(self : Time, hour? : Int, minute? : Int, second? : Int, nano? : Int) -> Time raise Refused

    The same time with some parts replaced (Python's time.replace).

    Time::to_repr

    Time::to_string

    fn Time::to_string(self : Time) -> String

    Zone

    pub(all) enum Zone {
    Utc
    Offset(Int)
    } derive(Eq,
    Debug
    )

    How far the clock is from UTC, in minutes east of it.

    Utc and Offset(0) are both midnight-at-Greenwich, but they are not the same thing to write down: RFC 3339 §4.3 gives -00:00 to a timestamp whose offset is unknown, and Z to one that is genuinely UTC.

    Zone::equal

    fn Zone::equal(Zone, Zone) -> Bool

    Zone::not_equal

    fn Zone::not_equal(x : Zone, y : Zone) -> Bool

    Zone::to_repr

    asn1

    fn asn1(src : StringView) -> Moment raise Refused

    An ASN.1 UTCTime or GeneralizedTime (ITU-T X.680 §47), as DER writes them.

    This is how a certificate spells its validity. DER requires the Z and whole seconds; UTCTime's two-digit year is read as RFC 5280 §4.1.2.5.1 says — 49 and under are 2000s, 50 and over are 1900s, which is not the same rule HTTP uses.

    day

    let day : Span

    end_of_day

    let end_of_day : Time

    hour

    let hour : Span

    http

    fn http(src : StringView) -> Moment raise Refused

    An HTTP date read (RFC 9110 §5.6.7).

    The preferred IMF-fixdate, and the two obsolete forms a recipient must still accept: RFC 850's Sunday, 06-Nov-94 08:49:37 GMT and asctime's Sun Nov 6 08:49:37 1994. A two-digit year is read as RFC 6265 §5.1.1 says: 69 and under are 2000s, 70 and over are 1900s.

    leap

    fn leap(year : Int) -> Bool

    Whether year has a twenty-ninth of February.

    Every fourth year, except every hundredth, except every four-hundredth — the rule that makes the calendar year 365.2425 days long.

    length

    fn length(year : Int, month : Int) -> Int

    How many days month has in year.

    max_date

    let max_date : Date

    max_year

    let max_year : Int

    midnight

    let midnight : Time

    Midnight, and the last representable moment of a day.

    min_date

    let min_date : Date

    The earliest and latest dates this calendar writes with a four-digit year.

    Python's date.min and date.max are 1-01-01 and 9999-12-31; the arithmetic here works outside that range, so these are a convention for writing rather than a limit on computing.

    min_year

    let min_year : Int

    The years Python's calendar spans (MINYEAR and MAXYEAR).

    minute

    let minute : Span

    nanosecond

    let nanosecond : Span

    Nanoseconds in a second, a minute, an hour and a day.

    of_isoformat

    fn of_isoformat(src : StringView) -> Moment raise Refused

    A moment read from ISO 8601 text (Python's fromisoformat), which is what [parse] does.

    parse

    fn parse(src : StringView) -> Moment raise Refused

    A moment read from RFC 3339 text, in whichever of the four shapes it is written.

    2026-09-22T14:30:00Z and …+08:00 are Zoned; without an offset it is Plain; a bare 2026-09-22 is Day and a bare 14:30:00 is Clock. TOML 1.0.0 names these four and allows a space in place of the T, which is what a person writes; RFC 3339 §5.6 allows it too, under NOTE.

    resolution

    let resolution : Span

    The smallest difference two moments can have (Python's resolution).

    A nanosecond rather than Python's microsecond: the clock here is finer, which is what a protocol timestamp and a benchmark both want.

    second

    let second : Span

    strptime

    fn strptime(src : StringView, format : StringView) -> Moment raise Refused

    A moment read by a format string of % directives (Python's strptime).

    The directives are the ones [Moment::strftime] writes. Literal characters in the format must match the text exactly, except that whitespace in the format matches any run of whitespace, which is what C's strptime does and what makes a format survive a double space.

    The result is Zoned when the text carried an offset, Plain when it carried a date and a time, Day when only a date, and Clock when only a time — the same four shapes [parse] answers with.