Type-safe datetimes for Python
Project description
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:
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: ...
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)
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()
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???
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"))
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!
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!
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.