3

Suppose I wanted to get when to celebrate birthdays with rrule. Then the frequency YEARLY works fine, except for leap days. There it is in fact only every 4 years.

Is there any way to deal with it directly with rrule?

from datetime import datetime
from dateutil.rrule import rrule, YEARLY

n = 1
print(list(rrule(freq=YEARLY, count=n + 1, dtstart=datetime(1990, 4, 28))))
print(list(rrule(freq=YEARLY, count=n + 1, dtstart=datetime(1992, 2, 29))))

gives

[datetime.datetime(1990, 4, 28, 0, 0), datetime.datetime(1991, 4, 28, 0, 0)]
[datetime.datetime(1992, 2, 29, 0, 0), datetime.datetime(1996, 2, 29, 0, 0)]

The fact that leap days are not even mentioned in the docs makes me wonder if this might simply be a bug.

byyearday

This might help, but only for the 28th of February:

from datetime import datetime
from dateutil.rrule import rrule, YEARLY

n = 5

bday = datetime(1990, 4, 28)
print(list(rrule(freq=YEARLY,
                 byyearday=bday.timetuple().tm_yday,
                 count=n + 1,
                 dtstart=bday)))

bday = datetime(1992, 2, 29)
print(list(rrule(freq=YEARLY,
                 byyearday=bday.timetuple().tm_yday,
                 count=n + 1,
                 dtstart=bday)))

gives

[datetime.datetime(1990, 4, 28, 0, 0), datetime.datetime(1991, 4, 28, 0, 0), datetime.datetime(1992, 4, 27, 0, 0), datetime.datetime(1993, 4, 28, 0, 0), datetime.datetime(1994, 4, 28, 0, 0), datetime.datetime(1995, 4, 28, 0, 0)]
[datetime.datetime(1992, 2, 29, 0, 0), datetime.datetime(1993, 3, 1, 0, 0), datetime.datetime(1994, 3, 1, 0, 0), datetime.datetime(1995, 3, 1, 0, 0), datetime.datetime(1996, 2, 29, 0, 0), datetime.datetime(1997, 3, 1, 0, 0)]
Martin Thoma
  • 124,992
  • 159
  • 614
  • 958
  • Looks like so. Your answer is "yes". – jsbueno Oct 03 '18 at 15:15
  • It is broken. You can help checking if an issue already existis reporting this at https://github.com/dateutil/dateutil/issues, otherwise filling up a new issue. Please, post the issue number back here in the comments if you do so. – jsbueno Oct 03 '18 at 15:24
  • The 3rd party code behavior questioned about is indeed broken. No answer can help but a PR to the project. – jsbueno Oct 03 '18 at 15:25
  • 2
    I posted it as an issue here: https://github.com/dateutil/dateutil/issues/823 – Martin Thoma Oct 03 '18 at 15:34

1 Answers1

4

This is by design and is in fact prominently mentioned in the rrule documentation, in the note that says:

Per RFC section 3.3.10, recurrence instances falling on invalid dates and times are ignored rather than coerced:

Recurrence rules may generate recurrence instances with an invalid date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM on a day where the local time is moved forward by an hour at 1:00 AM). Such recurrence instances MUST be ignored and MUST NOT be counted as part of the recurrence set.

Since February 29th, 1991 never existed, it is an invalid date and is skipped.

This is a limitation of the obsolete RFC 2445, which was later supplanted by RFC 5545, which is updated by RFC 7529. RFC 7529, among other things, adds the SKIP parameter to recurrence rules, which allows you to specify OMIT (default), BACKWARD or FORWARD. dateutil pre-dates RFC 7529 (and even RFC 5545), and is still in the process of being updated. You can track the progress on issue #285.

This particular issue is addressed in PR #522, but that PR is still missing support for one fallback case and has not (as of October 2018) been merged.

For the simple case of a function that returns the same day on each year, falling back to the last day of the month, I recommend instead using relativedelta (until a version with SKIP functionality is released):

from dateutil import relativedelta
from datetime import datetime

def yearly_rule(dtstart, count=None):
    n = 0
    while count is None or n < count:
        yield dtstart + relativedelta.relativedelta(years=n)
        n += 1

if __name__ == "__main__":
    for dt in yearly_rule(datetime(1992, 2, 29), count=5):
        print(dt)

    # Prints:
    # 1992-02-29 00:00:00
    # 1993-02-28 00:00:00
    # 1994-02-28 00:00:00
    # 1995-02-28 00:00:00
    # 1996-02-29 00:00:00

Note that I am using the base datetime (dtstart) in my rule rather than adding 1 year to the previous result. The reason for this is that relativedelta is lossy, so adding relativedelta(years=1) to datetime(1995, 2, 28) will give datetime(1996, 2, 28).

Community
  • 1
  • 1
Paul
  • 10,381
  • 13
  • 48
  • 86