From 4a7537012fe28aa70270000d1bdcfd08c820e188 Mon Sep 17 00:00:00 2001 From: patchwright Date: Wed, 17 Jun 2026 21:03:18 +0200 Subject: [PATCH] Fix naturalsize() rounding rollover at unit boundaries naturalsize() chooses the suffix from the unrounded byte count via int(min(log(abs_bytes, base), ...)), then rounds the mantissa with the format string afterward. When rounding pushes the mantissa up to the base, the already-chosen suffix is left stale: >>> naturalsize(999999) '1000.0 kB' # expected '1.0 MB' >>> naturalsize(999999999) '1000.0 MB' # expected '1.0 GB' >>> naturalsize(1024 ** 2 - 1, binary=True) '1024.0 KiB' # expected '1.0 MiB' This is distinct from the ZB->YB top-boundary fix in #206 (which added larger suffixes but did not touch the rounding) and from the metric() fix in #328 (a different function). Here the rollover happens at every small unit too. Fix: after rounding, if the mantissa has reached the base and a larger suffix is available, step up one suffix. Added regression cases to test_naturalsize (they fail before this change, pass after); all existing assertions and documented examples are unchanged. --- src/humanize/filesize.py | 6 ++++++ tests/test_filesize.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index c495fed3..315261aa 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -97,6 +97,12 @@ def naturalsize( return f"{int(bytes_)}B" if gnu else _("%d Bytes") % int(bytes_) exp = int(min(log(abs_bytes, base), len(suffix))) + # The suffix is chosen from the unrounded byte count, but `format` rounds the + # mantissa afterward; rounding can push it up to `base` (e.g. 999999 is + # 999.999 kB, which formats to "1000.0 kB"). When that happens and a larger + # suffix is available, step up one suffix so the result reads "1.0 MB". + if exp < len(suffix) and abs(float(format % (abs_bytes / (base**exp)))) >= base: + exp += 1 space = "" if gnu else " " ret: str = format % (bytes_ / (base**exp)) + space + _(suffix[exp - 1]) return ret diff --git a/tests/test_filesize.py b/tests/test_filesize.py index 04774d95..e6956399 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -82,6 +82,15 @@ ([1.123456789, False, True], "1B"), ([1.123456789 * 10**3, False, True], "1.1K"), ([1.123456789 * 10**6, False, True], "1.1M"), + # Rounding must not leave the mantissa at the base while a larger suffix + # is available: 999999 is 999.999 kB, which the "%.1f" format rounds to + # 1000.0 and must carry into 1.0 MB rather than render as "1000.0 kB". + ([999999], "1.0 MB"), + ([999999999], "1.0 GB"), + ([999999999999], "1.0 TB"), + ([1024**2 - 1, True], "1.0 MiB"), + ([1024**3 - 1, True], "1.0 GiB"), + ([1024**2 - 1, False, True], "1.0M"), ], ) def test_naturalsize(test_args: list[int] | list[int | bool], expected: str) -> None: