Cannot reconstitute end date with dateutil relativedelta

1.5k views Asked by At

I'm using dateutil.relativedelta to compute age. I was verifying the result when it appeared that in some cases, it is incorrect. The issue occurs only when the day of the start date is 1 and the end of month is 31, but not for all those months! Curiously, results are correct for January and August. I sure am missing something but unable to pinpoint what and where.

I'm using python 2.7.10 on Win32 and dateutil 2.7.5 (but same issue with 2.6.1)

The code to run:

from __future__ import print_function
import datetime
from dateutil.relativedelta import relativedelta

# Main function -- later on, its result is used in dateutil.rrule
# (all irrelevant code expurged)
def age(start, end, includeFirstDay=False):
    # includeFirstDay: whether to include the start date in the result
    if includeFirstDay:
        start += relativedelta(days=-1)
    # Documentation tells nothing about the order of arguments when the first
    # two are dates. The following seems correct.
    return relativedelta(end, start)

# Utility in external module
def eomonth(dt):
    # from the beginning of month, add one month and substract one day
    eom = dt + relativedelta(days=-dt.day+1) + relativedelta(months=+1, days=-1)
    return eom

test_values = [
    # the 3 following lines fail
    (datetime.date(2018, 10, 1), datetime.date(2018, 10, 31)),
    (datetime.date(2018, 10, 1), datetime.date(2018, 11, 1)+relativedelta(days=-1)),
    (datetime.date(2018, 10, 1), eomonth(datetime.date(2018, 10, 1))),
    # the following lines pass
    (datetime.date(2018, 10, 5), eomonth(datetime.date(2018, 10, 5))),
    (datetime.date(2016, 2, 1), datetime.date(2016, 2, 29)),
    (datetime.date(2016, 2, 1), datetime.date(2016, 3, 1)+relativedelta(days=-1)),
    (datetime.date(2016, 2, 1), eomonth(datetime.date(2016, 2, 1))),
    ]

def test(start, end, includeFirstDay=False):
    rd = age(start, end, includeFirstDay=includeFirstDay)
    # calculate end date from age
    d = rd.days + (-1 if includeFirstDay else 0)
    fin = start + relativedelta(years=rd.years, months=rd.months, days=d)
    if fin != end:  # i.e. AssertionError
        print('expected %s, got %s (includeFirstDay=%s)' %(end, fin, includeFirstDay))

for start, end in test_values:
    test(start, end, includeFirstDay=False)
    test(start, end, includeFirstDay=True)

# trying with all months in a year

def make_test(year):
    t = []
    for month in range(1, 13):
        start = datetime.date(year, month, 1)
        # Three ways to find the end of the month
        t.append((start, eomonth(start)))
        t.append((start, start + relativedelta(months=+1, days=-1)))  # only if start day == 1
        for eom in [31, 30, 29, 28]:  # brute force
            try:
                t.append((start, datetime.date(year, month, eom)))
                break
            except:
                pass
    return sorted(list(set(t)))

from pprint import pprint

print('\n*** testing 2016 (leap year)')
test_values = make_test(2016)
pprint(test_values)  # verify ends of months are correct
for start, end in test_values:
    test(start, end, includeFirstDay=False)
    test(start, end, includeFirstDay=True)

print('\n*** testing 2017')
test_values = make_test(2017)
pprint(test_values)  # verify ends of months are correct
for start, end in test_values:
    test(start, end, includeFirstDay=False)
    test(start, end, includeFirstDay=True)

Output:

expected 2018-10-31, got 2018-11-01 (includeFirstDay=True)
expected 2018-10-31, got 2018-11-01 (includeFirstDay=True)
expected 2018-10-31, got 2018-11-01 (includeFirstDay=True)

*** testing 2016 (leap year)
[(datetime.date(2016, 1, 1), datetime.date(2016, 1, 31)),
 (datetime.date(2016, 2, 1), datetime.date(2016, 2, 29)),
 (datetime.date(2016, 3, 1), datetime.date(2016, 3, 31)),
 (datetime.date(2016, 4, 1), datetime.date(2016, 4, 30)),
 (datetime.date(2016, 5, 1), datetime.date(2016, 5, 31)),
 (datetime.date(2016, 6, 1), datetime.date(2016, 6, 30)),
 (datetime.date(2016, 7, 1), datetime.date(2016, 7, 31)),
 (datetime.date(2016, 8, 1), datetime.date(2016, 8, 31)),
 (datetime.date(2016, 9, 1), datetime.date(2016, 9, 30)),
 (datetime.date(2016, 10, 1), datetime.date(2016, 10, 31)),
 (datetime.date(2016, 11, 1), datetime.date(2016, 11, 30)),
 (datetime.date(2016, 12, 1), datetime.date(2016, 12, 31))]
expected 2016-03-31, got 2016-04-02 (includeFirstDay=True)
expected 2016-05-31, got 2016-06-01 (includeFirstDay=True)
expected 2016-07-31, got 2016-08-01 (includeFirstDay=True)
expected 2016-10-31, got 2016-11-01 (includeFirstDay=True)
expected 2016-12-31, got 2017-01-01 (includeFirstDay=True)

*** testing 2017
[(datetime.date(2017, 1, 1), datetime.date(2017, 1, 31)),
 (datetime.date(2017, 2, 1), datetime.date(2017, 2, 28)),
 (datetime.date(2017, 3, 1), datetime.date(2017, 3, 31)),
 (datetime.date(2017, 4, 1), datetime.date(2017, 4, 30)),
 (datetime.date(2017, 5, 1), datetime.date(2017, 5, 31)),
 (datetime.date(2017, 6, 1), datetime.date(2017, 6, 30)),
 (datetime.date(2017, 7, 1), datetime.date(2017, 7, 31)),
 (datetime.date(2017, 8, 1), datetime.date(2017, 8, 31)),
 (datetime.date(2017, 9, 1), datetime.date(2017, 9, 30)),
 (datetime.date(2017, 10, 1), datetime.date(2017, 10, 31)),
 (datetime.date(2017, 11, 1), datetime.date(2017, 11, 30)),
 (datetime.date(2017, 12, 1), datetime.date(2017, 12, 31))]
expected 2017-03-31, got 2017-04-03 (includeFirstDay=True)
expected 2017-05-31, got 2017-06-01 (includeFirstDay=True)
expected 2017-07-31, got 2017-08-01 (includeFirstDay=True)
expected 2017-10-31, got 2017-11-01 (includeFirstDay=True)
expected 2017-12-31, got 2018-01-01 (includeFirstDay=True)

Moreover, end dates for March are even wronger if that can be (and the word exists).

If someone could enlighten me, that would be highly appreciated. Thanks in advance.

3

There are 3 answers

1
Ezz EdinSaleh On
from __future__ import print_function
import datetime
from datetime import date
from dateutil.relativedelta import relativedelta

today= date.today()
end_day=today.replace(day=1)
end_day += relativedelta(months=1)
end_day -= relativedelta(days=1)

print(end_day)
0
papin On

My function age() was badly written. Here the correct one:

def age(start, end, includeFirstDay=False):
    r = relativedelta(end, start)
    if includeFirstDay:
        r += relativedelta(days=1)
    return r

Explanation: offsetting start by -1 day moved the date to the previous month, and fooled relativedelta which got confused by my intentions.

0
papin On

Actually, I'm doing things wrong. Reasons why January and August behave "normally": for those months, preceding months also have 31 days. Reason why March looked strange to me: February is 2 or 3 days less than 31 days

For example:

start = datetime.date(2018, 3, 1)
end = start + relativedelta(months=+1, days=-1)
a = age(start, end, True)
print 'start:', start
print 'end  :', end
print 'age  :', a

Output:

start: 2018-03-01
end  : 2018-03-31
age  : relativedelta(months=+1, days=+3)

I was expecting something like relativedelta(days=+31).

Depending on how start and end dates are provided, relativedelta returns different results. Exemple:

relativedelta(datetime.date(2018, 4, 1), datetime.date(2018, 3, 1))

gives

relativedelta(months=+1)

and

relativedelta(datetime.date(2018, 3, 31), datetime.date(2018, 2, 28))

gives

relativedelta(months=+1, days=+3)

No matter how it is done, the difference between the start and end dates should always be equal to 31 days.

My conclusion is to not use relativedelta to compute age for single months between the 1st and the last day (all days included) when one of the date is computed.

Or modify the function age(), which I probably will do.

EDIT

In a not too far future, I will delete this question because I think the point it raises deserves to be included in the dateutil documentation within the "corner cases" category.