How to check if relativedelta object is negative

Viewed 2034

I have some problem with using relativedelta objects - cannot determine if "delta is negative". What I'm trying is:

from dateutil.relativedelta import relativedelta
print relativedelta(seconds=-5) > 0

that gives me True which is counter intuitive.

print relativedelta(seconds=5) > 0

also return True. Is there a way to check if "delta" represented by relativedata object is negative?

I'm currently using a workaround in the form of separate function to check if delta is negative but I expected that there is more elegant solution. Here is code I'm using:

def is_relativedelta_positive(rel_delta):
    is_positive = True
    is_positive &= rel_delta.microseconds >= 0
    is_positive &= rel_delta.seconds >= 0
    is_positive &= rel_delta.minutes >= 0
    is_positive &= rel_delta.hours >= 0
    is_positive &= rel_delta.days >= 0 
    return is_positive
3 Answers

TL;DR There is not a clear and intuitive definition of comparison between relativedelta objects, so comparison is not implemented in dateutil. If you want to compare them, you'll need to make an arbitrary choice about the ordering.

The problem

The semantics of comparisons between relativedelta are undefined because relativedelta objects themselves don't represent a fixed period of time. You can see this issue on github as to why this is a problem.

There are two major problems with comparisons between relativedelta objeccts. The more straightforward one is that relativedelta has "absolute" components (the singular arguments) such as day, hour, etc. So consider:

from dateutil.relativedelta import relativedelta
from datetime import datetime

rd1 = relativedelta(day=5, hours=5)
rd2 = relativedelta(hours=8)

for i in range(4, 7):
    dt = datetime(2014, 1, i)
    print((dt + rd1) > (dt + rd2))

# Result:
# True
# False
# False

Since each relativedelta does not represent a fixed amount of time, it's not necessarily meaningful to compare which one is "bigger" or "smaller" than the other.

The other problem is that even if you restrict yourself to the "relative" components of the relativedelta, all units larger than week depend on what they are being added to, so:

rd3 = relativedelta(months=1)
rd4 = relativedelta(days=30)

for i in range(1, 4):
    dt = datetime(2015, i, 1)
    print((dt + rd3) > (dt + rd4))

# Result:
# True
# False
# True

Possible comparison operations

That said, there are a few possible definitions that you can meaningfully use if you want a semi-arbitrary but consistent definition of "less than" for relativedelta.

One somewhat limited version of this is to say that "absolute" components will throw an error and to set a fixed value for the "relative" components:

def rd_to_td(rd):
    for comp in ['year', 'month', 'day', 'hour', 'minute', 'second',
                 'microsecond', 'weekday', 'leapdays']:
        if getattr(rd, comp) is not None:
            raise ValueError('Conversion not supported with component ' + comp)

    YEAR_LEN = 365.25
    MON_LEN = 30

    days = (rd.years or 0) * YEAR_LEN
    days += (rd.months or 0) * MON_LEN
    return timedelta(days=days, hours=rd.hours, minutes=rd.minutes,
                     seconds=rd.seconds, microseconds=rd.microseconds)

Closest to universal comparison

The above works for limited cases, but probably most universal comparison method you can define is to simply add both to a fixed date and compare the results:

from datetime import datetime

def lt_at_dt(rd1, rd2, dt=datetime(1970, 1, 1)):
    return (dt + rd1) < (dt + rd2)

If you want this as a key for sorting (rather than for pairwise comparisons), this same definition of "less than" can be used to convert relativedelta to timedelta (which is a fixed period of time):

def rd_to_td_at_dt(rd, dt=datetime(1970, 1, 1)):
    return (dt + rd1) - dt

Note The previous two definitions are about the more general operation of comparison between relativedelta objects. To know if one of these is negative, just compare the result to a relativedelta representing zero, or convert to timedelta by one of the above methods and compare to timedelta(0).

Finally, I will note that in the forthcoming 2.7.0 release of dateutil, relativedelta will define __abs__ (GH PR #472), so your original definition of positivity can be reduced to abs(rd) == rd. However, as Martijn points out, abs(relativedelta(days=20, hours=-1)) != relativedelta(days=20, hours=-1), but by most reasonable definitions, that relative delta is always a positive offset.

relativedelta() objects do not implement the necessary comparison methods. In Python 2 that means that they are thus compared by their type name, and numbers are always sorted before any other objects; this makes these objects larger than integer values whatever their values. In Python 3 you'd get a TypeError instead.

Your work-around doesn't account for an absolute positive value, relativedelta(years=1, seconds=-5) would move your datetime by almost a whole year forward, so it could hardly be named a negative delta.

You'd have to compare individual attributes instead (so years, months, days, hours, minutes, seconds and microseconds). Depending on your use-case, you may have to convert those to a total number of seconds:

def total_seconds(rd, _yeardays=365.2425, _monthdays=365.2425/12):
    """approximation of the number of seconds in a relative delta"""
    # year and month durations are averages, taking into account leap years
    total_days = rd.years * _yeardays + (rd.months * _monthdays) + rd.days
    total_hours = total_days * 24 + rd.hours
    total_minutes = total_hours * 60 + rd.minutes
    return total_minutes * 60 + rd.seconds + (rd.microseconds / 1000000)

then use this to do your comparisons:

if total_seconds(relativedelta(seconds=-5)) > 0:

The total_seconds() function produces an approximation; relative deltas handle leap years and the right number of days per month, so their actual effect on a datetime object will vary depending on that datetime value. However, the above should be good enough for the majority of cases. It does completely ignore the absolute components of the relative delta (hour, year, the singular names, that state a fixed value rather than a delta).

An elegant and concise way of checking whether a relativedelta object is negative:

from datetime import datetime
from dateutil.relativedelta import relativedelta

def is_negative(rd: relativedelta) -> bool:
''' Check whether a relativedelta object is negative'''
    try:
        datetime.min + rd
        return False
    except OverflowError:
        return True

Some examples:

is_negative(relativedelta(hours=1))
>> False
is_negative(relativedelta(hours=0))
>> False
is_negative(relativedelta(hours=-1))
>> True
is_negative(relativedelta(days=1, hours=-1))
>> False
is_negative(relativedelta(days=-1, hours=1))
>> True
Related