pythondatetimepython-dateutilrrule

No dtend as rrule parameters?


I'm trying to find with a dateutil.rrule the first match before a given datetime.

ATTEMPT 1 :

My first attempt was :

dtstart = datetime(2010, 1, 1, 0, 00)
myRule = rrule(freq=WEEKLY, dtstart=dtstart)
result = myRule.before(dtstart)

It doesn't work, the variable result will be equal to None.

If I'm right, when you execute the method before of an rrule, you will unfortunatelly search after the datetime passed to parameter dtstart. So basically, my previous code can be represent as that kind of timeline :

[zone A][TARGET][zone B][dtstart][zone C]

And in that timeline, only the [zone C] is parsed, so my target is never found.

ATTEMPT 2 :

My second attempt kind of works but is realy ugly... The purpose is to shift the [dtstart] before the [TARGET] to get a timeline that looks like that :

[zone A][dtstart][zone B][TARGET][zone C]

To do so, I have to figure out a dtstart that is, for sure, before the target and not that far of it to avoid performance issue. So the [zone B] must exists but must be as short as possible.

seekdt = datetime(2010, 1, 1, 0, 00)
startdt = seekdt - timedelta(days=7) # While my rrule.freq is WEEKLY and the interval is 1 (default), I'm sure that my startdt will be before my target if I shift it by 7 days
myRule = rrule(freq=WEEKLY, dtstart=startdt)
result = muRule.before(seekdt)

As I said, that solution is really ugly... Also it can be hard to define the best shift if I have a much more complexe rrule or rruleset.

The best solution but that actually doesn't seams to exists... :'( :

If rrule could take a dtend instead of a dtstart, that would have been perfect. I could just do something like that :

dtstart = datetime(2010, 1, 1, 0, 00)
myRule = rrule(freq=WEEKLY, dtend=dt)
result = muRule.before(dt)

The question :

Seams odd to me if there isn't any easy feature to do it. Is there any elegant way to reach my goal ? Is my attempt 2 the best solution ?

In other words (the same problematic but as an exercise):

How would you do to find the last Friday the 13th that was in February (based on today) ?


Solution

  • Assuming that dateutils.rrule doesn't provide the functionality out of the box I think your own solution is close but not quite there yet. You need to go backwards in the correct intervals and calculate the last result before your target date. The "correct interval" is important if your rule is not fully specified and the results inherit certain properties from the original date.

    For instance if your rule is MONTHLY you must ensure you end up on the same day of month in the or a previous month. That's tricky because the previous month may not even have that day, e.g. going 1 month backwards from July 31st gives you an incorrect result - you have to go back all the way to May 31st in such case. Another example: going backwards from February 29th with a YEARLY rule - you'll have to go 4 (or sometimes even 8) years backwards.

    In addition you must ensure to skip the number of intervals specified in the rule. For a biweekly rule (e.g. FREQ=WEEKLY;INTERVAL=2) you must go 2 weeks back in time, otherwise your result will be in the wrong week.

    The other pitfall to watch out for is that an interval may be empty. To pick up your example, you may have to go backwards several weeks in order to find a date that matches FREQ=WEEKLY;BYDAY=FR;BYMONTHDAY=13. You must be prepared for that and continue going backwards until you find a non-empty one.

    I'm not familiar with dateutil.rrule, so here is some pseudo code that should lead you the directions:

    rule = … // your rule
    target = … // the pivot date
    dtstart = target
    
    DO 
      DO
        SWITCH(rule.freq)
           CASE YEARLY ->
                dtstart = same month, same day of month rule.interval years before old dtstart
                BREAK
           CASE MONTHLY:
                dtstart = same day of month rule.interval months before old dtstart
                BREAK
           CASE WEEKLY:
                dtstart = 7 * rule.interval days before old dtstart
                BREAK
           CASE DAILY:
                dtstart = rule.interval days before old dtstart
                BREAK
      UNTIL dtstart is a valid date
    
      rule.dtstart = dtstart
      candidate = rule.before(target)
    UNTIL candidate is a date
    
    // candidate is your result
    

    Please note that an rule may very well be specified in a way that yields not a single result. So if your code accepts user input you should be prepared for that and avoid an infinite loop.

    Another way to deal with inherited properties would be to adjust the rule if certain fields are not present. For instance, for a MONTHLY rule without BYMONTHDAY you could set rule.bymonthday to the monthday of your target date. But the fields you have to set vary by FREQ so that has its own pitfalls.