From 5dbbe817175e5be6ded51912e4d3530d2c68f9f6 Mon Sep 17 00:00:00 2001 From: ApparentlyTejas Date: Mon, 26 Oct 2026 13:24:48 +0100 Subject: [PATCH] Fix format_timespan() producing one minus sign per unit for negative detailed spans format_timespan(-3700, detailed=True) previously returned '-1 hours, -1 minutes, -40 seconds and -0 nanoseconds' because the detailed/slow code path ran Decimal division and modulo directly on the still-negative num_seconds, so every unit kept its own sign, and a spurious '-0' component leaked through since the zero-filtering check only excluded the string '0', not '-0'. Also fix the related issue where the fast path for small values (abs < 60) used num_seconds < 60 instead of abs(num_seconds) < 60, so large negative non-detailed spans like -3700 were never broken into hours/minutes/seconds at all. Now the sign is stripped before decomposing into units and a single leading '-' is reapplied to the final formatted string. --- humanfriendly/__init__.py | 12 ++++++++---- humanfriendly/tests.py | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/humanfriendly/__init__.py b/humanfriendly/__init__.py index 4c0a333..212e503 100644 --- a/humanfriendly/__init__.py +++ b/humanfriendly/__init__.py @@ -429,13 +429,14 @@ def format_timespan(num_seconds, detailed=False, max_units=3): '1 year, 2 days and 3 hours' """ num_seconds = coerce_seconds(num_seconds) - if num_seconds < 60 and not detailed: + if abs(num_seconds) < 60 and not detailed: # Fast path. return pluralize(round_number(num_seconds), 'second') else: # Slow path. + is_negative = num_seconds < 0 result = [] - num_seconds = decimal.Decimal(str(num_seconds)) + num_seconds = decimal.Decimal(str(abs(num_seconds))) relevant_units = list(reversed(time_units[0 if detailed else 3:])) for unit in relevant_units: # Extract the unit count from the remaining time. @@ -454,13 +455,16 @@ def format_timespan(num_seconds, detailed=False, max_units=3): result.append(pluralize(count, unit['singular'], unit['plural'])) if len(result) == 1: # A single count/unit combination. - return result[0] + formatted = result[0] else: if not detailed: # Remove `insignificant' data from the formatted timespan. result = result[:max_units] # Format the timespan in a readable way. - return concatenate(result) + formatted = concatenate(result) + # Apply a single leading minus sign for negative timespans instead of + # letting each individual unit carry its own sign. + return ('-' + formatted) if is_negative else formatted def parse_timespan(timespan): diff --git a/humanfriendly/tests.py b/humanfriendly/tests.py index 72dad99..eadcb70 100644 --- a/humanfriendly/tests.py +++ b/humanfriendly/tests.py @@ -448,6 +448,12 @@ def test_format_timespan(self): assert '5 minutes and 0.3 seconds' == format_timespan(300.300) assert '1 second and 15 milliseconds' == format_timespan(1.015, detailed=True) assert '10 seconds and 15 milliseconds' == format_timespan(10.015, detailed=True) + # Negative timespans should get a single leading minus sign instead + # of a minus sign on every individual unit. + assert '-1 hour, 1 minute and 40 seconds' == format_timespan(-3700, detailed=True) + assert '-1 minute and 30 seconds' == format_timespan(-90, detailed=True) + assert '-30 seconds' == format_timespan(-30, detailed=False) + assert '-1 hour, 1 minute and 40 seconds' == format_timespan(-3700, detailed=False) assert '1 microsecond and 50 nanoseconds' == format_timespan(0.00000105, detailed=True) # Test the datetime.timedelta support: # https://github.com/xolox/python-humanfriendly/issues/27