ariebovenberg / whenever

⏰ Modern datetime library for Python
https://whenever.rtfd.io
MIT License
893 stars 15 forks source link

Add a better `timedelta`? #27

Closed ariebovenberg closed 8 months ago

ariebovenberg commented 9 months ago

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

pro:

con:

ariebovenberg commented 9 months ago

One idea I've been thinking of, is having two classes:

  1. Duration for exact durations. Can be measured absolutely in seconds, hours, etc. Cannot handle months, years because the duration of these depends on the context (i.e. months and years have different number of days). Perhaps even days can be disallowed, since it can be confusing whether you mean "exactly 24 hours" or "the same time the next day" (which can often be 23 or 25 hours later due to DST)
d = Duration(hours=34, seconds=1)

# addition/subtraction should work as expected
UTCDateTime.now() + d

# directly convert to common units
d.as_hours()
d.as_minutes()

# arithmetic between other Durations
d + Duration(hours=2)

# but NOT supported
Duration(months=3)  # NOT supported
  1. FuzzyDuration for durations that involve more 'human' durations that don't have an exact interpretation in hours/seconds.
f = FuzzyDuration(year=1)

# addition/subtraction should work as expected
UTCDateTime.now() + f

f.as_hours()  # NOT supported

# can addition be supported in all cases...I wonder...
f + FuzzyDuration(months=3)

RFC5545 could also be an interestig resource on how to handle durations

kseistrup commented 9 months ago

Speaking of timedelta, it would be nice to be able to use seconds() and microseconds() in addition to days(), hours(), and minutes().

(And if you think it doesn't fit in here, I'll be happy to open a separate issue and reference this one.)

ariebovenberg commented 9 months ago

@kseistrup agree, I always thought it weird that timedelta exposed the underlying fields (days, seconds, milliseconds). I consider that almost 'implementation details'. I'd much rather have an opaque duration with in_seconds(), in_microseconds(), in_days(), etc.

ariebovenberg commented 9 months ago

Here is a survey of how other languages and standards call the concepts of "fuzzy" and "exact" durations:

"fuzzy" duration "exact" duration
Python timedelta
C# (NodaTime) Period Duration
Rust (chrono) Duration
JS (Temporal) Duration
Haskell Duration
Java Period Duration
ISO8601 Period
alejcas commented 8 months ago

I Could be wrong but... I would like simplification over a ton of classes... I mean, we all know a month has a relative duration. It depends on the context. But the truth is that Duration(months=1) will never mean or result into nothing if not used with a datetime.

I mean this is not fuzzy: datetime(2023,1,1) + Duration(month=1) even when Duration(month=1) is in fact fuzzy.

In other means, Duration(month=1) has no "result" until it's arithmetically used with a date/datetime.

But either way maybe some assumptions are needed.

ariebovenberg commented 8 months ago

Thanks for the feedback 👍 , you're not the only one remarking about 'too many classes'.

A proposal to prevent "class overload" here: a .add and .subtract method, so you don't even need to interact with duration classes if you don't want to. This is basically what pendulum and arrow do. What do you think?

my_event.add(month=1)
alejcas commented 8 months ago

I prefer using operators (+ , -) but either way it's ok.

A good time delta implementation is what's keeping me from dropping python-dateutil.

ariebovenberg commented 8 months ago

I prefer using operators (+ , -) but either way it's ok.

Here's a thought: we could expose the units as months(), days(), hours(), etc.

Arithmetic would then automatically create the right classes:

my_event + months(3)  # always works
fuzzy_duration = years(1) - weeks(7) + hours(4)
exact_duration = hours(2.5) + seconds(30)

fuzzy_duration.in_seconds()  # not allowed. Only known relative to a DateTime 
exact_duration.in_seconds()  # allowed
alejcas commented 8 months ago

I prefer using operators (+ , -) but either way it's ok.

Here's a thought: we could expose the units as months(), days(), hours(), etc.

Arithmetic would then automatically create the right classes:

my_event + months(3)  # always works
fuzzy_duration = years(1) - weeks(7) + hours(4)
exact_duration = hours(2.5) + seconds(30)

fuzzy_duration.in_seconds()  # not allowed. Only known relative to a DateTime 
exact_duration.in_seconds()  # allowed

Seems awesome!

alejcas commented 8 months ago

Giving it a second thought:

ariebovenberg commented 8 months ago

Using months, days, etc.... adds complication as well. Maybe going back to duration: Duration(months=1) is easier (to the user!). As you will end up again with a lot of different clases.

You can have the benefits of both (fewer classes + less verbosity) if you do:

def months(i):
    return Duration(months=i)

def days(i):
    return Duration(days=i)

One way or another should be Duration(month=1) or Month(1) not duration(month=1) or month(1)

Yeah, I didn't make clear that month is a function; that's why I had them lowercased. Indeed classes should be properly cased 😄

[...] The operation is not commutative as adding a month and then adding a day can have a different value than adding a day and then a month.

It's mathematical properties like this that I'd ideally like to express in different types. I took a first stab at it, you can see the docs for this dev version here. What do you think?

Unless some operator precedence is set which I think complicates all a lot..

Note this isn't such a crazy idea. Established libraries in other languages and RFC 5545 perform these operations from biggest (year) to smallest (day and ) unit.

alejcas commented 8 months ago

Ok ok! Sorry I thought month() was a class constructor returning a month instance.

I will read the docs dev version and look into RFC 5545 🤯 didn’t know that this exists.

ariebovenberg commented 8 months ago

RFC 5545 🤯 didn’t know that this exists.

It's quite a read. Have a look at NodaTime or JS temporal for ideas on what a duration API can look like