If it ain't broke, fix it anyhow

It's time to get myself in the habit of writing posts on this site, so here we go. Recently, when helping somebody format a timedelta in python, I was reminded of my own timedelta formatting code I had written. At the time of writing it, I was a little proud (mostly of the and/or operator short circuiting), but I was also very frustrated at timedelta's inability to make use of strptime/strftime as date/time/datetime objects can do.

First, let's explain what exactly the idea is here. It sounds simple: a timedelta is an object that represents a span of time, rather than a specific time or date - in other words: "a half hour" instead of "today at 4:30 PM". The goal is to format a timedelta object into a human-readable string.

Let's take a look at the 'old' solution, even though I'm not proud of it now, and I'll explain what my thinking was (note: this was created to show uptime for a bot):

myuptime = str(uptime)
udays = None
if ',' in myuptime:
    udays, myuptime = myuptime.split(',')
uhours, umins, usecs = myuptime.split(':')
usecs = round(float(usecs))
hrformat = f" {int(uhours)} {int(uhours) == 1 and 'hour' or 'hours'},"
mnformat = f" {int(umins)} {int(umins) == 1 and 'minute' or 'minutes'},"
scformat = f" {int(usecs)} {int(usecs) == 1 and 'second' or 'seconds'}."
if udays:
    send(f"{udays},{hrformat}{mnformat}{scformat}")
else:
    send(f"{hrformat}{mnformat}{scformat}")

Okay, so, let's explain this. uptime here is our timedelta object. The first thing I do is convert it into a string. I did this out of frustration, after referencing the timedelta docs and realizing it didn't have methods for returning minutes, hours, etc. It goes from days to seconds, and then down from there! There ARE hours, minutes, and weeks, but those are for creating the object itself, not formatting it. However, if you str() this object, you'll get hours and minutes. Here's an ipython screenshot to explain:

/images/timedelta-ipython.png

It's not the greatest thing in the world, but I decided to just use split() on this string, to get the information that I needed, with a conditional for days, if included. The f-strings below that just modify the unit of time, adding an 's' at the end of the word if needed. The final send lines are what the bot returns (you can just replace them with 'print' in your head), also conditionally depending upon whether days are included in the timedelta.

This worked for years, but of course it wasn't the cleanest code in the world. Yes, even at the time, I knew I could use divmod to easily derive other units of time from the seconds, but I was too mad at the time of timedelta's limitations, and didn't mind doing my own basic strptime-like functionality.

I have this rule: whenever adding a new feature to a coding project, I try to give the existing unrelated code a once-over, to see if I can do something more cleanly. The idea is that I've hopefully improved in my programming skills since when I wrote the code, or at least enough time has passed that I can be my own 'fresh set of eyes'. In this particular case, I was actually helping somebody else with their timedelta-related problem, and in doing so I was compelled to look at my own related code, thinking about how it could help them. In addition to that, they had some questions and requirements that I hadn't considered when writing mine, which led me to come up with a few more solutions, and at the end of the day, in helping him, I had helped improve my code, too.

Let's take a look at what I replaced the above code with:

def sing(amount, unit):
    """singularizer(?) - returns a string containing the amount and type of something
    The type/unit of item will be pluralized if the amount is greater than one."""
    return f"{amount} {amount == 1 and f'{unit}' or f'{unit}s'}"


def deltaconv(seconds):
    """Converts a timedelta's total_seconds() to a humanized string"""
    mins, secs = divmod(seconds, 60)
    hrs, mins = divmod(mins, 60)
    dys, hrs = divmod(hrs, 24)
    timedict = {'day': dys, 'hour': hrs, 'minute': mins, 'second': secs}
    cleaned = {k:v for k,v in timedict.items() if v != 0}
    return " ".join(sing(v,k) for k,v in cleaned.items())

# and then later, in the bot's 'uptime' command, this is how we invoke our new funcs:
send(deltaconv(int(uptime.total_seconds())))

So, as you can see, I've broken this out into two functions. The first, sing (bad name, I know), handles what the 3 f-string lines in the earlier code did, except now it's reusable and dynamic. We could easily add weeks, months, or similar, and this function could handle it well. The second, deltaconv, takes the total_seconds() from the timedelta object, and first uses a series of divmod lines to convert to other units. Yes, I stopped being stubborn about being 'forced' to do math to get units the timedelta object didn't provide (unless, for some reason, you wanted the str() representation of it).

Following that, we've got a dict of the units of time along with the string names for them. This is used to pass to sing() so that we can construct the full uptime string properly. If we wanted to add weeks or months, it would be a simple matter of adding another divmod line just above, and a new key-value pair to this dict. Easy enough! Just beneath this, we use a dictionary comprehension to remove any time units if they happen to equal zero. This right here makes for an improvement the end user can actually see, as it won't bother saying something like '0 hours' where it's not necessary. The final line just after that uses join() to construct the full human-readable uptime string. This is also where it invokes sing() for each entry in timedict.

The output string looks something like this: 27 days 3 hours 8 minutes 43 seconds. Very similar to what my original code output, but it does have a small improvement, and the code is clearer and easier to reuse and modify. I have the person requesting timedelta help to thank for all of this, and it's one of the reasons I like to help others learn: it helps me learn and improve, as well! We both come out the other end with a better understanding, and some code that works (or in my case, works better).

Thanks for reading my first real post on this site, if you did!