whenever

repository·main·Indexed 25 days ago

https://github.com/ariebovenberg/whenever

A high-performance, type-safe datetime library for Python implemented as a Rust extension module. It provides DST-safe arithmetic and prevents common bugs by distinguishing between naive and aware datetimes at the type level using Instant, ZonedDateTime, and PlainDateTime.

Tokens
34.4K
Snippets
98
Records
172
Agent score
77%

What's inside whenever

  1. Understand the performance characteristics of whenever

    main

    The whenever library is designed with a balanced approach to three competing goals:

    1. Runtime speed: Optimized via a Rust extension that uses hand-written, single-pass byte-level parsers and formatters (avoiding regex and intermediate string objects) and front-loaded computation (storing UTC offsets at construction time).
    2. Import time: Minimized using lazy loading. The __init__.py uses PEP 562 (__getattr__) to defer loading the Rust extension and dependencies (datetime, zoneinfo, pydantic, typing) until they are actually accessed.
    3. Package size: Kept reasonable by implementing non-critical types (like Weekday, YearMonth, MonthDay, IsoWeekDate) in pure Python, even when the Rust extension is active.

    Note on Pure-Python Fallback: If you are using the pure-Python version instead of the Rust extension:

    • It is faster than Arrow and Pendulum for simple operations like now(), ISO parsing, and UTC normalization.
    • It is slower for timezone-heavy operations (like ZonedDateTime construction or conversion) because it relies on pure-Python timezone code instead of the C-optimized zoneinfo module.
  2. Why choose whenever over Pendulum

    main

    While Pendulum is a popular third-party datetime library, it has several limitations compared to whenever. Key reasons to prefer whenever include:

    • Comprehensive Pitfall Coverage: Pendulum only addresses DST arithmetic and timedelta.seconds issues, leaving other standard library pitfalls like ambiguous types and equality edge cases unresolved.
    • Performance: Pendulum's performance has degraded and is often an order of magnitude slower than both the Python standard library and whenever.
    • Predictable Defaults: Pendulum assumes UTC by default when parsing ISO8601 strings without timezone information, which can lead to bugs as these strings typically represent local time. It also uses non-standard disambiguation for ambiguous local times (using the offset after a transition instead of *before`).
    • Reliable Arithmetic: Pendulum's Duration class has design flaws, such as assuming all months are exactly 30 days and handling integer overflows incorrectly during parsing.
  3. Overview of partial date and time types

    main

    The whenever library provides several specialized types for representing partial or specific components of time without the complexity of full datetime/timezone handling:

    TypeRepresentsExample
    DateA calendar date (year, month, day)Date(2024, 3, 15)
    TimeA time of day (hour, minute, second…)Time(14, 30)
    YearMonthA year and month without a dayYearMonth(2024, 3)
    MonthDayA month and day without a yearMonthDay(3, 15)
    IsoWeekDateAn ISO 8601 week date (year, week, weekday)IsoWeekDate(2024, 1, Weekday.MONDAY)
  4. How DST affects arithmetic operations

    main

    The behavior of arithmetic depends on whether you are preserving elapsed time or local clock time. whenever follows conventions (like RFC 5545) to provide DST-safe arithmetic.

    Unit typeExamplesWhat is preservedDuration affected by DST
    Exact unitshours, minutes, secondsElapsed timeNo
    Calendar unitsdays, weeks, months, yearsLocal date and clock timeYes

    Key Rule for Days and Weeks: By convention, days and weeks are treated as calendar units. If you add one day to a meeting scheduled for 9:00 AM, the meeting will still be at 9:00 AM the next day, even if a DST transition occurred overnight that made the day 23 or 25 hours long.

  5. How `whenever` handles datetime equality and DST transitions

    main

    Standard Python datetime equality (following PEP 495) can produce unexpected results during Daylight Saving Time (DST) transitions. Specifically, two aware datetimes might compare as unequal even if they represent the same instant (due to different time zones), or compare as equal even if they represent different moments (due to different fold values in the same time zone).

    whenever solves this by defining equality for ZonedDateTime objects based strictly on the exact instant in time they represent. This ensures consistent behavior regardless of time zone or fold ambiguity.

    >>> dt1 = ZonedDateTime(2024, 10, 27, 2, 30, tz="Europe/Paris", disambiguate="earliest")
    >>> dt2 = ZonedDateTime(2024, 10, 27, 2, 30, tz="Europe/Paris", disambiguate="latest")
    >>> dt1 == dt2  # different instant, same zone
    False
    >>> dt1 == dt1.to_tz("Asia/Tokyo")  # same instant, different zone
    True
  6. Understand Exact vs. Calendar units

    main

    whenever distinguishes between two ways of measuring time:

    1. Exact units (hours, minutes, seconds, nanoseconds): These represent fixed durations on the global timeline. DST transitions do not affect them; adding 24 hours always adds exactly 24 hours of elapsed time.
    2. Calendar units (years, months, weeks, days): These measure calendar distance and preserve the local time of day. For example, adding 1 day to a ZonedDateTime will result in the same local time the next day, even if a DST transition occurred (meaning only 23 or 25 hours might have actually elapsed).

    Month truncation: If a calendar addition results in a day that does not exist in the target month (e.g., adding 1 month to August 31st), the result is truncated to the last valid day of that month (September 30th).

    >>> d = ZonedDateTime(2023, 3, 25, hour=12, tz="Europe/Amsterdam")
    >>> d.add(days=1)    # "same time tomorrow"—only 23 h elapsed due to DST
    ZonedDateTime("2023-03-26 12:00:00+02:00[Europe/Amsterdam]")
    >>> d.add(hours=24)  # exactly 24 hours later—local time shifts
    ZonedDateTime("2023-03-26 13:00:00+02:00[Europe/Amsterdam]")
  7. Understand the four main datetime types in whenever

    main

    The whenever library uses four distinct types to represent time, categorized by whether they represent an exact point in time (absolute) or local time (calendar/clock values).

    TypeRepresents Exact Time?Represents Local Time?
    Instant
    ZonedDateTime
    OffsetDateTime
    PlainDateTime

    When to use which:

    • Instant: Use for absolute timestamps where timezone/calendar context is irrelevant (e.g., logging, database timestamps in UTC).
    • ZonedDateTime: Use when you need an exact point in time that is tied to a specific geographic timezone (handles DST transitions).
    • OffsetDateTime: Use for an exact point in time with a fixed UTC offset, but without the full rules of a timezone.
    • PlainDateTime: Use for "wall clock" time where the timezone is unknown or irrelevant (e.g., "Every day at 9:00 AM").
  8. Understand the difference between Exact and Calendar units

    main

    Date-time arithmetic in whenever relies on two distinct types of units. Understanding which one you are using is critical for predictable behavior during Daylight Saving Time (DST) transitions.

    Exact Units

    • Examples: hours, minutes, seconds.
    • Behavior: These represent fixed durations of elapsed time. Adding an exact unit advances the moment on the global timeline by a specific number of seconds. DST transitions do not change the amount of time that passes.
    • Use Case: Use these when you care about the actual elapsed time (e.g., "The sensor recorded data for exactly 3600 seconds").

    Calendar Units

    • Examples: days, weeks, months, years.
    • Behavior: These are defined by local dates and local clock times. Adding a calendar unit advances the date while attempting to preserve the local clock time. DST transitions do affect the underlying duration to ensure the clock time remains consistent.
    • Use Case: Use these when you care about the structure of the calendar (e.g., "Reschedule this meeting for the same time tomorrow").
  9. Handle whenever warnings

    main

    whenever emits warnings when operations might produce incorrect results due to DST transitions, missing context, or calendar unit composition. All whenever warnings are subclasses of whenever.WheneverWarning, which inherits from Python's built-in UserWarning.

    The warning hierarchy is:

    • UserWarning (stdlib)
      • WheneverWarning
        • CalendarUnitCompositionWarning
        • PotentialDstBugWarning
          • DaysAssumed24HoursWarning
          • NaiveArithmeticWarning
          • StaleOffsetWarning
        • WheneverDeprecationWarning
  10. Use `whenever.Instant` for absolute moments in time

    main

    Use whenever.Instant when you only care about when something happened, without regard for local time or timezones. This is ideal for timestamps like ChatMessage.sent.

    Note that Instant does not have calendar attributes like .year or .hour because it is independent of any calendar system. To access these, you must first convert it to a timezone-aware type using .to_tz() or .to_fixed_offset().

    >>> now = Instant.now()
    Instant("2026-01-23 05:30:15Z")
    >>> now.year
    AttributeError: 'Instant' object has no attribute 'year'
    
    # To access calendar fields:
    >>> now.to_tz("Europe/Amsterdam").year
    2026
    >>> now.to_fixed_offset(0).hour
    5
  11. Avoid implicit timezone assumptions with PlainDateTime

    main

    In standard Python datetime libraries, "naive" datetimes suffer from inconsistent interpretations: they may be treated as the system timezone in some methods (like .timestamp()), assumed to be UTC in others (like .utctimetuple()), or cause errors when compared to aware datetimes.

    whenever solves this using the PlainDateTime type. A PlainDateTime is always explicitly detached from any timezone and never assumes an implicit meaning. To use a PlainDateTime in a timezone-aware context, you must perform an explicit conversion using .assume_utc() or .assume_system_tz().

    >>> d = PlainDateTime("2024-07-04 12:36:56")
    >>> d.assume_utc()
    Instant("2024-07-04 12:36:56Z")
    >>> d.assume_system_tz()
    ZonedDateTime("2024-07-04 12:36:56+02:00[Europe/Berlin]")
  12. Calculate elapsed time vs calendar units

    main

    When subtracting two datetimes using the - operator, whenever returns a TimeDelta, which represents an exact elapsed duration (unambiguous).

    If you require a difference expressed in calendar units (such as years, months, or days), do not use the subtraction operator. Instead, use the .since() or .until() methods on a ZonedDateTime object and specify the desired units via the in_units parameter.

    d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
    d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
    d2 - d1  # exact elapsed time
    TimeDelta("PT30263h")
    d2.since(d1, in_units=["years", "months", "days"])  # calendar units
    ItemizedDateDelta("P3y5m14d")