5
\$\begingroup\$

I wanted to write a human readable datetime.timedelta that can be used in log files.

Eg, "Report issued 1 hour, 44 minutes, 20 seconds ago"

I noticed that casting a timedelta to str() generates something almost like what I want, but not quite.

To this end I wrote this:

def verbose_timedelta(delta):
    hours, remainder = divmod(delta.seconds, 3600)
    minutes, seconds = divmod(remainder, 60)
    dstr = "%s day%s" % (delta.days, "s"[delta.days==1:])
    hstr = "%s hour%s" % (hours, "s"[hours==1:])
    mstr = "%s minute%s" % (minutes, "s"[minutes==1:])
    sstr = "%s second%s" % (seconds, "s"[seconds==1:])
    dhms = [dstr, hstr, mstr, sstr]
    for x in range(len(dhms)):
        if not dhms[x].startswith('0'):
            dhms = dhms[x:]
            break
    dhms.reverse()
    for x in range(len(dhms)):
        if not dhms[x].startswith('0'):
            dhms = dhms[x:]
            break
    dhms.reverse()
    return ', '.join(dhms)

Essentially, it's shaving off both ends of a list to make the results more meaningful.

The code above feels clunky though. Is there a more "Pythonic" way to do it? I'm using Python 2.7.3.

\$\endgroup\$
3
  • \$\begingroup\$ Use xrange instead of range and str.format instead of the % operator. But these are just side notes ;) \$\endgroup\$
    – jazzpi
    Nov 26, 2013 at 16:12
  • 2
    \$\begingroup\$ You can also use "s" if hours==1 else "" instead of "s"[hours==1:]. This goes with the "Explicit is better than implicit." Python idiom. \$\endgroup\$
    – jorispilot
    Nov 26, 2013 at 16:16
  • \$\begingroup\$ Thanks. The main part I wanted to clear up was the two for loops and the double reversing. This seems like something python could do in a single line. Maybe. Hmm. \$\endgroup\$
    – Paul
    Nov 26, 2013 at 16:44

3 Answers 3

3
\$\begingroup\$
  • Use a ternary operator in "s"[seconds==1:].
  • Use a generator expression to replace the xstr = "%s... lines.
  • The two for loops should use enumerate(), i.e. they could be for s, i in range(...).
  • The two for loops should be moved into a for _ in range(2):.
  • i is preferred over x when using an i​ncrementing i​ndex counter.
  • The filtering of the redundant strings which the for-loop does could be done earlier so that the number to string code can be modified but the filtering code will not require adjustments.

PS: I have implemented a similar function here:

    days, rem = divmod(seconds, 86400)
    hours, rem = divmod(rem, 3600)
    minutes, seconds = divmod(rem, 60)
    if seconds < 1:seconds = 1
    locals_ = locals()
    magnitudes_str = ("{n} {magnitude}".format(n=int(locals_[magnitude]), magnitude=magnitude)
                      for magnitude in ("days", "hours", "minutes", "seconds") if locals_[magnitude])
    eta_str = ", ".join(magnitudes_str)
\$\endgroup\$
4
  • \$\begingroup\$ I really like this because it's teaching me how to get under the belly of Python. However, it doesn't quite do what I would like. Or what my function above does. Your code could return something like this: "7 days, 5 seconds" Ie because hours and minutes aren't set they're not shown. I don't like this, but purely on aesthetic grounds. For me, intervening zero time intervals should be shown, eg "7 days, 0 hours, 6 minutes" not "7 days, 6 minutes". \$\endgroup\$
    – Paul
    Nov 26, 2013 at 16:42
  • \$\begingroup\$ @Paul Yes, it doesn't do exactly the same thing as yours. I should update it to shave off both the ends like yours does. \$\endgroup\$ Nov 27, 2013 at 5:03
  • \$\begingroup\$ I'm not sure if using locals() is considered best practice. I probably rather give it a dict instead. Makes it easier to adapt it for other languages, too. \$\endgroup\$ Feb 6, 2021 at 19:15
  • \$\begingroup\$ I can't understand this line: if seconds < 1:seconds = 1. If seconds < 1, then that's that. I don't understand why it needs to be assigned with 1. \$\endgroup\$
    – WhyWhat
    Dec 27, 2021 at 20:17
3
\$\begingroup\$

Pythonic is in the eye of the beholder, but here's my stab at it:

def verbose_timedelta(delta):
    d = delta.days
    h, s = divmod(delta.seconds, 3600)
    m, s = divmod(s, 60)
    labels = ['day', 'hour', 'minute', 'second']   
    dhms = ['%s %s%s' % (i, lbl, 's' if i != 1 else '') for i, lbl in zip([d, h, m, s], labels)]
    for start in range(len(dhms)):
        if not dhms[start].startswith('0'):
            break
    for end in range(len(dhms)-1, -1, -1):
        if not dhms[end].startswith('0'):
            break  
    return ', '.join(dhms[start:end+1])
\$\endgroup\$
2
  • \$\begingroup\$ Cool, looks like it might fall foul to "0 hour ago". Plural exists on zero quantities. \$\endgroup\$
    – Paul
    Nov 27, 2013 at 18:39
  • \$\begingroup\$ @Paul: Changed i > 1 to i != 1 to address zero quantities. \$\endgroup\$ Nov 27, 2013 at 18:42
1
\$\begingroup\$

here are my cents:

  1. From the 1st look, I see quite a lot of duplication. When you see repeated code try to somehow unify it. But not at whatever cost. Everything has a limit ;) But the s suffix handling can be definitely unified somehow.
  2. There is useful timedelta.total_seconds() method. It's better to use it than handling the attributes separately.
  3. I think also the handling of zeros should be done in reverse. You should filter them out by comparing hours > 0 etc. and only then format the data into a string. Numbers are much better data for processing in your case and the converting to the UI (in this case "string") should be the last piece of the code.

I would like to add my readable version of the readable timedelta in Python 3 :) Hope it helps you and other people how to make it a little more readable. It's not the exact version as yours but still, there are some useful hints about data structures and the program flow.

def readable_timedelta(duration: timedelta):
    data = {}
    data['days'], remaining = divmod(duration.total_seconds(), 86_400)
    data['hours'], remaining = divmod(remaining, 3_600)
    data['minutes'], data['seconds'] = divmod(remaining, 60)

    time_parts = [f'{round(value)} {name}' for name, value in data.items() if value > 0]
    if time_parts:
        return ' '.join(time_parts)
    else:
        return 'below 1 second'

It does not handle the s suffixes so it will produce 1 minutes and similar, but that is no big deal for me. It should be easy to add also suffix handling. For example like this:

def readable_timedelta(duration: timedelta):
    data = {}
    data['days'], remaining = divmod(duration.total_seconds(), 86_400)
    data['hours'], remaining = divmod(remaining, 3_600)
    data['minutes'], data['seconds'] = divmod(remaining, 60)

    time_parts = ((name, round(value)) for name, value in data.items())
    time_parts = [f'{value} {name[:-1] if value == 1 else name}' for name, value in time_parts if value > 0]
    if time_parts:
        return ' '.join(time_parts)
    else:
        return 'below 1 second'

And here are the tests:

@pytest.mark.parametrize('duration, readable_duration', [
    (timedelta(), 'below 1 second'),
    (timedelta(seconds=29), '29 seconds'),
    (timedelta(seconds=61), '1 minutes 1 seconds'),
    (timedelta(minutes=5, seconds=66), '6 minutes 6 seconds'),
    (timedelta(hours=4, minutes=0, seconds=5), '4 hours 5 seconds'),
    (timedelta(hours=48, minutes=5), '2 days 5 minutes'),
])
def test_readable_timedelta(duration, readable_duration):
    assert readable_timedelta(duration) == readable_duration
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy