Add function for textualising durations
authorChristian Herdtweck <christian.herdtweck@intra2net.com>
Tue, 9 Sep 2025 10:11:06 +0000 (12:11 +0200)
committerChristian Herdtweck <christian.herdtweck@intra2net.com>
Tue, 9 Sep 2025 10:11:06 +0000 (12:11 +0200)
Have created this ages ago for some unittest, move it here now since I
need it also in other project. Add unittests.

src/text_helpers.py
test/test_text_helpers.py

index 26a9c7c..81f7518 100644 (file)
@@ -27,6 +27,7 @@ This module has two parts. Part 1 includes:
     - head_and_tail: shows the first few and last few elements of an iterable that
                      could potentially be pretty long
     - size_str: textual representation of data size
+    - strftimedelta: textual representation of a :py:class:`datetime.timedelta`
 
 Part2 contains functions for coloring text, a poor-man's version of other
 modules like :py:mod:`colorclass` (which is now also available on Intra2net
@@ -50,6 +51,7 @@ from builtins import print as _builtin_print
 from functools import partial
 from itertools import islice
 import re
+from datetime import timedelta
 
 from .type_helpers import isstr
 from sys import stdout
@@ -160,6 +162,82 @@ def size_str(byte_number, is_diff=False):
     return f'{sign_str}{int(round(curr_num*factor)):,d} {units[-1]}B'
 
 
+def strftimedelta(delta, max_parts=2, only_up_to_hours=False):
+    """
+    Length-adjustable text representation of a timespan.
+
+    Will output the most significant non-zero time units, e.g.
+    '2h23m50s' or '3d50s12ms' (skipping 0 minutes), or '3y63d12h'
+    or for max_parts=2: '2h23m' / '3d50s' / '3y63d'
+
+    Assumes that each year has 365 days, every day 24h, every hour 60 mins, ...
+      not accounting for leap years / leap days, changes of timezones, etc
+
+    :param delta: timedelta or anything that can be converted to float;
+       if numeric, this is interpreted as number of seconds
+    :param int max_parts: precision (i.e., length) of output (default: 2)
+       set to None if always want precision down to microseconds
+    :param bool only_up_to_hours: set to True to never give number of days/years
+      (e.g. to get '40h' instead of '1d16h')
+      default: False
+    :rtype: string
+    """
+    units = '\u00B5s', 'ms', 's', 'm', 'h', 'd', 'y'
+
+    if (max_parts is None) or (max_parts < 1):
+        max_parts = 100
+
+    # get number of seconds and sign
+    if isinstance(delta, timedelta):
+        seconds = delta.total_seconds()
+    else:
+        seconds = float(delta)
+    negative = False
+    if seconds < 0:
+        negative = True
+        seconds = abs(seconds)
+
+    # split sub-second part
+    seconds, second_part = divmod(seconds, 1)
+    milliseconds, milli_part = divmod(second_part*1000, 1)
+    microseconds = int(round(milli_part*1000))
+    seconds = int(seconds)   # convert to int here --> all other variables will be int
+    vals = [microseconds, int(milliseconds), ]
+
+    # split longer units
+    minutes = hours = days = years = 0
+    if seconds > 0:
+        minutes, seconds = divmod(seconds, 60)
+    if minutes > 0:
+        hours, minutes = divmod(minutes, 60)
+    if hours > 0 and not only_up_to_hours:
+        days, hours = divmod(hours, 24)
+    if days > 0:
+        years, days = divmod(days, 365)
+    vals.append(seconds)
+    vals.append(minutes)
+    vals.append(hours)
+    vals.append(days)
+    vals.append(years)
+
+    # build string
+    str_parts = []
+    if negative:
+        str_parts.append('-')
+
+    n_used = 0
+    for val,unit in zip( reversed(vals), reversed(units) ):
+        if val == 0:
+            continue
+        str_parts.append(str(val))
+        str_parts.append(unit)
+        n_used += 1
+        if n_used >= max_parts:
+            break
+    return ''.join(str_parts)
+
+
+
 ###############################################################################
 # TEXT FORMATTING/COLORING
 ###############################################################################
index 0e6fbce..9559f90 100644 (file)
@@ -28,6 +28,7 @@ For help see :py:mod:`unittest`
 """
 
 import unittest
+from datetime import timedelta
 
 from src.text_helpers import *
 
@@ -106,6 +107,28 @@ class TextHelpersTester(unittest.TestCase):
         self.assertEqual(size_str(5.678 * 1024 * 1024 * 1024 * 1024), '5.7 TB')
         self.assertEqual(size_str(56.78 * 1024 * 1024 * 1024 * 1024), '57 TB')
 
+    def test_strftimedelta(self):
+        """Test function strftimedelta"""
+        self.assertEqual(strftimedelta(0.0001), "100µs")
+        self.assertEqual(strftimedelta(0.1), "100ms")
+        self.assertEqual(strftimedelta(1), "1s")
+        self.assertEqual(strftimedelta(1.1), "1s100ms")
+        self.assertEqual(strftimedelta(100), "1m40s")
+        self.assertEqual(strftimedelta(3600), "1h")
+        self.assertEqual(strftimedelta(3601), "1h1s")
+        self.assertEqual(strftimedelta(3661), "1h1m")
+        self.assertEqual(strftimedelta(3661, max_parts=3), "1h1m1s")
+        self.assertEqual(strftimedelta(timedelta(days=1)), "1d")
+        self.assertEqual(strftimedelta(timedelta(days=1), only_up_to_hours=True), "24h")
+        self.assertEqual(strftimedelta(timedelta(days=1, seconds=1)), "1d1s")
+        self.assertEqual(strftimedelta(timedelta(days=1, seconds=3600)), "1d1h")
+        self.assertEqual(strftimedelta(timedelta(days=1, seconds=3661)), "1d1h")
+        self.assertEqual(strftimedelta(timedelta(days=1, seconds=3661), max_parts=4), "1d1h1m1s")
+        self.assertEqual(strftimedelta(timedelta(days=5), only_up_to_hours=True), "120h")
+        self.assertEqual(strftimedelta(timedelta(days=90)), "90d")
+        self.assertEqual(strftimedelta(timedelta(days=365)), "1y")
+        self.assertEqual(strftimedelta(timedelta(days=3650)), "10y")
+
 
 if __name__ == '__main__':
     unittest.main()