I was working on something in one of my little Django sites and wondered how you make a recurring monthly event in Python? What I mean by recurring event is "every fourth Saturday" or "every first and second Wednesday" and so on.
I did not want to make a dependency on some huge calender server module like Calcore or Twisted's caldav. All I wanted was a function that accepts "every fourth Saturday" and returns me an actual date that I can use for scheduling things.
A quick google didn't come up with anything, so I decided to do it myself. Here are my first and second attempts. The first attempt just works it out mathematically, the second attempt uses a module from the Python standard library.
"""Helper for recurring date."""
DAYS = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
from datetime import date
def eventdate(year, month, target_day, target_ordinal):
"""Convert a human event date to a real date.
For example, 'the third Thursday of the month'
the target_ordinal is 3 and the target_day is 'Thursday'.
"""
day = DAYS.index(target_day.title())
match = 0
for i in range(1, 32):
try:
if date(year, month, i).weekday() == day:
match += 1
if match == target_ordinal:
return date(year, month, i)
except ValueError:
return None
def main():
"""Example when called directly."""
today = date.today()
if today.month == 12:
year = today.year + 1
month = 1
else:
year = today.year
month = today.month + 1
print "Next Month's Linux group is", eventdate(year, month, 'Thursday', 3)
print "Next Month's Python group is", eventdate(year, month, 'Saturday', 4)
# start the ball rolling
if __name__ == "__main__":
main()
That worked quite fine, but like all good Python programmers, I want to be as efficient (/lazy) as possible, surely the standard library can do this for me? Well I found that the calendar module will return a matrix of dates organised by week and day. This works as follows:
"""Event helpers."""
def eventdate(year, month, target_day, target_ordinal):
"""Convert a human event date to a real date.
For example, 'the third Thursday of the month'
the target_ordinal is 3 and the target_day is 'Thursday'.
"""
import calendar
day = getattr(calendar, target_day.upper())
cal = calendar.Calendar()
return cal.monthdatescalendar(year, month)[target_ordinal - 1][day]
def main():
"""Example when called directly."""
from datetime import date
today = date.today()
if today.month == 12:
year = today.year + 1
month = 1
else:
year = today.year
month = today.month + 1
print "Next Month's Linux group is", eventdate(year, month, 'Thursday', 3)
print "Next Month's Python group is", eventdate(year, month, 'Saturday', 4)
# start the ball rolling
if __name__ == "__main__":
main()
This seems to work identically as the above but in less lines of code. I still get the feeling I am trying too hard and I am missing something obvious, but maybe I am just being too much of perfectionist (as always).
If anyone knows or can work out a more efficient method, please do let me know.
<p>You should look at <a class="reference external" href="http://labix.org/python-dateutil">http://labix.org/python-dateutil</a>, it does this and much more.</p>
<div class="highlight"><pre><span class="n">DAYS</span> <span class="o">=</span> <span class="p">[</span><span class="s">'Sunday'</span><span class="p">,</span> <span class="s">'Monday'</span><span class="p">,</span> <span class="s">'Tuesday'</span><span class="p">,</span> <span class="s">'Wednesday'</span><span class="p">,</span> <span class="s">'Thursday'</span><span class="p">,</span> <span class="s">'Friday'</span><span class="p">,</span>
<span class="s">'Saturday'</span><span class="p">]</span>
<span class="k">def</span> <span class="nf">eventdate</span><span class="p">(</span><span class="n">year</span><span class="p">,</span> <span class="n">month</span><span class="p">,</span> <span class="n">target_day</span><span class="p">,</span> <span class="n">target_ordinal</span><span class="p">):</span>
<span class="k">import</span> <span class="nn">sqlite3</span>
<span class="n">db</span> <span class="o">=</span> <span class="n">sqlite3</span><span class="o">.</span><span class="n">connect</span><span class="p">(</span><span class="s">':memory:'</span><span class="p">)</span>
<span class="n">query</span> <span class="o">=</span> <span class="p">(</span><span class="s">"select date('</span><span class="si">%d</span><span class="s">-01-01', '</span><span class="si">%d</span><span class="s"> months', '</span><span class="si">%d</span><span class="s"> days', 'weekday </span><span class="si">%d</span><span class="s">');"</span>
<span class="o">%</span> <span class="p">(</span><span class="n">year</span><span class="p">,</span> <span class="n">month</span><span class="o">-</span><span class="mf">1</span><span class="p">,</span> <span class="mf">7</span><span class="o">*</span><span class="p">(</span><span class="n">target_ordinal</span><span class="o">-</span><span class="mf">1</span><span class="p">),</span> <span class="n">DAYS</span><span class="o">.</span><span class="n">index</span><span class="p">(</span><span class="n">target_day</span><span class="p">)))</span>
<span class="k">return</span> <span class="n">db</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="o">.</span><span class="n">fetchone</span><span class="p">()[</span><span class="mf">0</span><span class="p">]</span>
</pre></div>
<p>I agree with all in this post! Thank you</p>
<p>If you put the following code before returning the value you can handle things like "the last Saturday" by using a negative target_ordinal. Also, it checks to see if the date that is being returned is a zero indicating that that day doesn't come until the next (or came in the previous) week:</p>
<blockquote>
<dl class="docutils">
<dt>if target_ordinal > 0 and weeks[target_ordinal - 1][day] == 0:</dt>
<dd>target_ordinal+=1</dd>
<dt>elif target_ordinal < 0 and weeks[target_ordinal][day] == 0:</dt>
<dd>target_ordinal-=1</dd>
<dt>if target_ordinal < 0:</dt>
<dd>target_ordinal += len(weeks) + 1</dd>
</dl>
</blockquote>