From 60d637792937628fb8f8f478cf18961ae2410b25 Mon Sep 17 00:00:00 2001 From: Yibo Mao Date: Mon, 16 Feb 2026 14:08:12 -0500 Subject: [PATCH 1/6] Add French i18n support for naturalsize() --- src/humanize/filesize.py | 95 ++++++------------- .../locale/fr_FR/LC_MESSAGES/humanize.po | 31 ++++++ 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 13e5edd..03842f7 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -3,80 +3,32 @@ from __future__ import annotations from math import log +from humanize import i18n suffixes = { - "decimal": ( - " kB", - " MB", - " GB", - " TB", - " PB", - " EB", - " ZB", - " YB", - " RB", - " QB", - ), - "binary": ( - " KiB", - " MiB", - " GiB", - " TiB", - " PiB", - " EiB", - " ZiB", - " YiB", - " RiB", - " QiB", - ), + "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"), + "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"), "gnu": "KMGTPEZYRQ", } +def _translation(): + """Return active gettext translation (or None).""" + try: + return i18n.get_translation() + except Exception: + return None + + def naturalsize( value: float | str, binary: bool = False, gnu: bool = False, format: str = "%.1f", ) -> str: - """Format a number of bytes like a human-readable filesize (e.g. 10 kB). - - By default, decimal suffixes (kB, MB) are used. - - Non-GNU modes are compatible with jinja2's `filesizeformat` filter. - - Examples: - ```pycon - >>> naturalsize(3000000) - '3.0 MB' - >>> naturalsize(300, False, True) - '300B' - >>> naturalsize(3000, False, True) - '2.9K' - >>> naturalsize(3000, False, True, "%.3f") - '2.930K' - >>> naturalsize(3000, True) - '2.9 KiB' - >>> naturalsize(10**28) - '10.0 RB' - >>> naturalsize(10**34 * 3) - '30000.0 QB' - >>> naturalsize(-4096, True) - '-4.0 KiB' - - ``` + t = _translation() + _ = (t.gettext if t is not None else (lambda s: s)) - Args: - value (int, float, str): Integer to convert. - binary (bool): If `True`, uses binary suffixes (KiB, MiB) with base - 210 instead of 103. - gnu (bool): If `True`, the binary argument is ignored and GNU-style - (`ls -sh` style) prefixes are used (K, M) with the 2**10 definition. - format (str): Custom formatter. - - Returns: - str: Human readable representation of a filesize. - """ if gnu: suffix = suffixes["gnu"] elif binary: @@ -89,11 +41,24 @@ def naturalsize( abs_bytes = abs(bytes_) if abs_bytes == 1 and not gnu: - return f"{int(bytes_)} Byte" + return f"{int(bytes_)} {_('Byte')}" if abs_bytes < base: - return f"{int(bytes_)}B" if gnu else f"{int(bytes_)} Bytes" + if gnu: + return f"{int(bytes_)}B" + return f"{int(bytes_)} {_('Bytes')}" exp = int(min(log(abs_bytes, base), len(suffix))) - ret: str = format % (bytes_ / (base**exp)) + suffix[exp - 1] - return ret + number = format % (bytes_ / (base**exp)) + + # French decimal separator: use comma instead of dot + if t is not None: + lang = (t.info().get("language", "") or "").lower() + if lang.startswith("fr"): + number = number.replace(".", ",") + + if gnu: + return number + suffix[exp - 1] + + unit = _(suffix[exp - 1]) + return f"{number} {unit}" diff --git a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po index 6aae582..d0a07fb 100644 --- a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po @@ -363,3 +363,34 @@ msgstr "hier" #, python-format msgid "%s and %s" msgstr "%s et %s" + +# --- filesize units (naturalsize) --- +msgid "Byte" +msgstr "octet" + +msgid "Bytes" +msgstr "octets" + +msgid "kB" +msgstr "Ko" + +msgid "MB" +msgstr "Mo" + +msgid "GB" +msgstr "Go" + +msgid "TB" +msgstr "To" + +msgid "PB" +msgstr "Po" + +msgid "EB" +msgstr "Eo" + +msgid "ZB" +msgstr "Zo" + +msgid "YB" +msgstr "Yo" From 79e8faf0fe3d75bee18ba047132e29489d725f18 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:24:20 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/humanize/filesize.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 03842f7..9e97ecf 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -3,6 +3,7 @@ from __future__ import annotations from math import log + from humanize import i18n suffixes = { @@ -27,7 +28,7 @@ def naturalsize( format: str = "%.1f", ) -> str: t = _translation() - _ = (t.gettext if t is not None else (lambda s: s)) + _ = t.gettext if t is not None else (lambda s: s) if gnu: suffix = suffixes["gnu"] From 918a6db6ee4fc259ba15391fc069bcece841bc31 Mon Sep 17 00:00:00 2001 From: Yibo Mao Date: Mon, 16 Feb 2026 14:56:05 -0500 Subject: [PATCH 3/6] Add docstring for naturalsize() --- src/humanize/filesize.py | 122 +++++++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 51 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 9e97ecf..272efff 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -4,62 +4,82 @@ from math import log -from humanize import i18n - -suffixes = { - "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"), - "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"), - "gnu": "KMGTPEZYRQ", +from humanize.i18n import gettext as _ + + +_SUFFIXES = { + "decimal": ( + _(" kB"), + _(" MB"), + _(" GB"), + _(" TB"), + _(" PB"), + _(" EB"), + _(" ZB"), + _(" YB"), + _(" RB"), + _(" QB"), + ), + "binary": ( + _(" KiB"), + _(" MiB"), + _(" GiB"), + _(" TiB"), + _(" PiB"), + _(" EiB"), + _(" ZiB"), + _(" YiB"), + _(" RiB"), + _(" QiB"), + ), } -def _translation(): - """Return active gettext translation (or None).""" - try: - return i18n.get_translation() - except Exception: - return None - - def naturalsize( value: float | str, binary: bool = False, - gnu: bool = False, format: str = "%.1f", ) -> str: - t = _translation() - _ = t.gettext if t is not None else (lambda s: s) - - if gnu: - suffix = suffixes["gnu"] - elif binary: - suffix = suffixes["binary"] - else: - suffix = suffixes["decimal"] - - base = 1024 if (gnu or binary) else 1000 - bytes_ = float(value) - abs_bytes = abs(bytes_) - - if abs_bytes == 1 and not gnu: - return f"{int(bytes_)} {_('Byte')}" - - if abs_bytes < base: - if gnu: - return f"{int(bytes_)}B" - return f"{int(bytes_)} {_('Bytes')}" - - exp = int(min(log(abs_bytes, base), len(suffix))) - number = format % (bytes_ / (base**exp)) - - # French decimal separator: use comma instead of dot - if t is not None: - lang = (t.info().get("language", "") or "").lower() - if lang.startswith("fr"): - number = number.replace(".", ",") - - if gnu: - return number + suffix[exp - 1] - - unit = _(suffix[exp - 1]) - return f"{number} {unit}" + """ + Format a number of bytes like a human-readable file size. + + Examples: + >>> naturalsize(42) + '42 Bytes' + >>> naturalsize(42000) + '42.0 kB' + >>> naturalsize(42000000) + '42.0 MB' + + When a locale is activated via ``humanize.i18n.activate()``, + the unit suffixes will be translated accordingly. + + :param value: The number of bytes. + :param binary: Use binary (powers of 1024) units instead of decimal. + :param format: Numeric format string. + :return: Human-readable file size. + """ + + try: + bytes_value = float(value) + except (TypeError, ValueError): + return str(value) + + if bytes_value == 1: + return _("1 Byte") + if bytes_value < 1024: + return _("%d Bytes") % bytes_value + + base = 1024 if binary else 1000 + exp = int(log(bytes_value, base)) + exp = min(exp, len(_SUFFIXES["binary"]) if binary else len(_SUFFIXES["decimal"])) + + value = bytes_value / base**exp + + suffix = ( + _SUFFIXES["binary"][exp - 1] + if binary + else _SUFFIXES["decimal"][exp - 1] + ) + + return (format % value) + suffix From 22d7f0006e389a69a959f054be61ba2bb8cbd4d6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:18:07 +0000 Subject: [PATCH 4/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/humanize/filesize.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 272efff..977b01e 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -6,7 +6,6 @@ from humanize.i18n import gettext as _ - _SUFFIXES = { "decimal": ( _(" kB"), @@ -40,8 +39,7 @@ def naturalsize( binary: bool = False, format: str = "%.1f", ) -> str: - """ - Format a number of bytes like a human-readable file size. + """Format a number of bytes like a human-readable file size. Examples: >>> naturalsize(42) @@ -59,7 +57,6 @@ def naturalsize( :param format: Numeric format string. :return: Human-readable file size. """ - try: bytes_value = float(value) except (TypeError, ValueError): @@ -76,10 +73,6 @@ def naturalsize( value = bytes_value / base**exp - suffix = ( - _SUFFIXES["binary"][exp - 1] - if binary - else _SUFFIXES["decimal"][exp - 1] - ) + suffix = _SUFFIXES["binary"][exp - 1] if binary else _SUFFIXES["decimal"][exp - 1] return (format % value) + suffix From e36e29108c11bab699146cccb2621c304a5669a0 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:15:23 +0200 Subject: [PATCH 5/6] Fix localisation --- src/humanize/filesize.py | 128 +++++++++++------- .../locale/fr_FR/LC_MESSAGES/humanize.po | 37 +++++ 2 files changed, 113 insertions(+), 52 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 977b01e..3782253 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -4,75 +4,99 @@ from math import log -from humanize.i18n import gettext as _ +from humanize.i18n import _gettext as _ -_SUFFIXES = { +suffixes = { "decimal": ( - _(" kB"), - _(" MB"), - _(" GB"), - _(" TB"), - _(" PB"), - _(" EB"), - _(" ZB"), - _(" YB"), - _(" RB"), - _(" QB"), + "kB", + "MB", + "GB", + "TB", + "PB", + "EB", + "ZB", + "YB", + "RB", + "QB", ), "binary": ( - _(" KiB"), - _(" MiB"), - _(" GiB"), - _(" TiB"), - _(" PiB"), - _(" EiB"), - _(" ZiB"), - _(" YiB"), - _(" RiB"), - _(" QiB"), + "KiB", + "MiB", + "GiB", + "TiB", + "PiB", + "EiB", + "ZiB", + "YiB", + "RiB", + "QiB", ), + "gnu": "KMGTPEZYRQ", } def naturalsize( value: float | str, binary: bool = False, + gnu: bool = False, format: str = "%.1f", ) -> str: - """Format a number of bytes like a human-readable file size. + """Format a number of bytes like a human-readable filesize (e.g. 10 kB). + + By default, decimal suffixes (kB, MB) are used. + + Non-GNU modes are compatible with jinja2's `filesizeformat` filter. Examples: - >>> naturalsize(42) - '42 Bytes' - >>> naturalsize(42000) - '42.0 kB' - >>> naturalsize(42000000) - '42.0 MB' - - When a locale is activated via ``humanize.i18n.activate()``, - the unit suffixes will be translated accordingly. - - :param value: The number of bytes. - :param binary: Use binary (powers of 1024) units instead of decimal. - :param format: Numeric format string. - :return: Human-readable file size. - """ - try: - bytes_value = float(value) - except (TypeError, ValueError): - return str(value) + ```pycon + >>> naturalsize(3000000) + '3.0 MB' + >>> naturalsize(300, False, True) + '300B' + >>> naturalsize(3000, False, True) + '2.9K' + >>> naturalsize(3000, False, True, "%.3f") + '2.930K' + >>> naturalsize(3000, True) + '2.9 KiB' + >>> naturalsize(10**28) + '10.0 RB' + >>> naturalsize(10**34 * 3) + '30000.0 QB' + >>> naturalsize(-4096, True) + '-4.0 KiB' - if bytes_value == 1: - return _("1 Byte") - if bytes_value < 1024: - return _("%d Bytes") % bytes_value + ``` + + Args: + value (int, float, str): Integer to convert. + binary (bool): If `True`, uses binary suffixes (KiB, MiB) with base + 210 instead of 103. + gnu (bool): If `True`, the binary argument is ignored and GNU-style + (`ls -sh` style) prefixes are used (K, M) with the 2**10 definition. + format (str): Custom formatter. + + Returns: + str: Human readable representation of a filesize. + """ + if gnu: + suffix = suffixes["gnu"] + elif binary: + suffix = suffixes["binary"] + else: + suffix = suffixes["decimal"] - base = 1024 if binary else 1000 - exp = int(log(bytes_value, base)) - exp = min(exp, len(_SUFFIXES["binary"]) if binary else len(_SUFFIXES["decimal"])) + base = 1024 if (gnu or binary) else 1000 + bytes_ = float(value) + abs_bytes = abs(bytes_) - value = bytes_value / base**exp + if abs_bytes == 1 and not gnu: + return f"{int(bytes_)} Byte" - suffix = _SUFFIXES["binary"][exp - 1] if binary else _SUFFIXES["decimal"][exp - 1] + if abs_bytes < base: + return f"{int(bytes_)}B" if gnu else f"{int(bytes_)} Bytes" - return (format % value) + suffix + exp = int(min(log(abs_bytes, base), len(suffix))) + space = "" if gnu else " " + ret: str = format % (bytes_ / (base**exp)) + space + _(suffix[exp - 1]) + return ret diff --git a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po index d0a07fb..43de50b 100644 --- a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po @@ -394,3 +394,40 @@ msgstr "Zo" msgid "YB" msgstr "Yo" + +msgid "RB" +msgstr "Ro" + +msgid "QB" +msgstr "Qo" + +# --- binary filesize units --- +msgid "KiB" +msgstr "Kio" + +msgid "MiB" +msgstr "Mio" + +msgid "GiB" +msgstr "Gio" + +msgid "TiB" +msgstr "Tio" + +msgid "PiB" +msgstr "Pio" + +msgid "EiB" +msgstr "Eio" + +msgid "ZiB" +msgstr "Zio" + +msgid "YiB" +msgstr "Yio" + +msgid "RiB" +msgstr "Rio" + +msgid "QiB" +msgstr "Qio" From e4e50d5860720b2d5fa51e7346f01a22580acfa2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:31:18 +0200 Subject: [PATCH 6/6] Add tests and fix Byte/Bytes localisation --- src/humanize/filesize.py | 4 +-- .../locale/fr_FR/LC_MESSAGES/humanize.po | 10 +++--- tests/test_i18n.py | 33 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 3782253..c495fed 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -91,10 +91,10 @@ def naturalsize( abs_bytes = abs(bytes_) if abs_bytes == 1 and not gnu: - return f"{int(bytes_)} Byte" + return _("%d Byte") % int(bytes_) if abs_bytes < base: - return f"{int(bytes_)}B" if gnu else f"{int(bytes_)} Bytes" + return f"{int(bytes_)}B" if gnu else _("%d Bytes") % int(bytes_) exp = int(min(log(abs_bytes, base), len(suffix))) space = "" if gnu else " " diff --git a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po index 43de50b..e9a4220 100644 --- a/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/fr_FR/LC_MESSAGES/humanize.po @@ -365,11 +365,13 @@ msgid "%s and %s" msgstr "%s et %s" # --- filesize units (naturalsize) --- -msgid "Byte" -msgstr "octet" +#, python-format +msgid "%d Byte" +msgstr "%d octet" -msgid "Bytes" -msgstr "octets" +#, python-format +msgid "%d Bytes" +msgstr "%d octets" msgid "kB" msgstr "Ko" diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 59e896c..3a4c2b9 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -157,6 +157,39 @@ def test_intword_i18n(locale: str, number: int, expected_result: str) -> None: humanize.i18n.deactivate() +@pytest.mark.parametrize( + "locale, value, expected_result", + [ + ("fr_FR", 1, "1 octet"), + ("fr_FR", 42, "42 octets"), + ("fr_FR", 42_000, "42.0 Ko"), + ("fr_FR", 42_000_000, "42.0 Mo"), + ("fr_FR", 42_000_000_000, "42.0 Go"), + ("fr_FR", -42_000, "-42.0 Ko"), + ], +) +def test_naturalsize_i18n(locale: str, value: float, expected_result: str) -> None: + try: + humanize.i18n.activate(locale) + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturalsize(value) == expected_result + finally: + humanize.i18n.deactivate() + + +def test_naturalsize_i18n_binary() -> None: + try: + humanize.i18n.activate("fr_FR") + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.naturalsize(3000, binary=True) == "2.9 Kio" + finally: + humanize.i18n.deactivate() + + @pytest.mark.parametrize( "locale, expected_result", [