Code Monkey home page Code Monkey logo

whenever's Introduction

⏰ Whenever

Typed and DST-safe datetimes for Python, available in speedy Rust or pure Python.

Do you cross your fingers every time you work with Python's datetime—hoping that you didn't mix naive and aware? or that you avoided its other pitfalls? or that you properly accounted for Daylight Saving Time (DST)? There’s no way to be sure...

✨ Until now! ✨

Whenever helps you write correct and type checked datetime code. Mistakes become red squiggles in your IDE, instead of bugs in production. It's also way faster than other third-party libraries—and usually the standard library as well. If performance isn't your top priority, a pure Python version is available as well.

Shows a bar chart with benchmark results.

RFC3339-parse, normalize, compare to now, shift, and change timezone (1M times)

⚠️ Note: Whenever is in pre-1.0 beta. The API may change as we gather feedback and improve the library. Leave a ⭐️ on github if you'd like to see how this project develops!

Why not the standard library?

Over 20+ years, Python's datetime has grown out of step with what you'd expect from a modern datetime library. Two points stand out:

  1. It doesn't always account for Daylight Saving Time (DST). Here is a simple example:

    bedtime = datetime(2023, 3, 25, 22, tzinfo=ZoneInfo("Europe/Paris"))
    full_rest = bedtime + timedelta(hours=8)
    # It returns 6am, but should be 7am—because we skipped an hour due to DST!

    Note this isn't a bug, but a design decision that DST is only considered when calculations involve two timezones. If you think this is surprising, you are not alone.

  2. Typing can't distinguish between naive and aware datetimes. Your code probably only works with one or the other, but there's no way to enforce this in the type system!

    # Does this expect naive or aware? Can't tell!
    def schedule_meeting(at: datetime) -> None: ...

Why not other libraries?

There are two other popular third-party libraries, but they don't (fully) address these issues. Here's how they compare to whenever and the standard library:

Whenever datetime Arrow Pendulum
DST-safe ⚠️
Typed aware/naive
Fast

Arrow is probably the most historically popular 3rd party datetime library. It attempts to provide a more "friendly" API than the standard library, but doesn't address the core issues: it keeps the same footguns, and its decision to reduce the number of types to just one (arrow.Arrow) means that it's even harder for typecheckers to catch mistakes.

Pendulum arrived on the scene in 2016, promising better DST-handling, as well as improved performance. However, it only fixes some DST-related pitfalls, and its performance has significantly degraded over time. Additionally, it hasn't been actively maintained since a breaking 3.0 release last year.

Why use whenever?

  • 🌐 DST-safe arithmetic
  • 🛡️ Typesafe API prevents common bugs
  • ✅ Fixes issues arrow/pendulum don't
  • ⚖️ Based on proven and familiar concepts
  • ⚡️ Unmatched performance
  • 💎 Thoroughly tested and documented
  • 📆 Support for date arithmetic
  • ⏱️ Nanosecond precision
  • 🦀 Rust!—but with a pure-Python option
  • 🚀 Support for the latest GIL-related improvements (experimental)

Quickstart

>>> from whenever import (
...    # Explicit types for different use cases
...    Instant,
...    ZonedDateTime,
...    LocalDateTime,
... )

# Identify moments in time, without timezone/calendar complexity
>>> now = Instant.now()
Instant(2024-07-04 10:36:56Z)

# Simple, explicit conversions
>>> now.to_tz("Europe/Paris")
ZonedDateTime(2024-07-04 12:36:56+02:00[Europe/Paris])

# A 'naive' local time can't accidentally mix with other types.
# You need to explicitly convert it and handle ambiguity.
>>> party_invite = LocalDateTime(2023, 10, 28, hour=22)
>>> party_invite.add(hours=6)
Traceback (most recent call last):
  ImplicitlyIgnoringDST: Adjusting a local datetime implicitly ignores DST [...]
>>> party_starts = party_invite.assume_tz("Europe/Amsterdam", disambiguate="earlier")
ZonedDateTime(2023-10-28 22:00:00+02:00[Europe/Amsterdam])

# DST-safe arithmetic
>>> party_starts.add(hours=6)
ZonedDateTime(2022-10-29 03:00:00+01:00[Europe/Amsterdam])

# Comparison and equality
>>> now > party_starts
True

# Formatting & parsing common formats (ISO8601, RFC3339, RFC2822)
>>> now.format_rfc2822()
"Thu, 04 Jul 2024 10:36:56 GMT"

# If you must: you can convert to/from the standard lib
>>> now.py_datetime()
datetime.datetime(2024, 7, 4, 10, 36, 56, tzinfo=datetime.timezone.utc)

Read more in the feature overview or API reference.

Roadmap

  • 🧪 0.x: get to feature-parity, process feedback, and tweak the API:

    • ✅ Datetime classes
    • ✅ Deltas
    • ✅ Date and time of day (separate from datetime)
    • ✅ Implement Rust extension for performance
    • 🚧 Parsing leap seconds
    • 🚧 Improved parsing and formatting
    • 🚧 More helpful error messages
    • 🚧 Intervals
  • 🔒 1.0: API stability and backwards compatibility

Limitations

  • Supports the proleptic Gregorian calendar between 1 and 9999 AD
  • Timezone offsets are limited to whole seconds (consistent with IANA TZ DB)
  • No support for leap seconds (consistent with industry standards and other modern libraries)

Versioning and compatibility policy

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

⚠️ Note: until 1.x, pickled objects may not be unpicklable across versions. After 1.0, backwards compatibility of pickles will be maintained as much as possible.

License

Whenever is licensed under the MIT License. The binary wheels contain Rust dependencies which are licensed under similarly permissive licenses (MIT, Apache-2.0, and others). For more details, see the licenses included in the distribution.

Acknowledgements

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

The benchmark comparison graph is based on the one from the Ruff project.

whenever's People

Contributors

ariebovenberg avatar davila-vilanova avatar dependabot[bot] avatar exoriente avatar fuyukai avatar sherbang avatar tibor-reiss avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

whenever's Issues

Installation seems to put two *.rst files in “public” area

Disclaimer: I am probably just an incompetent python module packager.

After building a wheel, I find CHANGELOG.rst and README.rst outside whenever's directory:

$ uname -or
6.7.1-arch1-1 GNU/Linux
$ python --version
Python 3.11.6
$ git clone https://github.com/ariebovenberg/whenever.git
$ cd whenever
$ python -m build --wheel --no-isolation
$ unzip -l dist/*.whl  # or: bsdtar tvf dist/*.whl 
Archive:  dist/whenever-0.3.0-py3-none-any.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
     1768  1980-01-01 00:00   CHANGELOG.rst
     9892  1980-01-01 00:00   README.rst
    77672  1980-01-01 00:00   whenever/__init__.py
        0  1980-01-01 00:00   whenever/py.typed
     1088  1980-01-01 00:00   whenever-0.3.0.dist-info/LICENSE
    10765  1980-01-01 00:00   whenever-0.3.0.dist-info/METADATA
       88  1980-01-01 00:00   whenever-0.3.0.dist-info/WHEEL
      585  2016-01-01 00:00   whenever-0.3.0.dist-info/RECORD
---------                     -------
   101858                     8 files

The same thing happens when I install whenever from PyPI in a virtual environment:

$ (whenever) pip install whenever
$ (whenever) cd …/site-packages/
$ (whenever) ls -l
drwxr-xr-x     - kas kas  1 Feb 15:32 __pycache__
drwxr-xr-x     - kas kas  1 Feb 15:32 _distutils_hack
.rw-r--r--    18 kas kas  1 Feb 15:32 _virtualenv.pth
.rw-r--r-- 4.329 kas kas  1 Feb 15:32 _virtualenv.py
.rw-r--r-- 1.768 kas kas  1 Feb 15:33 CHANGELOG.rst
.rw-r-----   151 kas kas  1 Feb 15:32 distutils-precedence.pth
drwxr-xr-x     - kas kas  1 Feb 15:32 pip
drwxr-xr-x     - kas kas  1 Feb 15:32 pip-23.3.2.dist-info
.rw-r-----     0 kas kas  1 Feb 15:32 pip-23.3.2.virtualenv
drwxr-xr-x     - kas kas  1 Feb 15:32 pkg_resources
.rw-r--r-- 9.892 kas kas  1 Feb 15:33 README.rst
drwxr-xr-x     - kas kas  1 Feb 15:32 setuptools
drwxr-xr-x     - kas kas  1 Feb 15:32 setuptools-69.0.3.dist-info
.rw-r-----     0 kas kas  1 Feb 15:32 setuptools-69.0.3.virtualenv
drwxr-xr-x     - kas kas  1 Feb 15:32 wheel
drwxr-xr-x     - kas kas  1 Feb 15:32 wheel-0.42.0.dist-info
.rw-r-----     0 kas kas  1 Feb 15:32 wheel-0.42.0.virtualenv
drwxr-xr-x     - kas kas  1 Feb 15:33 whenever
drwxr-xr-x     - kas kas  1 Feb 15:33 whenever-0.3.0.dist-info

It is consistent with pyproject.toml, that reads:

   
include = ["CHANGELOG.rst", "README.rst"]
   

but if every python package/wheel did that, they would [potentially] end up overwriting eachother's files.

Am I doing something wrong?


Context:

I am planning to package this for ArchLinux User Repository (AUR), and for now I am just moving the offending files to the package's individual doc directory, but that is not usually necessary.

Document packages bundled inside wheels

The binary wheels on PyPI currently ship with a shared object compiled using Rust which seems to have some external dependencies: https://github.com/ariebovenberg/whenever/blob/main/Cargo.lock

For now, the corresponding packages including their version and licenses are not documented inside the whenever package itself, thus requiring additional documentation/modification work to ensure license compliance. As far as I have seen, in the source distributions at least the lock file is provided.

It would be great to have the whenever packages/wheels to provide these information for the official builds.

tzdata needed for Windows

Hello,

under windows, zoneinfo.available_timezones() returns an empty set, thus some tests fail. In linux, more often than not the database is provided.

Proposal: add tzdata as a windows test dependency.

Cheers!

the time is wrong

Hey, I saw your reddit post.

Your time is wrong.

from whenever import Instant

import datetime

now = Instant.now()

print(now.format_common_iso())
print(datetime.datetime.now().isoformat())
2024-07-10T15:13:44.653539569Z
2024-07-10T17:13:44.653566

Datetime gets it right.

Pandas Support

I'm not sure if you're much of a pandas user, but I think it would be really nice if these classes could be used in Pandas. Would you be open to providing support via the extension array interface?

error when accessing offset attribute of LocalDateTime

A LocalDateTime object should either supply a valid .offset or refuse access to this attribute. It reports, however, a missing microseconds attribute. Example:

In [1]: from whenever import LocalSystemDateTime
In [2]: now = LocalSystemDateTime.now()
In [3]: now.offset
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
...
File /tmp/venv-we/lib/python3.12/site-packages/whenever/__init__.py:1019, in TimeDelta.from_py_timedelta(cls, td)
...
AttributeError: 'NoneType' object has no attribute 'microseconds'

It's actually possible though to obtain the offset through “naive” conversions:

In [4]: now.naive() - now.as_utc().naive()
Out[4]: TimeDelta(02:00:00)

Which is what I would expect from .offset in the first place – or an AttributeError for .offset.

Discussion: Why pyo3_ffi vs pyo3

Just wondering what the reasoning is for using the lower level (and much more unsafe) pyo3_ffi bindings directly rather than the nicer pyo3 wrapper on top?

ZonedDateTime should expose its 'fold' and 'is_ambiguous' properties in some way

As far as I can tell, ZonedDateTime, which inherits from DateTime AwareDateTime time, does not expose the underlying fold value:
https://whenever.readthedocs.io/en/latest/api.html#whenever.ZonedDateTime
https://whenever.readthedocs.io/en/latest/api.html#whenever.AwareDateTime

It may not be obvious that the fold parameter described by PEP 495 is both an input parameter and an output parameter. The output fold parameter is what allows datetime.datetime to support round trip conversions without loss of information. In other words, epoch_seconds can be converted into datetime.datetime, which can then be converted back to epoch_seconds, and the 2 epoch_seconds will be identical, even during an overlap (when, for example, northern hemisphere countries "fall back" in the fall).

But more practically, the fold parameter of a datetime.datetime object is useful to a client app, because it allows the app to know if a specific epoch_second was converted to a datetime which could be interpreted by humans as ambiguous. Access to the fold parameter is required if the application wants to warn the user of an ambiguous time, something like: "The meeting time is 01:30 on Nov 3, 2024 (Warning: this is the first occurrence of 01:30, not the second occurrence.)"

Currently, ZonedDateTime is a thin wrapper around datetime.datetime. But it does not expose the fold parameter to the calling client. I think it should, but in a way that is more friendly than fold. As I was writing this ticket, it occurred to me that there is a complication: the underlying datetime.datetime class, which is used by ZonedDateTime, does not actually contain enough information to solve this problem properly. We actually needZonedDateTime to expose two bits of information:

  1. is_ambiguous: bool: Whether or not the epoch_seconds mapped to a ZonedDateTime that can be ambiguous, i.e. a "duplicate" or an "overlap". Alternative names for this property may be is_overlap or is_duplicate.
  2. fold: int: 0 if the ZonedDateTime corresponds to the first instance, and 1 if the ZonedDateTime corresponds to the second.

The is_ambiguous parameter can be calculated by brute force within ZonedDateTime. But it would be more efficient to delegate that calculation to a TimeZone class (which does not currently exist in whenever, but I described its contours in #60), so that we get efficient access to the metadata information about the given timezone.

Anyway, I don't know how you want to prioritize this issue. I figure that if you are going make a clean break from datetime, and solve a bunch of problems, you might as well fix this one too. :-)

Nanosecond resolution

Almost all modern datetime libraries support precision up to nanoseconds. There has been some discussion in the past to add it to Python, but there seems to be limited demand and it wouldn't fit into the current API.

Since whenever breaks from the standard library, it makes sense to use nanoseconds, if only for consistency.
There is probably no performance drawback since both fit into a 32-bit integer

Ability to mock the current time (for testing)

For testing, it's often useful to set the current time in order to control the output of .now().

The API would probably look something like this:

from whenever import patch_current_time

with patch_current_time(Instant.from_utc(2000, 8, 4, hour=3), keep_ticking=True):
    Instant.now()  # returns the patched time
    ...  # etc.

Instant.now()  # back to unpatched

Consider relaxing Python version constraint

The current version constraint is >=3.8.1,<4.0 which means installing the project in Poetry projects with just a lower bound constraint (such as >3.11) will fail.

Given that Python makes breaking changes in 3.x releases, this cap doesn't really make much sense. (This is a bit of a contentious topic, ref 1, ref 2, and so on).

Unexpected behavior of disambiguate during dst change foreward

When the dst change means that the clock moves forward disambiguate behaves rather unexpected.
I would have expected that earlier returns the time before the switch forward and later returns the time when the switch is complete.
However whenever is guessing that I might want to add/subtract an hour and does that for me resulting in the following behavior (Which is in brief described here)

# Clock moves from 2 -> 3, so this does not exist
dt = ZonedDateTime(2001, 3, 25, 2, 30, tz='Europe/Berlin', disambiguate='earlier')
print(dt)
dt = ZonedDateTime(2001, 3, 25, 2, 30, tz='Europe/Berlin', disambiguate='later')
print(dt)
2001-03-25T01:30:00+01:00[Europe/Berlin]
2001-03-25T03:30:00+02:00[Europe/Berlin]

What I would have expected

2001-03-25T01:59:59.999...+01:00[Europe/Berlin]
2001-03-25T03:00:00+02:00[Europe/Berlin]

Could you explain the reasoning behind this behavior?

Support handling non-existing time without `DoesntExistInZone` exception

Currently an exception is created when creating non-existing times. However, in some cases it can be useful to (explicitly) handle non-existing times without errors. The most common (and easy) way to do this is to "extrapolate", i.e. use the offset from before the gap. This is also what RFC5545 does:

If the local time described does not occur (when
changing from standard to daylight time), the DATE-TIME value is
interpreted using the UTC offset before the gap in local times.
Thus, TZID=America/New_York:20070311T023000 indicates March 11,
2007 at 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST
(UTC-05:00).

This could be controlled with a nonexistent="raise" | "extrapolate" parameter

A more efficient pickle

Currently, the pickling format is rather straightforward, but inefficient.

On the plus side, the format has been set up so backwards-compatible changes are possible in the future. This means we can optimize the format later without breaking existing pickles.

Allow parsing (and representing?) leap seconds

Many standards and libraries allow for times like 23:59:60 to represent leap seconds, whenever should too. (Note that this is not the same as taking into account historical leap seconds, which almost no libraries or standards do)

Two approaches I can think of for this:

  1. Allow 60 seconds when ingesting or parsing data, but constrain it to 59 in the actual class (this is what JS temporal does)
  2. Representing leap seconds by allowing up to 2 seconds of micro/nanoseconds (this is what Chrono does)

Semantics and need for local system datetime

The oddest of the DateTime classes is undoubtebly LocalDateTime. This is because its value is tied to the system timezone, which may change at any time.

This leads to tricky situations:

  • a LocalDateTime can become non-existent after initialization
  • a LocalDateTime can suddenly require disambiguation after initialization
  • the meaning of the disambiguation can change (i.e. later is chosen, but the system timezone changes so that later is now a more/less further in time)
  • converting from other aware types preserves the same moment in time, but can then abruptly change:
# always preserves the same moment in time, no matter now much you convert
original = UTCDateTime.now()
original.as_utc().as_offset().as_zoned("Asia/Toykyo").as_offset().as_zoned("Europe/Paris") == original

# however, as soon as you use `as_local()`, a race condition can occur in which the local datetime is shifted
intermediate = d.as_local()
os.environ['TZ'] = ... # set the system timezone to something different
intermediate.as_utc() == original  # false!

The current mititagations in place:

  • a warning in the docs about the effect of changing the timezone
  • all LocalDateTime methods check for existence first and raise exceptions.

Potential solutions:

  • Put LocalDateTime in a separate category from the other aware types. Instead of as_local, make the method better reflect that you're entering a new reality.
  • Name the class FloatingLocalDateTime to reflect how it 'floats' on the system timezone
  • Have a way to (explicitly) automatically handle non-existent times
  • an as_offset_local method to avoid race conditions when converting to local datetime at a particular moment.
  • Make is necessary to call to_offset(dismabiguate=...) before comparison operations

Add a better `timedelta`?

Should there be a 'better' class to wrap timedelta?

pro:

  • allows for a better named class, like Duration
  • allows for friendlier API
  • allows implementing 'fuzzy' durations like year or month

con:

  • timedelta is fine. It doesn't have pitfalls like datetime. Wrapping in another class adds complexity

Naming bikeshedding thread

This is an issue to gather bikeshedding discussion about names. To keep churn minimal, any renames will be bundled as much as possible into a small number of releases.

Here are naming discussions. Items are only checked off when a decision has been made.

Comments are welcomed, but note that decisions are not (necessarily) made by majority vote

  • add Error suffix to exceptions: SkippedTime -> SkippedTimeError
  • rename py_datetime() to stdlib_datetime(), perhaps adding the to_ prefix?
  • Rename local() and instant() to to_local() and to_instant(). The original rationale was the the other to_* methods convert between "aware" types, while conversion to local or instant is more fundamental.
  • ...

consider renaming 'naive()' to 'as_naive()'

A very minor observation, but other conversion functions have the form:

  • as_utc()
  • as_offset
  • as_zoned()
  • as_local()

But conversion to NiaveDateTime is:

  • naive()

Consider changing this to as_naive() for consistency.

Use `chrono-tz` for faster timezone switching

Using chrono-tz for switching between timezones will probably lead to a big speedup (expect x3 faster). However, it's currently not possible to implement the disambiguation logic in chrono when it comes to gaps.

In release 0.5, there will likely be a new API that will allow better handling of gaps.

A better tagline

The tagline for the library needs to be short, descriptive, and enticing.

Some I can think of:

  • datetimes made correct and strict
  • datetimes defused and modernized
  • strict and reliable datetimes
  • foolproof datetimes for maintainable code
  • datetime without the pitfalls
  • datetime reimagined
  • Modern datetime API for Python

Feel free to chime in below 👇

In what way is The behavior of UTC, fixed offset, or IANA timezone datetimes very different when it comes to ambiguity

At https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/#1-incompatible-concepts-are-squeezed-into-one-class we read:

The behavior of UTC, fixed offset, or IANA timezone datetimes is very different when it comes to ambiguity, for example.

But:

  1. what "ambiguity" is the author talking about? Are there any examples of it?
  2. what "different behavior" results?

Also:

what does the sentence in question have to do with a section titled "Incompatible concepts are squeezed into one class"? In my opinion, UTC, fixed offset, or IANA timezone datetimes do not represent incompatiable concepts squeezed into one class. Therefore this sentence should be removed from the document or this particular issue needs another section and with clear examples of "ambiguity" and "different behavior" to substantiate it.

As it stands right now, we have claims without examples or substantation in a section they do not belong.

behavior of ZonedDateTime during a "gap" unclear

https://whenever.readthedocs.io/en/latest/api.html#whenever.ZonedDateTime

The ZonedDateTime takes an optional disambiguate parameter which allows "later" and "earlier". I think the documentation may need to be more explicit about the behavior is during a "gap". If the library converts the "earlier" string to fold=0 (as documented in the API docs), then according to PEP 495, the ZonedDateTime will actually pick the later datetime, which contradicts the documentation. Similarly, if "later" is chosen and it sets fold=1, then during a gap, the earlier datetime will be selected.

Secondly, I think the API documentation should probably contain a reference to PEP 495 somewhere around the first mention of the fold parameter, because it's basically impossible to understand what that parameter means without PEP 495.

Thirdly, it looks like the fold parameter is no longer exposed through LocalDateTime, but the API documentation still has some examples with an explicit fold=0 or fold=1 in the constructor of LocalDateTime. (I don't want to create too many tickets, so I included this minor issue here.)

Improving the `[Date|Time|DateTime]Delta` API

There are two design questions for DateDelta:

  • Currently, DateDelta stores years, months, weeks, and days separately. However, in the Gregorian calandar, years are always 12 months, and weeks are always 7 days. It's save on storage to just store months and days. The downside is that users may want to deliberately store unnormalized deltas, such that "24 months" isn't the same as "2 years".
  • On a related note, the DateDelta components may have different signs (P4Y-3M+1W0D). While this is certainly flexible, it's unclear whether this functionality is actually used. Having one single sign would simplify things—although it may result in exceptions when doing arithmetic (whether years(a) + months(b) - years(c) would raise an exception depending on whether the result would have mixed signs).

Recurring date/times

It'd be useful to have some kind of recurrence/range type to indicate "every day", "every 3 weeks", or "every other month".

Probably best to follow established standards (iCalendar, ISO8601) on this.

Handle invalidation of zoned datetimes due to changes to timezone definition

How to handle this

i.e. imagine you store ZonedDateTime(2030, 3, 31, hour=1, tz='America/New_York') which we expect to exist at this moment. However, by the time 2030 rolls around NYC has decided to implement summer time at this exact time, making the datetime invalid. How to handle this?

Note that timezone changes during the runtime of the program will likely never be handled. This would be terrible to implement, and I doubt there is a use case for this.

However, unpickling and from_canonical_str() will be affected. Perhaps a similar approach to JS temporal can be used.

Support Windows Timezones

Hi I like whenever implementation as it address a lot of problems I face when using datetimes.

I would like to see if it fits into whenever to support windows timezones:

See O365 windows timezones support

Windows timezones are horrible in every imaginable situation, but they are used sometimes (mainly outlook).

The problem is that you can get a windows timezone from an Iana one and it's not ambiguos, but not the other way around.
It will be a guess:

For example both Romance Standard Time (wtf is romance standard time anyway??) can be "Europe/Paris", "Europe/Madrid", etc..

Thanks

Need for naïve date/time

Hello, and thank you for the excellent article.

It raised the following question in my mind, and I would be very happy to hear your take on it, as you obviously have thought long and hard on the problem of date/times in Python.

What do you see as the must-have use case for naïve date/time, to warrant its inclusion in whenever?

To me, it only brings confusion, while all the potential use cases seem just as well (or better) served by UTC or local date/times.

More consistent exception types and messages on parsing

There are various parsing functions, such as from_canonical_format and from_rfc2822. However they don't behave consistently on exceptions. They either raise InvalidFormat or ValueError, and mostly don't include a descriptive message.

Going forward, the best solution should be:

  • Remove InvalidFormat exception, just use ValueError everywhere.
  • Ensure the input string is included in a descriptive message, e.g: Could not parse as RFC3339 string: "blabla"
  • Ensure the type and message are properly tested for

Manylinux wheels for x86-64

Trying to download/install the package using pip download --no-deps whenever with the wheel package installed will download the source distribution and try to build it on a regular x86_64 architecture. I would have expected that this common architecture would already be supported out of the box in the form of binary wheels.

The system is an up-to-date Ubuntu 22.04 running Python 3.10.12 with wheel==0.40.0, setuptools==59.6.0 and pip==22.0.2.

Checking kwarg names by identity isn't always safe

Version: whenever==0.6.2

Example code which fails unexpectedly:

import whenever
import whenever._pywhenever
import json

t2 = whenever._pywhenever.Time()
t1 = whenever.Time()

shift_kwargs = json.loads("""{"hour": 3}""")

assert '03:00:00' == str(t2.replace(**shift_kwargs))
assert '03:00:00' == str(t1.replace(**shift_kwargs))
# ^- TypeError: replace() got an unexpected keyword argument: 'hour'

This is caused by comparing kwargs by pointer identity, e.g. here:

whenever/src/time.rs

Lines 425 to 426 in 6b06aa9

for &(name, value) in kwargs {
if name == str_hour {

use tzinfo instead of str to identify the TimeZone in ZonedDateTime

The ZonedDateTime class uses a str to identify the timezone. Please consider using the standard tzinfo class instead. Using tzinfo allows alternative timezone libraries to be used instead of the one baked into whatever (presumably zoneinfo).

I have my own timezone Python library. It provides 2 different implementations of tzinfo. Both are drop-in replacements for zoneinfo, pytz, or any other Python timezone libraries. Given the current implementation of ZonedDateTime, I cannot use my own timezone library with whatever.

Why do I want to use my own? Because zoneinfo has bugs in obscure edge cases. (pytz has even more bugs, as well as the year 2038 problem). My libraries don't.

Rounding

It'd make sense to be able to round datetimes in different ways:

  • by method: (banker's) rounding, ceil, floor
  • by unit: seconds, minutes, hours, etc.
  • by amount: 15 minutes, 30 seconds, etc.

The temporal API probably gives a big hint for doing this.

An interval type

Something that's quite common in datetime libraries is an 'interval' type.

It's not the question whether such a thing could be implemented, but whether it is possible to define the 'just right' abstraction that supports the common use cases without being overly complex.

Questions that come to mind:

  • are intervals always closed-open, or are other variants possible?
  • Can intervals only exist of aware types?

Support ISO8601 periods

Hi,

Thanks for the nice article.

I work on a workflow manager used in weather & climate. Previously I worked on another workflow manager used for cyclic workflows. In that workflow manager, the workflow definition uses ISO8601 periods, like P1Y2M for one year and two months, and PT1M for one minute.

The library used to handle it there is isodatetime, maintained by the UK Met Office (I'd be interested to see how well that library performs in the datetime-pitfalls article). Here's how isodatetime handles ISO8601 time intervals/periods..

In [1]: import metomi.isodatetime.parsers as parse

In [2]: parse.DurationParser().parse('P1Y1M')
Out[2]: <metomi.isodatetime.data.Duration: P1Y1M>

In [3]: parse.DurationParser().parse('P1Y1M').get_days_and_seconds()
Out[3]: (395.0, 0.0)

Pendulum supports it too,

In [4]: import pendulum

In [5]: pendulum.parse('P1Y1M')
Out[5]: Duration(years=1, months=1)

In [6]: d.total_days()
Out[6]: 395.0

However, while metomi-isodatetime handles the ISO8601 recurring intervals,

In [7]: date_time = parse.TimePointParser().parse('1984-01-01')

In [8]: parse.TimeRecurrenceParser().parse('R/1984/P1Y').get_next(date_time)
Out[8]: <metomi.isodatetime.data.TimePoint: 1985-01-01T00:00:00+01:00>

Pendulum doesn't support it

In [9]: pendulum.parse('R/1984/P1Y')
---------------------------------------------------------------------------
ParserError                               Traceback (most recent call last)
Cell In[9], line 1
----> 1 pendulum.parse('R/1984/P1Y')

File /tmp/venv/lib/python3.10/site-packages/pendulum/parser.py:30, in parse(text, **options)
     26 def parse(text: str, **options: t.Any) -> Date | Time | DateTime | Duration:
     27     # Use the mock now value if it exists
     28     options["now"] = options.get("now")
---> 30     return _parse(text, **options)

File /tmp/venv/lib/python3.10/site-packages/pendulum/parser.py:43, in _parse(text, **options)
     40 if text == "now":
     41     return pendulum.now()
---> 43 parsed = base_parse(text, **options)
     45 if isinstance(parsed, datetime.datetime):
     46     return pendulum.datetime(
     47         parsed.year,
     48         parsed.month,
   (...)
     54         tz=parsed.tzinfo or options.get("tz", UTC),
     55     )

File /tmp/venv/lib/python3.10/site-packages/pendulum/parsing/__init__.py:78, in parse(text, **options)
     75 _options: dict[str, Any] = copy.copy(DEFAULT_OPTIONS)
     76 _options.update(options)
---> 78 return _normalize(_parse(text, **_options), **_options)

File /tmp/venv/lib/python3.10/site-packages/pendulum/parsing/__init__.py:125, in _parse(text, **options)
    121 # We couldn't parse the string
    122 # so we fallback on the dateutil parser
    123 # If not strict
    124 if options.get("strict", True):
--> 125     raise ParserError(f"Unable to parse string [{text}]")
    127 try:
    128     dt = parser.parse(
    129         text, dayfirst=options["day_first"], yearfirst=options["year_first"]
    130     )

ParserError: Unable to parse string [R/1984/P1Y]

I installed whenever in my test venv, but reading the docs and API I couldn't find a way to parse these kind of expressions. I guess it's not supported? Any plans to support that?

These expressions are very useful in cyclic workflows, and in climate & weather research, as a way to support solving problems like "iterate over some data weekly, taking into account timezones/dst", or "get the next date for a 6-hours daily forecast workflow task, skipping weekends", or "get the next first day of the second week of a month in a leap calendar".

In cycling workflows, it's also extremely common to have a "calendar", e.g. gregorian, 360-days (12 months of 30 days), 365-days (no leaps years), 366-days (always a leap year), etc.. But this is more research-oriented, and I am not sure if there are other libraries that allow for that (even though it might be common in other fields outside earth-sciences). But FWIW, here's how it's done in Met Office's library (same example used at the top of this description, note the number of days):

In [1]: import metomi.isodatetime.parsers as parse
   ...: 

In [2]: from metomi.isodatetime import data

In [3]: data.CALENDAR.set_mode("360day")
   ...: 

In [4]: parse.DurationParser().parse('P1Y1M')
Out[4]: <metomi.isodatetime.data.Duration: P1Y1M>

In [5]: parse.DurationParser().parse('P1Y1M').get_days_and_seconds()
Out[5]: (390.0, 0.0)

Cheers,

p.s. the reason for the 360-days calendar, for example, “is analytical convenience in creating seasonal, annual and multi-annual means which are an integral part of climate model development and evaluation.” 10.31223/X5M081 (author works in NIWA-NZ, where Cylc was created... Cylc uses metomi-isodatetime 👍, but the same approach is common everywhere climate models are executed, Australia, NZ, Canada, Brazil, USA, UK, here in Spain where we use some custom datetime code, Japan, etc.)

Provide timezone metadata in ZonedDateTime

I took a quick scan through the API doc (https://whenever.readthedocs.io/en/latest/api.html), and I noticed that the IANA TZDB timezones are always referenced symbolically, through a string (e.g. "America/Los_Angeles"). As far as I can tell, there is no explicit TimeZone class. (I assume internally, whenever is using zoneinfo or something similar.) This means that timezone metadata is not available. For example:

  • tz.name() - full unique name of the IANA timezone (e.g. "America/Los_Angeles")
  • tz.abbrev(timestamp) - the IANA abbreviation (e.g. "EST") at a given timestamp
  • tz.stdoffset(timestamp) - the Standard UTC offset at a given timestamp
  • tz.dstoffset(timestamp) - the DST offset at a given timestamp
  • tz.utcoffset(timestamp) - the full UTC offset at a given timestamp (i.e. stdoffset + dstoffset)
  • tz.isambiguous(timestamp) - return True if timestamp generates a ZonedDateTime whose inner DateTime representation is ambiguous
  • tz.transitions(from, until) - list of DST transitions between [from, until)
  • [Added] tz.version() - return the IANA TZDB version identifier (e.g. "2024a"), this is important for deterministic and reproducible integration tests

I also noticed that your AwareDateTime classes do not have methods corresponding to some of these. In other words, the following do not exist:

  • ZonedDateTime.tzname()
  • ZonedDateTime.tzabbrev()
  • ZonedDateTime.stdoffset()
  • ZonedDateTime.dstoffset()
  • ZonedDateTime.utcoffset()

Admittedly, applications which need this information are rare, but they do exist. If the whenever library aims to be a general purpose replacement of the standard datetime library, then access to the TimeZone metadata may need to be added.

Publish pure Python wheels under a separate PyPI name?

I'm a big fan of Alpine/musl Linux so an extra 4 MBs of disk footprint plus all the security implications of a static/vendored runtime (both libgcc and Rust's runtime are in there) are a pretty high cost for Rust's performance boost in a library that wasn't anywhere close to being a bottleneck.

You do already have an opt-out for the Rust stuff but it's not really sufficient – I can't put it in a requirements.txt nor the dependencies section of a pyproject.toml. Would you be willing to create a separate project on PyPI with only the pure Python wheels to make them more accessible?

How do I get an Instant from a SystemDateTime

Before 0.6 I called LocalSystemDateTime(...).as_utc() to get an UTCDateTime.
Now UTCDateTime Is called Instant and LocalSystemDateTime is called SystemDateTime,
however it's unclear how I can get it to normalize.
I thought the Idea is to normalize timestamps to Instant and do all calculations there.
Is this a misunderstanding from my side?

OffsetDateTime should support add and subtract

https://whenever.readthedocs.io/en/latest/overview.html#offsetdatetime

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

I am confused why OffsetDateTime cannot support add and subtract. An OffsetDateTime has a fixed UTC offset. The add and subtraction operations are well-defined under the fixed UTC offset.

Improve API for calculating difference between two datetimes

I was expecting it to return a DateTimeDelta, but it is:

    def __sub__(self, other: _AwareDateTime) -> TimeDelta: ...

Hint: I want to compute (dt - UTCDateTime(1970, 1, 1)).days.

Update: this seems to work: (dt._py_dt - datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)).days

can UTCDateTime be replaced with Instant?

(Spun off from #59)

Ah, so you are using UTCDateTime as a substitute for Instant

The whole discussion on whether to have Instant or not probably belongs in its own issue. The fact that most modern libraries have this, is a strong signal for its usefulness. Having both UTCDateTime and Instant would probably be unnecessary

I can see some value in UTCDateTime, because it captures an intent of the developer, and it's more ergonomic than OffsetDateTime(offset=0).

Can UTCDateTime be replaced with an Instant class? The Instant class is a wrapper around an "epoch seconds". It provides type-safety and encapsulation. There should be an unambiguous 1-to-1 conversion between an Instant and UTCDateTime.

Here are some examples from other libraries:

  • java.time has an Instant class that wraps a long (seconds) and an int (nanoseconds)
    • "the class stores a long representing epoch-seconds and an int representing nanosecond-of-second,"
  • NodaTime implements an Instant class
    • (I don't know how to access its implementation source code)
    • it holds at least an Int64 as "milliseconds from UNIX epoch"
  • GoLang provides a Time struct
    • it wraps 3 values: a uint64, an int64, and a pointer to a Location (i.e. TimeZone) object
    • Go's Time class contains a Location object, which makes it different than the Instant class in java.time and NodaTime
    • Go does not have a DateTime class hierarchy. Everything is derived from the Time object
  • C++ has std::chrono::time_point which is implemented as a std::chrono::duration
    • I have no idea how it is actually implemented under the covers

I don't know, I can see both Instant and UTCDateTime being useful, because they capture slightly different things, and the ergonomics for the end-users are slightly different.

rationale for 5 different DateTime subclasses?

I read the Overview document (https://whenever.readthedocs.io/en/latest/overview.html). Maybe I missed something, but I don't understand the need for 5 different DateTime classes:

  • UTCDateTime - This is just an OffsetDateTime with Offset=+00:00
  • OffsetDateTime - Required to implement ZonedDateTime, and for convenience of capturing fixed UTC offsets
  • ZonedDateTime - Required to support IANA timezones
  • LocalDateTime - This is just ZonedDatetime with a tz={current system timezone}
  • NaiveDateTime - Required to implement OffsetDateTime

The following 3 should be enough: NaiveDateTime, OffsetDateTime and ZonedDateTime. No?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.