From: Christian Herdtweck Date: Tue, 9 Sep 2025 10:11:06 +0000 (+0200) Subject: Add function for textualising durations X-Git-Tag: v1.7.5~9 X-Git-Url: http://developer.intra2net.com/git/?a=commitdiff_plain;h=59148617a61eae95cf4f422ec945dfab65aff3dc;p=pyi2ncommon Add function for textualising durations Have created this ages ago for some unittest, move it here now since I need it also in other project. Add unittests. --- diff --git a/src/text_helpers.py b/src/text_helpers.py index 26a9c7c..81f7518 100644 --- a/src/text_helpers.py +++ b/src/text_helpers.py @@ -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 ############################################################################### diff --git a/test/test_text_helpers.py b/test/test_text_helpers.py index 0e6fbce..9559f90 100644 --- a/test/test_text_helpers.py +++ b/test/test_text_helpers.py @@ -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()