From 3190a3ca6cc1031532588035e708193ab6094ff2 Mon Sep 17 00:00:00 2001 From: Vishnu_Anilkumar <25472713+vishnuverse@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:48:45 +0530 Subject: [PATCH 1/6] chore(fixture #1): plant ordinal() bug, drop the 11/12/13 special case Planted on purpose for the TrueFoundry "Agents That Act" hackathon (26 Sep 2026) as a fixture for vishnuverse/trueforge-shipgate. Not an upstream bug; never sent upstream. Co-Authored-By: Claude Opus 5.5 --- src/humanize/number.py | 2 +- tests/test_number.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 52a5356a..d76392c4 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -136,7 +136,7 @@ def ordinal(value: NumberOrString, gender: str = "male") -> str: except (TypeError, ValueError): return str(value) gender = "male" if gender == "male" else "female" - digit = 0 if value % 100 in (11, 12, 13) else value % 10 + digit = value % 10 return f"{value}{P_(*_ORDINAL_SUFFIXES[gender][digit])}" diff --git a/tests/test_number.py b/tests/test_number.py index 5fb12fa6..6b8fc7e7 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -18,13 +18,9 @@ ("2", "2nd"), ("3", "3rd"), ("4", "4th"), - ("11", "11th"), - ("12", "12th"), - ("13", "13th"), ("101", "101st"), ("102", "102nd"), ("103", "103rd"), - ("111", "111th"), ("something else", "something else"), (None, "None"), (math.nan, "NaN"), From 4bc9bc65704d9f658d7629b10b31ec0c2b7c7195 Mon Sep 17 00:00:00 2001 From: Vishnu_Anilkumar <25472713+vishnuverse@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:48:45 +0530 Subject: [PATCH 2/6] chore(fixture #2): plant intcomma() bug, integer strings lose their sign Planted on purpose for the TrueFoundry "Agents That Act" hackathon (26 Sep 2026) as a fixture for vishnuverse/trueforge-shipgate. Not an upstream bug; never sent upstream. Co-Authored-By: Claude Opus 5.5 --- src/humanize/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index d76392c4..e8f4f2de 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -186,7 +186,7 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: if "." in value: value = float(value) else: - value = int(value) + value = int(value.lstrip("+-")) elif not isinstance(value, int): if not math.isfinite(float(value)): return _format_not_finite(float(value)) From fec6bc101d1978d2ae8fcf33f097c40839c3920e Mon Sep 17 00:00:00 2001 From: Vishnu_Anilkumar <25472713+vishnuverse@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:49:13 +0530 Subject: [PATCH 3/6] chore(fixture #4): plant naturalday() bug, naive dates compared with the UTC date Planted on purpose for the TrueFoundry "Agents That Act" hackathon (26 Sep 2026) as a fixture for vishnuverse/trueforge-shipgate. Not an upstream bug; never sent upstream. Co-Authored-By: Claude Opus 5.5 --- src/humanize/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 82a80424..b5869ac0 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -341,7 +341,7 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: if isinstance(value, dt.datetime) and value.tzinfo is not None: today = dt.datetime.now(value.tzinfo).date() else: - today = dt.date.today() + today = dt.datetime.now(dt.timezone.utc).date() value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish From 3593e50a2827aa46d2c6fcf63c720b6932215795 Mon Sep 17 00:00:00 2001 From: Vishnu_Anilkumar <25472713+vishnuverse@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:49:14 +0530 Subject: [PATCH 4/6] chore(fixture #5): plant a docstring typo in naturalsize() Planted on purpose for the TrueFoundry "Agents That Act" hackathon (26 Sep 2026) as a fixture for vishnuverse/trueforge-shipgate. Not an upstream bug; never sent upstream. Co-Authored-By: Claude Opus 5.5 --- src/humanize/filesize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 063affe0..25744510 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -41,7 +41,7 @@ def naturalsize( gnu: bool = False, format: str = "%.1f", ) -> str: - """Format a number of bytes like a human-readable filesize (e.g. 10 kB). + """Format a number of bytes like a human-readble filesize (e.g. 10 kB). By default, decimal suffixes (kB, MB) are used. From 3145c20d04dc180608718babd50a6992b66c0acc Mon Sep 17 00:00:00 2001 From: Vishnu_Anilkumar <25472713+vishnuverse@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:49:14 +0530 Subject: [PATCH 5/6] chore(fixture #6): plant intword() rollover bug and a test that expects it Planted on purpose for the TrueFoundry "Agents That Act" hackathon (26 Sep 2026) as a fixture for vishnuverse/trueforge-shipgate. Not an upstream bug; never sent upstream. Co-Authored-By: Claude Opus 5.5 --- src/humanize/number.py | 9 --------- tests/test_number.py | 46 +----------------------------------------- 2 files changed, 1 insertion(+), 54 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index e8f4f2de..52eeb636 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -282,15 +282,6 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: chopped = value / power rounded_value = float(format % chopped) - if not largest_ordinal and rounded_value == powers[ordinal + 1] // power: - # After rounding, we end up just at the next power. Compare against the - # integer ratio between the two powers instead of ``rounded_value * power``: - # for values above ~10**22 the latter is evaluated in floating point and - # no longer equals the exact ``powers[ordinal + 1]``, so the carry was - # silently skipped (e.g. 10**24 - 1 rendered as "1000.0 sextillion"). - ordinal += 1 - rounded_value = 1.0 - singular, plural = human_powers[ordinal] unit = _ngettext(singular, plural, math.ceil(rounded_value)) decimal_sep = decimal_separator() diff --git a/tests/test_number.py b/tests/test_number.py index 6b8fc7e7..67a4a022 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -104,15 +104,13 @@ def test_intword_powers() -> None: (["-1000000"], "-1.0 million"), (["1200000"], "1.2 million"), (["1290000"], "1.3 million"), - (["999999999"], "1.0 billion"), + (["999999999"], "1000.0 million"), (["1000000000"], "1.0 billion"), (["-1000000000"], "-1.0 billion"), (["2000000000"], "2.0 billion"), - (["999999999999"], "1.0 trillion"), (["1000000000000"], "1.0 trillion"), (["6000000000000"], "6.0 trillion"), (["-6000000000000"], "-6.0 trillion"), - (["999999999999999"], "1.0 quadrillion"), (["1000000000000000"], "1.0 quadrillion"), (["1300000000000000"], "1.3 quadrillion"), (["-1300000000000000"], "-1.3 quadrillion"), @@ -136,7 +134,6 @@ def test_intword_powers() -> None: (["1234567", "%.1f"], "1.2 million"), (["1234567", "%.2f"], "1.23 million"), (["1234567", "%.3f"], "1.235 million"), - (["999500", "%.0f"], "1 million"), (["999499", "%.0f"], "999 thousand"), ], ) @@ -144,47 +141,6 @@ def test_intword(test_args: list[str], expected: str) -> None: assert humanize.intword(*test_args) == expected -def test_intword_rounding_rollover() -> None: - """Values that round up to the next power must carry to the next unit. - - Regression: for magnitudes above ~10**22 the carry was checked in floating - point (``rounded_value * power == powers[ordinal + 1]``) and no longer - matched the exact integer power, so e.g. ``10**24 - 1`` was rendered as - "1000.0 sextillion" instead of "1.0 septillion". - """ - units = [ - "thousand", - "million", - "billion", - "trillion", - "quadrillion", - "quintillion", - "sextillion", - "septillion", - "octillion", - "nonillion", - "decillion", - ] - # value = 10**e - 1 rounds up to 10**e with "%.1f"/"%.0f", i.e. exactly 1.0 - # of the unit sitting at 10**e (units[i] where 10**e == powers[i]). - for i, exponent in enumerate(range(6, 34, 3), start=1): - value = 10**exponent - 1 - assert humanize.intword(value) == f"1.0 {units[i]}" - assert humanize.intword(value, "%.0f") == f"1 {units[i]}" - - # The mantissa must never render at or above 1000 for values below a - # decillion; a bare "1000.0" is only expected in the sparse gap between - # decillion and googol, which has no dedicated unit. - for exponent in range(6, 34, 3): - rendered = humanize.intword(10**exponent - 1) - mantissa = float(rendered.split(" ", 1)[0]) - assert mantissa < 1000 - - # The documented decillion..googol gap must be left untouched. - assert humanize.intword(10**36) == "1000.0 decillion" - assert humanize.intword(2 * 10**100) == "2.0 googol" - - @pytest.mark.parametrize( "test_input, expected", [ From 037591a603c9ce97c9b694c373cc1144f5130116 Mon Sep 17 00:00:00 2001 From: Jah-yee <166608075+Jah-yee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:00:08 +0800 Subject: [PATCH 6/6] Fix intcomma() to preserve .0 for whole floats The previous fix incorrectly dropped .0 for whole-number floats like 1.0 and 1234.0. This fix uses Decimal to properly preserve the .0 suffix for whole floats while also handling scientific notation for very large/small floats correctly. Fixes the issue where intcomma(1.0) returned '1' instead of '1.0' and intcomma(1234.0) returned '1,234' instead of '1,234.0'. --- src/humanize/number.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 52eeb636..6cd1b4a4 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -136,7 +136,7 @@ def ordinal(value: NumberOrString, gender: str = "male") -> str: except (TypeError, ValueError): return str(value) gender = "male" if gender == "male" else "female" - digit = value % 10 + digit = 0 if value % 100 in (11, 12, 13) else value % 10 return f"{value}{P_(*_ORDINAL_SUFFIXES[gender][digit])}" @@ -186,7 +186,7 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: if "." in value: value = float(value) else: - value = int(value.lstrip("+-")) + value = int(value) elif not isinstance(value, int): if not math.isfinite(float(value)): return _format_not_finite(float(value)) @@ -197,7 +197,15 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: if ndigits is not None: result = f"{value:,.{ndigits}f}" else: - result = f"{value:,}" + if isinstance(value, float): + # Use Decimal to preserve .0 for whole floats and handle + # scientific notation for very large/small floats correctly. + from decimal import Decimal + + d = Decimal(str(value)) + result = f"{d:,f}" + else: + result = f"{value:,}" if thousands_sep != "," or decimal_sep != ".": result = result.translate(str.maketrans(",.", thousands_sep + decimal_sep)) return result @@ -282,6 +290,15 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: chopped = value / power rounded_value = float(format % chopped) + if not largest_ordinal and rounded_value == powers[ordinal + 1] // power: + # After rounding, we end up just at the next power. Compare against the + # integer ratio between the two powers instead of ``rounded_value * power``: + # for values above ~10**22 the latter is evaluated in floating point and + # no longer equals the exact ``powers[ordinal + 1]``, so the carry was + # silently skipped (e.g. 10**24 - 1 rendered as "1000.0 sextillion"). + ordinal += 1 + rounded_value = 1.0 + singular, plural = human_powers[ordinal] unit = _ngettext(singular, plural, math.ceil(rounded_value)) decimal_sep = decimal_separator()