Skip to main content

Type-safe datetimes for Python

Project description

https://img.shields.io/pypi/v/whenever.svg?style=flat-square&color=blue https://img.shields.io/pypi/pyversions/whenever.svg?style=flat-square https://img.shields.io/pypi/l/whenever.svg?style=flat-square&color=blue https://img.shields.io/badge/mypy-strict-forestgreen?style=flat-square https://img.shields.io/badge/coverage-100%25-forestgreen?style=flat-square https://img.shields.io/github/actions/workflow/status/ariebovenberg/whenever/tests.yml?branch=main&style=flat-square https://img.shields.io/readthedocs/whenever.svg?style=flat-square

Foolproof datetimes for maintainable code

Do you cross your fingers every time you work with datetimes, hoping that you didn’t mix naive and aware? or that you diligently converted to UTC everywhere? or that you avoided the pitfalls of the standard library? There’s no way to be sure, until you run your code…

✨ Until now! ✨

Whenever is built from the ground up, explicitly designed to enforce correctness. Mistakes become red squiggles in your IDE, instead of production outages.

Benefits:

  • Fully typed classes with explicit semantics

  • Built on top of the good parts of the standard library

  • Removes footguns and pitfalls of the standard library

  • No dependencies

  • Minimal API surface. No frills or surprises.

Overview

Whenever distinguishes these types of datetimes:

from whenever import (
    UTCDateTime, OffsetDateTime, ZonedDateTime, LocalDateTime, NaiveDateTime
)

and here’s how you can use them:

Feature

Aware

Naive

UTC

Offset

Zoned

Local

comparison

difference

add/subtract timedelta

unambiguous

to/from timestamp

now

  • UTCDateTime is always UTC: simple, fast, and unambiguous. It’s great if you’re storing when something happened (or will happen) regardless of location.

    py311_release_livestream = UTCDateTime(2022, 10, 24, hour=17)

    In >95% of cases, you should use this class over the others. The other classes are most often useful at the boundaries of your application.

  • OffsetDateTime defines a local time with its UTC offset. This is great if you’re storing when something happened at a local time.

    from whenever import hours  # alias for timedelta(hours=...)
    
    # 9:00 in Salt Lake City, with the UTC offset at the time
    pycon23_started = OffsetDateTime(2023, 4, 21, hour=9, offset=hours(-6))

    It’s less suitable for future events, because the UTC offset may change (e.g. due to daylight savings time). For this reason, you cannot add/subtract a timedelta — the offset may have changed!

  • ZonedDateTime accounts for the variable UTC offset of timezones, and is great for representing localized times in the past and future. Note that when the clock is set backwards, times occur twice. Use disambiguate to resolve these situations.

    # always at 11:00 in London, regardless of the offset
    changing_the_guard = ZonedDateTime(2024, 12, 8, hour=11, zone="Europe/London")
    
    # Explicitly resolve ambiguities when clocks are set backwards.
    # Default is "raise", which raises an exception
    night_shift = ZonedDateTime(2023, 10, 29, 1, 15, zone="Europe/London", disambiguate="later")
  • LocalDateTime is a datetime in the system local timezone. This type is great for representing times related to the user’s system.

    print(f"Your timer will go off at {LocalDateTime.now() + hours(1)}.")
  • NaiveDateTime has no timezone or UTC offset. Use this if you need a datetime type detached from the complexities of the real world.

    city_simulation_start = NaiveDateTime(1900, 1, 1, hour=0)

The pitfalls of datetime

Here are some of the issues with the standard library:

  1. Can’t statically enforce aware datetimes. You can only annotate with datetime, which doesn’t distinguish between naive and aware.

    # 🧨 No easy way to enforce that it's aware, you only know at runtime
    def schedule_livestream(d: datetime) -> None: ...
  2. Adding/subtracting timedelta doesn’t account for DST. You may think using timezoned datetimes solves this, but it doesn’t!

    # on the eve of changing the clock forward
    bedtime = datetime(2023, 3, 26, hour=22, tzinfo=ZoneInfo("Europe/Amsterdam"))
    # 🧨 6:00, but should be 7:00 due to DST
    bedtime + timedelta(hours=8)
  3. The meaning of naive datetimes is inconsistent.

    d = datetime(1970, 1, 1, 0)  # a naive datetime
    
    # ⚠️ Treated as a local datetime here...
    d.timestamp()
    d.astimezone(UTC)
    
    # 🧨 ...but assumed UTC here.
    d.utctimetuple()
    email.utils.format_datetime(d)
    datetime.utcnow()
  4. You aren’t prevented from creating non-existent datetimes, which creates subtle havoc once you perform basic operations.

    # ⚠️ No error that the datetime doesn't exist due to DST (clock set forward)
    d = datetime(2023, 3, 26, hour=2, minute=30, tzinfo=ZoneInfo("Europe/Amsterdam"))
    
    # 🧨 No UTC equivalent exists, so it just makes one up
    assert d.astimezone(UTC) == d  # False???
  5. In the face of ambiguity, it guesses. When a datetime occurs twice (due to the clock being set backwards), the fold attribute resolves the ambiguity. However, it silently defaults to 0, negating the explicitness of the attribute.

    # 🧨 Code silently assumes you mean the first occurrence
    d = datetime(2023, 10, 29, 2, 30, tzinfo=ZoneInfo("Europe/Amsterdam"))
  6. Equality between ambiguous datetimes is always False, even while the whole purpose of fold is to disambiguate them.

    # We carefully disembiguate a DST-ambiguous datetime with fold=1...
    x = datetime(2023, 10, 29, 2, 30, tzinfo=ZoneInfo("Europe/Amsterdam"), fold=1)
    
    # 🧨 But nonetheless comparisons with other timezones are *always* False
    y = d.astimezone(UTC)
    assert x == y  # False, even though they're the same time!
  7. Equality behaves differently within the same timezone than between different timezones.

    # 🧨 In the same timezone, fold is ignored...
    before_dst = datetime(2023, 10, 29, 2, 30, tzinfo=ZoneInfo("Europe/Amsterdam"), fold=0)
    after_dst = before_dst_transition.replace(fold=1)
    before_dst == after_dst  # True -- even though they are one hour apart!
    
    # ⁉️ ...but between different timezones, it *is* accounted for!
    after_dst = after_dst.astimezone(ZoneInfo("Europe/Paris"))
    before_dst == after_dst  # False -- even though Paris has same DST behavior as Amsterdam!
  8. Datetime inherits from date, which leads to unexpected behavior. This is widely considered a design flaw in the standard library.

    # 🧨 Breaks when you pass in a datetime, even though it's a date subclass!
    def is_future(dt: date) -> bool:
        return dt > date.today()
    
    # 🧨 Doesn't make sense
    datetime.today()

Why not…?

Pendulum

Pendulum is full-featured datetime library, but it’s hamstrung by the decision to inherit from the standard library datetime. From the issues mentioned above, it only addresses #2 (DST-aware addition/subtraction). All other pitfalls are still present.

python-dateutil

Dateutil attempts to solve some of the issues with the standard library. However, it only adds functionality to work around the issues, instead of removing the pitfalls themselves. It only solves issues if you carefully use the right functions, which isn’t easy to do.

pytz

Pytz brought the IANA timezone database to Python, before zoneinfo was added to the standard library. Now that zoneinfo is available from Python 3.9 onwards, and backported to Python 3.6+, there’s no reason to use pytz anymore. What’s worse, pytz introduces footguns of its own.

Arrow

Pendulum did a good write-up of the issues with Arrow. It doesn’t seem to address any of the above mentioned issues with the standard library.

Maya

By enforcing UTC, Maya bypasses a lot of issues with the standard library. To do so, it sacrifices the ability to represent offset, zoned, and local datetimes. So in order to perform any timezone-aware operations, you need to convert to the standard library datetime first, which reintroduces the issues.

Also, it appears to be unmaintained.

udatetime

udatetime focusses on fast RFC 3339 parsing and formatting, and leaves other concerns by the wayside.

Also, it appears to be unmaintained, and doesn’t support Windows.

DateType

DateType mostly fixes issue #1 (statically enforce aware datetimes), but doesn’t address the other issues. Additionally, it isn’t able to fully type-check all cases.

Heliclockter

This library is a lot more explicit about the different types of datetimes, solving issue #1 (statically enforce aware datetimes). However, it doesn’t address the other issues.

Versioning and compatibility policy

Whenever follows semantic versioning. Until the 1.0 version, the API may change with minor releases. Breaking changes will be announced in the changelog. Since the API is fully typed, your typechecker and/or IDE will help you adjust to any API changes.

Acknowledgements

This project is inspired by the following projects. Check them out!

Contributing

Contributions are welcome! Please open an issue or pull request.

An example of setting up things and running the tests:

poetry install
pytest

⚠️ Note: The tests don’t run on Windows yet. This is because the tests use unix-specific features to set the timezone for the current process. It can be made to work on Windows too, but I haven’t gotten around to it yet.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

whenever-0.1.0.tar.gz (22.9 kB view hashes)

Uploaded Source

Built Distribution

whenever-0.1.0-py3-none-any.whl (24.2 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page