Time-Based Computations
What time is it?
If it takes seven days for the check to arrive, on what day will it arrive?
How many days until my birthday?
While these might seem amusing, they are serious—and common—questions for software developers. Time-based functions are invariably complex because we track time by non-decimal methods. While we might have 10 fingers and a meter has 100 centimeters, when it comes to time an hour has 60 minutes, a week has 7 days, and a month can have from 28 to 31 days. Also, almost every four years you have to account for leap year. Many day-tracking calculations have to take into account the standard business days of Monday through Friday, and the weekend days of Saturday and Sunday. The list of "edge cases" in time calculations is almost infinite!
It is arguably for this reason that Python has three built-in libraries for handling time issues: datetime, time, and calendar, each of which has a lot of sophisticated functionality. Because of the volume of functionality provided by each library, this lesson will focus on the datetime library. In fact, this lesson will focus on the three questions asked at the start, since they provide an excellent introduction to many features of handling time from the perspective of a software developer.
This lesson includes these sections:
A common way to find the current time is with the code in this interactive session:
>>> import datetime >>> print(datetime.datetime.now()) 2010-09-26 20:21:50.813824
And there you have the time!
| Modern Python | The output of datetime.datetime.now() is date-dependent and will
differ from the value shown here. To get a timezone-aware UTC datetime (recommended for
unambiguous timestamps), use datetime.datetime.now(datetime.timezone.utc) or,
more concisely after from datetime import datetime, timezone,
datetime.now(timezone.utc). This requires no additional packages in Python 3.2+. |
How a date is formatted depends on who is looking at it. For a software developer, engineer, system administrator, or scientist, the format shown in that last session is a good way to see time. Because the date is in YYYY-MM-DD format and the time uses the 24-hour clock, you can do easy sorting on the results either by hand or with computers, whereas the American (MM/DD/YYYY) and European (DD/MM/YYYY) date methods require more work for sorting, and the 12-hour clock repeats itself, so times after noon won't sort correctly.
In fact, often people working in these time-sensitive fields rely on alternate time measurement methods like counting seconds since the epoch or the Julian date (JD) system used by the astronomy community. Python supports these alternate methods extremely well, which is one minor reason why Python is so frequently used by the scientific community.
However, most people don't like, or even understand, this way of representing time. It isn't what they're used to seeing and forcing them to use a new time representation format is a good way to lose their interest in your projects. Let's do some formatting to make this a little more natural to the American eye. Continue your interactive session:
>>> now = datetime.datetime.now() >>> format_string = "%x %X" >>> now.strftime(format_string) 09/26/10 20:35:04
From previous lessons, you know what a formatter string does. Now nearly every time object by Python supports the strftime() method, which accepts a format string with any number of predefined mapping keys. "%x %X" fetches the datetime setup you defined on your computer when you set it up.
These predefined mapping keys let you actually precisely map exactly what date and time setup you want your users to experience. The legal mapping keys are:
| key | Meaning |
|---|---|
| %a | Locale's abbreviated weekday name. |
| %A | Locale's full weekday name. |
| %b | Locale's abbreviated month name. |
| %B | Locale's full month name. |
| %c | Locale's appropriate date and time representation. |
| %d | Day of the month as a decimal number [01,31]. |
| %f | Microsecond as a decimal number [0,999999], zero-padded on the left |
| %H | Hour (24-hour clock) as a decimal number [00,23]. |
| %I | Hour (12-hour clock) as a decimal number [01,12]. |
| %j | Day of the year as a decimal number [001,366]. |
| %m | Month as a decimal number [01,12]. |
| %M | Minute as a decimal number [00,59]. |
| %p | Locale's equivalent of either AM or PM. |
| %S | Second as a decimal number [00,61]. |
| %U | Week number of the year (Sunday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Sunday are considered to be in week 0. |
| %w | Weekday as a decimal number [0(Sunday),6]. |
| %W | Week number of the year (Monday as the first day of the week) as a decimal number [00,53]. All days in a new year preceding the first Monday are considered to be in week 0. |
| %x | Locale's appropriate date representation. |
| %X | Locale's appropriate time representation. |
| %y | Year without century as a decimal number [00,99]. |
| %Y | Year with century as a decimal number. |
| %z | UTC offset in the form +HHMM or -HHMM (empty string if the object is naive). |
| %Z | Time zone name (empty string if the object is naive). |
| %% | A literal '%' character. |
Armed with this table and the strftime() function, you can now provide a much more attractive date format customized for your target user. Continue your interactive session now to try it out:
>>> format_string = "%A, %B %d, %Y at %I:%M %p." >>> now.strftime(format_string) Sunday, September 26, 2010 at 8:35 PM.
| Modern Python | The %z and %Z keys produce empty strings for
naive datetimes (those with no timezone information). To get meaningful timezone
output, use a timezone-aware datetime. In Python 3.9+ you can use the standard library
zoneinfo module: from zoneinfo import ZoneInfo followed by
datetime.now(ZoneInfo("Europe/London")). For earlier Python 3 versions,
the third-party pytz package provides the same functionality, but
zoneinfo is now the recommended approach. |
At a glance this should be easy—you just take the date of the month as fetched by datetime.datetime.now() and add 31, right? Lets give it a try. Create count_thirtyone_days.py as shown:
import datetime
now = datetime.datetime.now()
date = now.strftime("%d")
delivery = int(date) + 31
print("Today: %s" % date)
print("Delivery: %s" % delivery)
At a glance, this looks like it should work, but in fact you'll get a response like this:
Today: 29 Delivery: 60
In theory, you could write a bit of code that would handle month rollovers and the leap year. This is not a small undertaking and will probably take more time than you really want to dedicate to the problem of adding thirty-one days to the current date. Also, your result would lack the ability to reformat the results via the strftime() method because it would be a simple integer, not a time object.
Yes, there is a way. The Python datetime library has an object named timedelta, which represents the difference between two dates or times. This difference is called a duration. You can add a timedelta to the current date, and it will account for month rollovers and the leap year. Modify count_thirtyone_days.py as shown:
from datetime import datetime, timedelta # more attractive import
now = datetime.now()
delta = timedelta(31) # create a timedelta of 31 days
delivery = now + delta # add the timedelta to the current datetime.
print("Today: %s" % now.strftime("%d"))
print("Delivery: %s" % delivery.strftime("%d"))
Save and run it. You'll see that it works correctly:
Today: 29 Delivery: 30
You may have noticed that what was printed was string values returned from the strftime() methods on the now() and delivery objects. This means that you can execute further calculations as needed on these objects—they have not been changed at all. This becomes really useful when you want to skip over weekends. Thanks to the datetime object's isoweekday() method which returns a numeric value as shown below, we can write code that skips over weekends with some ease.
| Value returned from isoweekday() | Weekday name |
|---|---|
| 1 | Monday |
| 2 | Tuesday |
| 3 | Wednesday |
| 4 | Thursday |
| 5 | Friday |
| 6 | Saturday |
| 7 | Sunday |
The next example shows how to skip over weekends. It doesn't take into account national or bank holidays, but it is similar to what organizations use to determine when they can expect payments and other letters to arrive.
from datetime import datetime, timedelta
delivery = datetime.now()
delta = timedelta(1)
count = 0
while count < 31:
delivery = delivery + delta
if delivery.isoweekday() in (6, 7):
continue
count += 1
now = datetime.now()
print(now)
print(delivery)
print("Today: %s" % now.strftime("%d"))
print("Delivery: %s" % delivery.strftime("%d"))
Save and run it. It counts only working days.
2010-11-29 10:40:26.439000 2011-01-11 10:40:26.439000 Today: 29 Delivery: 11
The timedelta object can be instantiated with other values than days. Some of the ones you'll use frequently are weeks, hours, minutes, and seconds. All you need to do is add one or more of these items as arguments and the timedelta is constructed accordingly. Create more_deltas.py as shown to see what you get:
from datetime import datetime, timedelta weeks = timedelta(weeks=2) hours = timedelta(hours=1) minutes = timedelta(minutes=100) seconds = timedelta(seconds=1000) composite = timedelta(hours=1, minutes=30) now = datetime.now() print(now) print(now + weeks) print(now + hours) print(now + minutes) print(now + seconds) print(now + composite)
Save and run it (your results will vary unless you traveled back in time to November 29, 2010):
2010-11-29 10:41:06.312000 2010-12-13 10:41:06.312000 2010-11-29 11:41:06.312000 2010-11-29 12:21:06.312000 2010-11-29 10:57:46.312000 2010-11-29 12:11:06.312000
Years and months are not constants, thanks to the leap year issue and the general inconsistency of month durations. Therefore, the timedelta does not accept them as arguments. However, because the other arguments (weeks, hours, minutes, etc.) are constants, timedeltas can handle the leapyear and month durations, which works well for years and not so well for months.
This means you can provide an almost exact year timedelta by simply doing this:
>>> from datetime import timedelta >>> timedelta(365) datetime.timedelta(365)
On the other hand, this obviously fails because months range in duration from 28 to 31 days:
>>> timedelta(30) datetime.timedelta(30)
The general indeterminate duration of a month is exactly why bankers use 30 days as their standard value and why scientists prefer other date formats.
| Modern Python | If you need to add calendar months or years to a date, the standard library
does not provide a direct method. The datetime.replace() method can be used to
construct a new date with an adjusted year or month, taking care to handle month-end edge
cases manually. Alternatively, the third-party dateutil library provides
relativedelta for this purpose:
from dateutil.relativedelta import relativedelta. |
Remember when you were a kid and carefully counted the days until your next birthday? As a programmer you can skip marking off each day and simply write a program to do the work for you. You can write a simple program that:
- Takes your birthday
- Converts your birthday to a datetime object
- Subtracts the current date from your birthday object
- Publishes the results
Ready? Let's do this thing!
The first step is to accept a date as your birthday. Let's use optparse to accept a string to be converted into a datetime object. Then we'll use a new method, datetime.strptime() to not only convert the string to a date, but confirm that it is a valid date. The datetime.strptime() method works like datetime.strftime(), but in reverse, converting strings to date objects. You use the same date-formatting keys as described for datetime.strftime(), which means you can create common formatting strings used across your application for both creating and rendering time objects.
>>> formatter_string = "%m-%d-%Y" # format for MM-DD-YYYY
>>> from datetime import datetime
>>> datetime.strptime("07-24-1967", formatter_string) # The conversion code
datetime.datetime(1967, 7, 24, 0, 0)
But what if someone enters a date such as "1967-07-24" or something like "Python ROCKS" or even "15-35-2010"? Since those does not match the format specified by the formatter string and are not valid dates, datetime.strptime() throws a ValueError exception. This makes it trivial to create datetime validators without having to lean on string methods or even regular expressions, which could handle the rough formatting issue of numbers, but can't as easily handle the confirmation that a date is real.
With what we've learned so far, let's write some birthday.py code:
import logging
from datetime import datetime
from optparse import OptionParser
logging.basicConfig(filename='birthday.log',level=logging.DEBUG)
class InvalidDateFormat(Exception):
pass
def string_to_date(date):
"""
Converts 'MM-DD-YYYY' to a date/time object
or throws an InvalidDateFormat exception
"""
try:
# create a datetime object from the date value
formatter_string = "%m-%d-%Y"
birthday = datetime.strptime(date, formatter_string)
except ValueError as e:
# log the format error then raise it again so it can be handled gracefully
logging.error(e)
raise InvalidDateFormat(e)
return birthday
def birthday_counter(birthday):
"""
Returns the number of days until your birthday.
(not yet fully implemented)
"""
return 100
if __name__ == '__main__':
parser = OptionParser()
parser.add_option('-b', '--birthday', dest="birthday", action="store",
help="Your birthday in MM-DD-YYYY format")
(options, args) = parser.parse_args()
format_error_message = "birthday.py requires a date in MM-DD-YYYY format"
if not options.birthday:
parser.error(format_error_message)
try:
print(birthday_counter(options.birthday))
except InvalidDateFormat:
parser.error(format_error_message)
This looks pretty good, but how do you know it works? Time to write a unittest!
from datetime import datetime
import unittest
from birthday import *
class TestBirthday(unittest.TestCase):
def test_birthday_counter(self):
self.assertEqual(birthday_counter("10-31-1948"), 100)
def test_string_to_date(self):
self.assertRaises(InvalidDateFormat, string_to_date, "10-32-1948")
# create a new datetime object from scratch
datetime_obj = datetime(2012, 10, 31)
self.assertEqual(datetime_obj, string_to_date("10-31-2012"))
if __name__ == "__main__":
unittest.main()
Save and run it; both tests pass. Take a careful look at the second test, which checks that the string_to_date() function works properly. To do that, its second assertion requires a datetime created from scratch. Hence this line of code:
>>> from datetime import datetime >>> datetime(2012, 10, 31) datetime.datetime(2012, 10, 31, 0, 0)
Note that the self.assertEqual(datetime_obj, string_to_date("10-31-1948")) assertion is actually just doing datetime_obj == string_to_date("10-31-1948"). Just as you can add or subtract datetime objects to or from each other, you can also do comparisons against them. This means you can do any of these comparisons:
| Sign | Description |
|---|---|
| == | equals |
| > | greater than |
| >= | greater than or equals |
| < | less than |
| <= | less than or equals |
You can get a lot more specific than days. You can specify hours, minutes, seconds, and microseconds. This is good for constructing tests and setting up deadlines and other time-related points. Create making_time.py as shown:
from datetime import datetime print(datetime(2012, 10, 31)) print(datetime(2012, 10, 31, 12)) print(datetime(2012, 10, 31, 12, 30)) print(datetime(2012, 10, 31, 12, 30, 59)) print(datetime(2012, 10, 31, 12, 30, 59, 300))
Save and run it:
2012-10-31 00:00:00 2012-10-31 12:00:00 2012-10-31 12:30:00 2012-10-31 12:30:59 2012-10-31 12:30:59.000300
The datetime object has integer attributes that are specific year, month, day, hour, minute, second, and microsecond representations for that object. Create time_attributes.py as shown below to demonstrate your options:
from datetime import datetime dt = datetime(2012, 10, 31, 12, 30, 59, 300) print(dt.year) print(dt.month) print(dt.day) print(dt.hour) print(dt.minute) print(dt.second) print(dt.microsecond)
Save and run it:
2012 10 31 12 30 59 300
We now have enough information to finish the birthday.py program and test it adequately. Let's expand the unittest to properly test the birthday_counter() function.
from datetime import datetime
import unittest
from birthday import *
class TestBirthday(unittest.TestCase):
def test_birthday_counter(self):
# will fail on October 31
self.assertTrue(birthday_counter("10-31-1948") > 0)
# will fail on February 1
self.assertTrue(birthday_counter("02-01-1999") > 0)
def test_string_to_date(self):
self.assertRaises(InvalidDateFormat, string_to_date, "10-32-1948")
# create a new datetime object from scratch
datetime_obj = datetime(2012, 10, 31)
self.assertEqual(datetime_obj, string_to_date("10-31-2012"))
if __name__ == "__main__":
unittest.main()
Now, we'll finish the birthday_counter() itself. Because datetime handling can get tricky, we'll include lots of comments and logging.debug statements. Once we confirm that the provided birthday is valid, we can construct an upcoming birthday using attributes from your own birthday and the current year. Give it a try:
import logging
from datetime import datetime, timedelta
from optparse import OptionParser
logging.basicConfig(filename='birthday.log',level=logging.DEBUG)
class InvalidDateFormat(Exception):
pass
def string_to_date(date):
"""
Converts 'MM-DD-YYYY' to a date/time object
or throws an InvalidDateFormat exception
"""
try:
# create a datetime object from the date value
formatter_string = "%m-%d-%Y"
birthday = datetime.strptime(date, formatter_string)
except ValueError as e:
# log the format error then raise it again so it can be handled gracefully
logging.error(e)
raise InvalidDateFormat(e)
return birthday
def birthday_counter(birthday):
"""
Returns the number of days until your birthday.
"""
now = datetime.now()
birthday = string_to_date(birthday)
logging.debug("birthday: %s" % birthday)
# construct the upcoming birthday from this year, your birthday month, and birthday day
upcoming = datetime(now.year, birthday.month, birthday.day)
logging.debug("upcoming: %s" % upcoming)
# Make sure that upcoming is in the future, not the past
if upcoming < now:
upcoming = upcoming + timedelta(365)
logging.debug("fixed upcoming: %s" % upcoming)
# create a timedelta (duration) between the now and your birthday
duration = upcoming - now
logging.debug("duration: %s" % duration)
# return only the days
return duration.days
if __name__ == '__main__':
parser = OptionParser()
parser.add_option('-b', '--birthday', dest="birthday", action="store",
help="Your birthday in MM-DD-YYYY format")
(options, args) = parser.parse_args()
format_error_message = "birthday.py requires a date in MM-DD-YYYY format"
if not options.birthday:
parser.error(format_error_message)
try:
print(birthday_counter(options.birthday))
except InvalidDateFormat:
parser.error(format_error_message)
Save both programs and run the test:
.. ---------------------------------------------------------------------- Ran 2 tests in 0.172s OK
Once the tests pass, run the program:
336
So how many days is it until your birthday?
Handling basic dates and times seems easy for us humans to do in our head because we've been taught from a very young age how to read clocks. However, as soon as you need to calculate adding 156 minutes to the current time or 65 days to the current day, things get very challenging. We often need to stop and think about things because the math is not clear—we are converting from decimal into a chaotic mix of base 60, base 24 and other counting systems. Because of this lack of clarity, we need to take extra special care when writing any kind of date/time code.
