Human-readable time intervals in Python

Expressing time intervals in a format that is readable by humans is an old problem that every frontend developer will eventually face. While there are plenty of solutions available (this one is my favourite), I required a function that could provide different levels of granularity in it’s “human-readable” description of time.

Take, for example, a component that tells the user how long in aggregate they have been using a service. If they have been using it for a few hours, it would be appropriate to display ## hours, ## minutes, ignoring the seconds and milliseconds. If they have been using the service for several years, it would probably not make sense to provide minute-level-accuracy – simply ## years, ## days would suffice. So for this situation, granularity=2 seems appropriate.

If you are creating a countdown timer however, it may be more useful to have a higher level of granularity, displaying days, hours, minutes and seconds (granularity=4).

The below script contains two functions: time_breakdown, which handles the unit conversions, and time_breakdown_string, which handles the “human readability” logic:

semantic_time.py

from math import floor

def time_breakdown(ms):
 """Converts an integer representing number of milliseconds into a dictionary
 representing days, hours, minutes, seconds and milliseconds. Output is
 rounded down to the nearest whole number.

 Parameters:
  ms (integer): number of milliseconds.

 Returns:
  (dictionary): breakdown of total days, hours, minutes, seconds and
  milliseconds.

 Example usage:
  >>> time_breakdown = time_breakdown(123456789)
  {'day': 1, 'hr': 10, 'min': 17, 'sec': 36, 'ms': 789}
 """

 ms_out = ms % 1000;
 sec = floor((ms % (1000 * 60)) / 1000)
 min = floor((ms % (1000 * 60 * 60)) / 1000 / 60)
 hr = floor((ms % (1000 * 60 * 60 * 24)) / 1000 / 60 / 60)
 day = floor(
  (ms % (1000 * 60 * 60 * 24 * 365)) / 1000 / 60 / 60 / 24
 )

 return {
  "day": day,
  "hr": hr,
  "min": min,
  "sec": sec,
  "ms": round(ms_out)
 }

def time_breakdown_string(ms, granularity=5):
 """Converts an integer representing number of milliseconds into a string that
 uses natural language to represent the time quantity.

 Parameters:
  ms (integer): number of milliseconds.
  granularity (integer): the level of detail required.

 Returns:
  (string): string that uses natural language to represent the time quantity.

 Example usage:
  >>> time_breakdown_string(123456789)

  '1 day 10 hours 17 minutes 36 seconds 789 milliseconds'

  >>>time_breakdown_string(
  123456789,
  granularity=3)

  '1 day 10 hours 17 minutes'
 """

 time = time_breakdown(ms)
 time_string = ""

 if time["day"] != 0:
  time_string += str(time["day"]) + " day"
  if time["day"] != 1:
   time_string += "s"

 if time["hr"] != 0:
  time_string += " " + str(time["hr"]) + " hour"
  if time["hr"] != 1:
   time_string += "s"

 if time["min"] != 0:
  time_string += " " + str(time["min"]) + " minute"
  if time["min"] != 1:
   time_string += "s"

 if time["sec"] != 0:
  time_string += " " + str(time["sec"]) + " second"
  if time["sec"] != 1:
   time_string += "s"

 if time["ms"] != 0:
  time_string += " " + str(time["ms"]) + " millisecond"
  if time["ms"] != 1:
   time_string += "s"

 #filter to specified granularity
 time_string = time_string.strip().split(" ")[:2*granularity]

 return " ".join(time_string)

Example implementation

To test it out, add the below code at the bottom of semantic_time.py:

def main():
 from datetime import datetime

 mercury_orbit_period = 7600608000
 print(f"""
To orbit the sun, it takes Mercury:

 {time_breakdown(mercury_orbit_period)}
 {time_breakdown_string(mercury_orbit_period, 1)}
 {time_breakdown_string(mercury_orbit_period, 2)}
 {time_breakdown_string(mercury_orbit_period, 3)}
 {time_breakdown_string(mercury_orbit_period, 4)}
 """)

 one_day_minus_one_second = 1*24*60*60*1000 - 1000
 one_day_exactly = 1*24*60*60*1000
 one_day_plus_one_second = 1*24*60*60*1000 + 1000
 one_day_plus_one_minute = 1*24*60*60*1000 + 1000*60
 print(f"""
Ignores 'zero' units:

 {time_breakdown(one_day_minus_one_second)}
 {time_breakdown_string(one_day_minus_one_second)}

 {time_breakdown(one_day_exactly)}
 {time_breakdown_string(one_day_exactly)}

 {time_breakdown(one_day_plus_one_second)}
 {time_breakdown_string(one_day_plus_one_second)}

 {time_breakdown(one_day_plus_one_minute)}
 {time_breakdown_string(one_day_plus_one_minute)}
 """)

 one_second = 1000
 two_seconds = 2000
 one_minute_one_second = 361000
 two_minute_one_second = 721000
 two_minute_two_second = 722000
 print(f"""
Plurality is considered:

 {time_breakdown_string(one_second)}
 {time_breakdown_string(two_seconds)}
 {time_breakdown_string(one_minute_one_second)}
 {time_breakdown_string(two_minute_one_second)}
 {time_breakdown_string(two_minute_two_second)}
 """)

if __name__ == "__main__":
 main()

Example output

To orbit the sun, it takes Mercury:

        {'day': 87, 'hr': 23, 'min': 16, 'sec': 48, 'ms': 0}
        87 days
        87 days 23 hours
        87 days 23 hours 16 minutes
        87 days 23 hours 16 minutes 48 seconds


Ignores 'zero' units:

        {'day': 0, 'hr': 23, 'min': 59, 'sec': 59, 'ms': 0}
        23 hours 59 minutes 59 seconds

        {'day': 1, 'hr': 0, 'min': 0, 'sec': 0, 'ms': 0}
        1 day

        {'day': 1, 'hr': 0, 'min': 0, 'sec': 1, 'ms': 0}
        1 day 1 second

        {'day': 1, 'hr': 0, 'min': 1, 'sec': 0, 'ms': 0}
        1 day 1 minute


Plurality is considered:

        1 second
        2 seconds
        6 minutes 1 second
        12 minutes 1 second
        12 minutes 2 seconds