Converting The Current Time Into A Sentence In Python

3rd October 2021 - 15 minutes read time

Changing time into different formats is quite a common thing to do in programming. I have seen examples that change times into Roman numerals and other formats, but I realised I hadn't seen any code that changed the current time into a sentence.

This means converting a numeric time value into a sentence that can be read. For example, the time 9:05 can be read as "it is five past nine", or if the time is 9:00 then it would read "it is nine o'clock". At it turns out, there are only a few rules that govern doing this.

Getting The Current Time

In Python, the current time can be found using the built in 'time' module. The localtime() method will return a Unix timestamp that will give us the current local time.

import time

current_time = time.localtime()

We can then use the strftime() method to convert this timestamp into the hour, minute and AM/PM values needed for the sentence. The strftime() method takes a formatting string and a timestamp and will return a string representing the format we specified.

There are three format strings we need here, these are as follows.

  • "%I" - Hour (12-hour clock) as a decimal number [01,12]. This is a capital "i".
  • "%M" - Minute as a decimal number [00,59].
  • "%p" - Locale’s equivalent of either AM or PM.

As the strftime() method returns a string we need to cast the hour and minute into an integer value so that we can performs some simple maths on it later. The AM or PM value is a string containing those values, so we don't need to do any type conversion on them. We can use those values as they are later on.

hour = int(time.strftime("%I", current_time))
minute = int(time.strftime("%M", current_time))
am_or_pm = time.strftime("%p", current_time)

With the time values now in hand we can now look at translating the time into the correct words for the sentence.

Translating The Time

To translate the time into a sentence there are a couple of things we need to take into account.

  • Anything before the half hour is referred to as "past" the current hour, and anything after is referred to as "to" the next hour. This means that we need to take into account the minutes when working out the hour. Also, as we want to be able to show "half past" for a few minutes we need to give it a little bit of a buffer zone of a few minutes past half past.
  • Rather than print out the minutes value directly, we need to print out a value of "five", "ten", "a quarter", "twenty" and "twenty five" along with "half" for the half hour mark. This means we need to translate the minutes integer into ranges of numbers that map.

The first thing to do is translate the hour into a text value. This is done by mapping the hour value against an array of hours in text format. As we need to take into account the past/to logic we need to also use the minutes to see if this is the next hour or the current one.

As arrays are mapped from 0 this means we need to use the current array value if the minutes value is over the half value, and the sum of hour - 1 if the minutes values is less than this. Also, as we need to use a small buffer we need to use a value of 33 so that the text "half past" will be shown for at least 3 minutes after the half hour.

This is the function that takes hour and minute values and returns the text value of the hour.

def translate_hour(hour, minute):
    hours = [
        'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', 'TEN', 'ELEVEN', 'TWELVE', 'ONE'
    ]
    if minute >= 33:
        return hours[hour]
    else:
        return hours[hour - 1]

Note that we have "one" in there twice as we need to loop back to the beginning if the hour is "twelve" and the minutes is over 33. By adding the duplicate array entry at the end we don't have to do any other maths here.

Next we need to look at the "past" and "to" values.

Using the same logic as the hour function we need to detect if the minute number falls between two sets of values to be "past" or "to". There are a few ways in which to detect if a number is between two other numbers in Python and in this case we are using the following syntax.

if 3 <= minute < 33:

This means that we are detecting if the value of minute is greater than or equal to 3, or less than 33.

Here is the function to translate the minute integer into a "past", "to", or a blank value if the minute is near the hour.

def translate_to_or_past(minute):
    to_or_past = ''
    if 3 <= minute < 33:
        to_or_past = 'PAST'
    elif 33 <= minute <= 57:
        to_or_past = 'TO'
    return to_or_past

The minutes need to be translated into a series of ranges. For example, anything around the 10 minute mark should translate to the word "ten". This means that values between 8 and 12 should translate to the value of "ten", but also values between 48 and 52 as these will be the "ten to" values.

To translate the minute we could do throw a bunch of if statements together to compare the minute value to a series of ranges, and that would work fine. A better approach, however, is to map the value of the minute against an array of known values. The translate_minute() function below will convert the number of minutes into a text value by performing the following actions.

  • The number of minutes is normalised to be between 0 and 30 by taking away the number of minutes from 60 if the value is over 30. This converts values like 45 to 15, which we can convert to be "a quarter".
  • If the number of minutes is below 3 (which would mean a value around the hour mark) then return a blank string.
  • We then define an array that contains the minute values we need, in much the same way as the translate_hour() function above.
  • Next, we map the number of minutes between 3 and 28 to the number of items in the array (0 to 5). This value is bumped down by 0.4 in order to better fit the values we want and then rounded so that it is an integer value and can be used to select an item from the array. The result of this will be a number between 0 and 5.
  • We then use the integer value we just worked out to pick out the correct value from the minute values array.

With that translated value in hand we then return the value.

def translate_minute(minute):
    if (minute > 30):
        minute = 60 - minute

    if minute >= 3:
        minute_blocks = ['FIVE', 'TEN', 'A QUARTER', 'TWENTY', 'TWENTYFIVE', 'HALF']
        mapped_minute_value = round((0 + (5 - 0) * ((minute - 3) / (28 - 3))) - 0.4)
        minute_name = minute_blocks[mapped_minute_value]
    else:
        minute_name = ''
    return minute_name

If you are wondering what minute numbers map to what minute text values then you can use the following code to print out a full list.

minute_blocks = ['FIVE', 'TEN', 'A QUARTER', 'TWENTY', 'TWENTYFIVE', 'HALF', '']

for minute in range(3, 31):
    mapped_minute_value = round(0 + (5 - 0) * ((minute - 3) / (28 - 3)) - 0.4)
    print(minute, mapped_minute_value, minute_blocks[mapped_minute_value])

Finally, we need to put the different parts of our sentence together so that they read correctly. We create a function called translate_time() that takes in the hour, minute and the am_or_pm variables that we created at the start and passes them through the translation functions. With these values in hand we can then put together the full sentence.

If we find that the translation of the "to" or "past" value is a blank string then this must be an hour value, so we therefore print the "o'clock" text.

def translate_time(hour, minute, am_or_pm):
    translated_minute = translate_minute(minute)
    translated_hour = translate_hour(hour, minute)
    translated_to_or_past = translate_to_or_past(minute)
    if translated_to_or_past == '':
        return "IT IS %s %s %s" % (translated_hour, 'O\'CLOCK', am_or_pm)
    else:
        return "IT IS %s %s %s %s" % (translated_minute, translated_to_or_past, translated_hour, am_or_pm)

To use this function we just need to work out the current hour and minute, and feed those values into the function.

import time

current_time = time.localtime()

hour = int(time.strftime("%I", current_time))
minute = int(time.strftime("%M", current_time))
am_or_pm = time.strftime("%p", current_time)

print(translate_time(hour, minute, am_or_pm))

This will print out the following (which will be correct once a day).

IT IS A QUARTER TO TEN PM

Unit Testing

Whilst I was writing this code I decided to create some unit tests to go along with it. I had written each of the translation functions to take in a number, rather than work out the current time within the function. This meant that I could just pass in all the values I expected and ensure they worked correctly without having to wait until that time of day to make sure the values worked.

Unit testing in Python is simple to do as the framework is built into the core of Python. All you need to do is import the 'unittest' module and set up a skeleton class that extends the unittest.TestCase class. You can then start writing tests.

Here is the very basic setup of a unit test class with a single method that runs a test against translate_to_or_past() and lives in the same file as the other functions here.

import unittest

class TestTimeTranslateMethods(unittest.TestCase):

    def test_translate_to_or_past(self):
        self.assertEqual(translate_to_or_past(0), '')

if __name__ == '__main__':
    unittest.main()

Any method that you prefix with "test_" are automatically picked up and run. The test object has access to a number of assertion methods from unittest.TestCase, but in this case we are just testing that the output is equal to an expected value.

The final two lines are used to run the tests if this file run directly, which would be the case if the file is run on the command line. The __name__ variable is a dunder built into Python that informs the program where the code is being run. If we were to import this code as a module then this code wouldn't run as this check would return false. That means we can package the unit tests in the same file as the code and not have to worry about it.

Running this code will run the test and tell us the output.

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Let's expand this with some more tests to properly test the functionality of the code we just wrote.

We need to test each of the translate_to_or_past(), translate_minute() and translate_hour() methods on their own, but we also need to test the translate_time() method that brings all that code together.

import unittest

class TestTimeTranslateMethods(unittest.TestCase):

    def test_translate_to_or_past(self):
        self.assertEqual(translate_to_or_past(0), '')
        self.assertEqual(translate_to_or_past(1), '')
        self.assertEqual(translate_to_or_past(3), 'PAST')
        self.assertEqual(translate_to_or_past(30), 'PAST')
        self.assertEqual(translate_to_or_past(33), 'PAST')
        self.assertEqual(translate_to_or_past(45), 'TO')        
        self.assertEqual(translate_to_or_past(59), '')

    def test_translate_minute(self):
        self.assertEqual(translate_minute(1), '')
        self.assertEqual(translate_minute(3), 'FIVE')
        self.assertEqual(translate_minute(53), 'FIVE')
        self.assertEqual(translate_minute(13), 'A QUARTER')
        self.assertEqual(translate_minute(47), 'A QUARTER')
        self.assertEqual(translate_minute(18), 'TWENTY')
        self.assertEqual(translate_minute(22), 'TWENTY')
        self.assertEqual(translate_minute(24), 'TWENTYFIVE')
        self.assertEqual(translate_minute(33), 'TWENTYFIVE')
        self.assertEqual(translate_minute(28), 'HALF')
        self.assertEqual(translate_minute(32), 'HALF')

    def test_translate_hour(self):
        self.assertEqual(translate_hour(1, 29), 'ONE')
        self.assertEqual(translate_hour(2, 29), 'TWO')
        self.assertEqual(translate_hour(12, 29), 'TWELVE')
        self.assertEqual(translate_hour(1, 34), 'TWO')
        self.assertEqual(translate_hour(2, 34), 'THREE')
        self.assertEqual(translate_hour(3, 34), 'FOUR')
        self.assertEqual(translate_hour(12, 34), 'ONE')

    def test_translate_time(self):
        self.assertEqual(translate_time(10, 0, 'AM'), 'IT IS TEN O\'CLOCK AM')
        self.assertEqual(translate_time(10, 48, 'AM'), 'IT IS TEN TO ELEVEN AM')
        self.assertEqual(translate_time(8, 55, 'AM'), 'IT IS FIVE TO NINE AM')
        self.assertEqual(translate_time(2, 33, 'PM'), 'IT IS TWENTYFIVE TO THREE PM')

if __name__ == '__main__':
    unittest.main()

Running this produces the following.

....
----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

Note that I would normally use a data provider to run these sort of tests but as Python doesn't have a data provider built in then I have just added lots of different tests for each method. A data provider is a secondary method that feeds data into the test method so that we can test that a variety of different inputs produce output values that we expect. There are one or two third party modules that allow this, but as I am concentrating on core Python I haven't included any here.

Including unit tests for this code (especially around the edge cases involved) means that this code has been pretty well tested and works well. I have added lots more unit tests to the actual code to ensure that the test coverage is. much greater than seen here.

If you want to see the code in full then I have created a GitHub Gist for printing the time as a sentence that contains all of the code here as well as extra unit tests. Feel free to use this code for your needs.

Add new comment

The content of this field is kept private and will not be shown publicly.