From 9b836198bb96a99b85f9bc5e57df676ef93d99a7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 3 May 2022 14:53:10 +0300 Subject: [PATCH 01/55] Typing: ignore second import --- src/humanize/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 0953c6de..dda543da 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -24,7 +24,7 @@ import importlib.metadata as importlib_metadata except ImportError: # Date: Tue, 3 May 2022 14:53:56 +0300 Subject: [PATCH 02/55] Autotyping: add -> None return type to functions without any return, yield, or raise in their body --- src/humanize/i18n.py | 2 +- tests/test_filesize.py | 2 +- tests/test_i18n.py | 18 +++++++++--------- tests/test_number.py | 16 ++++++++-------- tests/test_time.py | 40 ++++++++++++++++++++-------------------- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 1e76a698..75fd92f7 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -61,7 +61,7 @@ def activate(locale, path=None): return _TRANSLATIONS[locale] -def deactivate(): +def deactivate() -> None: """Deactivate internationalisation.""" _CURRENT.locale = None diff --git a/tests/test_filesize.py b/tests/test_filesize.py index 3e31de0a..35006175 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -32,7 +32,7 @@ ([10**26 * 30, True, False, "%.3f"], "2481.542 YiB"), ], ) -def test_naturalsize(test_args, expected): +def test_naturalsize(test_args, expected) -> None: assert humanize.naturalsize(*test_args) == expected args_with_negative = test_args diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 64e9b2ac..d08ae76b 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -7,7 +7,7 @@ import humanize -def test_i18n(): +def test_i18n() -> None: three_seconds = dt.timedelta(seconds=3) one_min_three_seconds = dt.timedelta(milliseconds=67_000) @@ -31,7 +31,7 @@ def test_i18n(): assert humanize.precisedelta(one_min_three_seconds) == "1 minute and 7 seconds" -def test_intcomma(): +def test_intcomma() -> None: number = 10_000_000 assert humanize.intcomma(number) == "10,000,000" @@ -59,7 +59,7 @@ def test_intcomma(): ("es_ES", 6700000000000, "6.7 trillones"), ), ) -def test_intword_plurals(locale, number, expected_result): +def test_intword_plurals(locale, number, expected_result) -> None: try: humanize.i18n.activate(locale) except FileNotFoundError: @@ -82,7 +82,7 @@ def test_intword_plurals(locale, number, expected_result): ("it_IT", 8, "female", "8ª"), ), ) -def test_ordinal_genders(locale, number, gender, expected_result): +def test_ordinal_genders(locale, number, gender, expected_result) -> None: try: humanize.i18n.activate(locale) except FileNotFoundError: @@ -93,18 +93,18 @@ def test_ordinal_genders(locale, number, gender, expected_result): humanize.i18n.deactivate() -def test_default_locale_path_defined__file__(): +def test_default_locale_path_defined__file__() -> None: i18n = importlib.import_module("humanize.i18n") assert i18n._get_default_locale_path() is not None -def test_default_locale_path_null__file__(): +def test_default_locale_path_null__file__() -> None: i18n = importlib.import_module("humanize.i18n") i18n.__file__ = None assert i18n._get_default_locale_path() is None -def test_default_locale_path_undefined__file__(): +def test_default_locale_path_undefined__file__() -> None: i18n = importlib.import_module("humanize.i18n") del i18n.__file__ i18n._get_default_locale_path() is None @@ -116,7 +116,7 @@ class TestActivate: " 'locale' folder. You need to pass the path explicitly." ) - def test_default_locale_path_null__file__(self): + def test_default_locale_path_null__file__(self) -> None: i18n = importlib.import_module("humanize.i18n") i18n.__file__ = None @@ -124,7 +124,7 @@ def test_default_locale_path_null__file__(self): i18n.activate("ru_RU") assert str(excinfo.value) == self.expected_msg - def test_default_locale_path_undefined__file__(self): + def test_default_locale_path_undefined__file__(self) -> None: i18n = importlib.import_module("humanize.i18n") del i18n.__file__ diff --git a/tests/test_number.py b/tests/test_number.py index eec6217b..5d5b1721 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -24,7 +24,7 @@ (None, None), ], ) -def test_ordinal(test_input, expected): +def test_ordinal(test_input, expected) -> None: assert humanize.ordinal(test_input) == expected @@ -57,11 +57,11 @@ def test_ordinal(test_input, expected): ([1234.5454545, 10], "1,234.5454545000"), ], ) -def test_intcomma(test_args, expected): +def test_intcomma(test_args, expected) -> None: assert humanize.intcomma(*test_args) == expected -def test_intword_powers(): +def test_intword_powers() -> None: # make sure that powers & human_powers have the same number of items assert len(number.powers) == len(number.human_powers) @@ -92,7 +92,7 @@ def test_intword_powers(): ([10**101], "1" + "0" * 101), ], ) -def test_intword(test_args, expected): +def test_intword(test_args, expected) -> None: assert humanize.intword(*test_args) == expected @@ -110,7 +110,7 @@ def test_intword(test_args, expected): (None, None), ], ) -def test_apnumber(test_input, expected): +def test_apnumber(test_input, expected) -> None: assert humanize.apnumber(test_input) == expected @@ -131,7 +131,7 @@ def test_apnumber(test_input, expected): (0.333, "333/1000"), ], ) -def test_fractional(test_input, expected): +def test_fractional(test_input, expected) -> None: assert humanize.fractional(test_input) == expected @@ -153,7 +153,7 @@ def test_fractional(test_input, expected): ([float(0.3), 0], "3 x 10⁻¹"), ], ) -def test_scientific(test_args, expected): +def test_scientific(test_args, expected) -> None: assert humanize.scientific(*test_args) == expected @@ -170,5 +170,5 @@ def test_scientific(test_args, expected): ([1, humanize.intword, 1e6, None, "under "], "under 1.0 million"), ], ) -def test_clamp(test_args, expected): +def test_clamp(test_args, expected) -> None: assert humanize.clamp(*test_args) == expected diff --git a/tests/test_time.py b/tests/test_time.py index defb8e5b..c5e979f1 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -31,7 +31,7 @@ class FakeDate: - def __init__(self, year, month, day): + def __init__(self, year, month, day) -> None: self.year, self.month, self.day = year, month, day @@ -39,11 +39,11 @@ def __init__(self, year, month, day): OVERFLOW_ERROR_TEST = FakeDate(120390192341, 2, 2) -def assertEqualDatetime(dt1, dt2): +def assertEqualDatetime(dt1, dt2) -> None: assert (dt1 - dt2).seconds == 0 -def assertEqualTimedelta(td1, td2): +def assertEqualTimedelta(td1, td2) -> None: assert td1.days == td2.days assert td1.seconds == td2.seconds @@ -51,7 +51,7 @@ def assertEqualTimedelta(td1, td2): # These are not considered "public" interfaces, but require tests anyway. -def test_date_and_delta(): +def test_date_and_delta() -> None: now = dt.datetime.now() td = dt.timedelta int_tests = (3, 29, 86399, 86400, 86401 * 30) @@ -82,7 +82,7 @@ def nd_nomonths(d): (dt.timedelta(days=400), "1 year, 35 days"), ], ) -def test_naturaldelta_nomonths(test_input, expected): +def test_naturaldelta_nomonths(test_input, expected) -> None: assert nd_nomonths(test_input) == expected @@ -124,7 +124,7 @@ def test_naturaldelta_nomonths(test_input, expected): (dt.timedelta(days=999_999_999), "2,739,726 years"), ], ) -def test_naturaldelta(test_input, expected): +def test_naturaldelta(test_input, expected) -> None: assert humanize.naturaldelta(test_input) == expected @@ -160,7 +160,7 @@ def test_naturaldelta(test_input, expected): ("NaN", "NaN"), ], ) -def test_naturaltime(test_input, expected): +def test_naturaltime(test_input, expected) -> None: assert humanize.naturaltime(test_input) == expected @@ -202,7 +202,7 @@ def nt_nomonths(d): ("NaN", "NaN"), ], ) -def test_naturaltime_nomonths(test_input, expected): +def test_naturaltime_nomonths(test_input, expected) -> None: assert nt_nomonths(test_input) == expected @@ -222,7 +222,7 @@ def test_naturaltime_nomonths(test_input, expected): ([OVERFLOW_ERROR_TEST], OVERFLOW_ERROR_TEST), ], ) -def test_naturalday(test_args, expected): +def test_naturalday(test_args, expected) -> None: assert humanize.naturalday(*test_args) == expected @@ -266,7 +266,7 @@ def test_naturalday(test_args, expected): (dt.date(2021, 2, 2), "Feb 02 2021"), ], ) -def test_naturaldate(test_input, expected): +def test_naturaldate(test_input, expected) -> None: assert humanize.naturaldate(test_input) == expected @@ -284,7 +284,7 @@ def test_naturaldate(test_input, expected): (ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_default(seconds, expected): +def test_naturaldelta_minimum_unit_default(seconds, expected) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -327,7 +327,7 @@ def test_naturaldelta_minimum_unit_default(seconds, expected): ("microseconds", ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected): +def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -349,7 +349,7 @@ def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected): (ONE_YEAR + FOUR_MICROSECONDS, "a year ago"), ], ) -def test_naturaltime_minimum_unit_default(seconds, expected): +def test_naturaltime_minimum_unit_default(seconds, expected) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -392,7 +392,7 @@ def test_naturaltime_minimum_unit_default(seconds, expected): ("microseconds", ONE_YEAR + FOUR_MICROSECONDS, "a year ago"), ], ) -def test_naturaltime_minimum_unit_explicit(minimum_unit, seconds, expected): +def test_naturaltime_minimum_unit_explicit(minimum_unit, seconds, expected) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -421,7 +421,7 @@ def test_naturaltime_minimum_unit_explicit(minimum_unit, seconds, expected): (3600 * 24 * 365 * 1_963, "seconds", "1,963 years"), ], ) -def test_precisedelta_one_unit_enough(val, min_unit, expected): +def test_precisedelta_one_unit_enough(val, min_unit, expected) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -475,7 +475,7 @@ def test_precisedelta_one_unit_enough(val, min_unit, expected): ), ], ) -def test_precisedelta_multiple_units(val, min_unit, expected): +def test_precisedelta_multiple_units(val, min_unit, expected) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -524,7 +524,7 @@ def test_precisedelta_multiple_units(val, min_unit, expected): (dt.timedelta(days=183), "years", "%0.1f", "0.5 years"), ], ) -def test_precisedelta_custom_format(val, min_unit, fmt, expected): +def test_precisedelta_custom_format(val, min_unit, fmt, expected) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit, format=fmt) == expected @@ -599,13 +599,13 @@ def test_precisedelta_custom_format(val, min_unit, fmt, expected): ), ], ) -def test_precisedelta_suppress_units(val, min_unit, suppress, expected): +def test_precisedelta_suppress_units(val, min_unit, suppress, expected) -> None: assert ( humanize.precisedelta(val, minimum_unit=min_unit, suppress=suppress) == expected ) -def test_precisedelta_bogus_call(): +def test_precisedelta_bogus_call() -> None: assert humanize.precisedelta(None) is None with pytest.raises(ValueError): @@ -615,7 +615,7 @@ def test_precisedelta_bogus_call(): humanize.naturaldelta(1, minimum_unit="years") -def test_time_unit(): +def test_time_unit() -> None: years, minutes = time.Unit["YEARS"], time.Unit["MINUTES"] assert minutes < years assert years > minutes From 7971aa64e70b71f2dae8ca68c86009c07a4a3d34 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 3 May 2022 14:54:45 +0300 Subject: [PATCH 03/55] Autotyping: add a : bool annotation to any function parameter with a default of True or False --- src/humanize/filesize.py | 2 +- src/humanize/time.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 5497e8e6..e58c61f8 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -9,7 +9,7 @@ } -def naturalsize(value, binary=False, gnu=False, format="%.1f"): +def naturalsize(value, binary: bool = False, gnu: bool = False, format="%.1f"): """Format a number of bytes like a human readable filesize (e.g. 10 kB). By default, decimal suffixes (kB, MB) are used. diff --git a/src/humanize/time.py b/src/humanize/time.py index 3fbefed6..1df8a1d4 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -84,7 +84,7 @@ def _date_and_delta(value, *, now=None): def naturaldelta( value, - months=True, + months: bool = True, minimum_unit="seconds", ) -> str: """Return a natural representation of a timedelta or number of seconds. @@ -204,8 +204,8 @@ def naturaldelta( def naturaltime( value, - future=False, - months=True, + future: bool = False, + months: bool = True, minimum_unit="seconds", when=None, ) -> str: From 4de3e7408b5f3b2d10bc3b1f118290ca69e1722a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 3 May 2022 14:56:13 +0300 Subject: [PATCH 04/55] Autotyping: add an annotation to any parameter for which the default is a literal int, float, str object --- src/humanize/filesize.py | 2 +- src/humanize/number.py | 15 +++++++++++---- src/humanize/time.py | 10 ++++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index e58c61f8..f392fd90 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -9,7 +9,7 @@ } -def naturalsize(value, binary: bool = False, gnu: bool = False, format="%.1f"): +def naturalsize(value, binary: bool = False, gnu: bool = False, format: str = "%.1f"): """Format a number of bytes like a human readable filesize (e.g. 10 kB). By default, decimal suffixes (kB, MB) are used. diff --git a/src/humanize/number.py b/src/humanize/number.py index 6611a21f..92830e81 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -13,7 +13,7 @@ from .i18n import thousands_separator -def ordinal(value, gender="male"): +def ordinal(value, gender: str = "male"): """Converts an integer to its ordinal as a string. For example, 1 is "1st", 2 is "2nd", 3 is "3rd", etc. Works for any integer or @@ -153,7 +153,7 @@ def intcomma(value, ndigits=None): ) -def intword(value, format="%.1f"): +def intword(value, format: str = "%.1f"): """Converts a large integer to a friendly text representation. Works best for numbers over 1 million. For example, 1_000_000 becomes "1.0 million", @@ -312,7 +312,7 @@ def fractional(value): return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" -def scientific(value, precision=2): +def scientific(value, precision: int = 2): """Return number in string scientific notation z.wq x 10ⁿ. Examples: @@ -391,7 +391,14 @@ def scientific(value, precision=2): return final_str -def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_token=">"): +def clamp( + value, + format: str = "{:}", + floor=None, + ceil=None, + floor_token: str = "<", + ceil_token: str = ">", +): """Returns number with the specified format, clamped between floor and ceil. If the number is larger than ceil or smaller than floor, then the respective limit diff --git a/src/humanize/time.py b/src/humanize/time.py index 1df8a1d4..c1fa6541 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -85,7 +85,7 @@ def _date_and_delta(value, *, now=None): def naturaldelta( value, months: bool = True, - minimum_unit="seconds", + minimum_unit: str = "seconds", ) -> str: """Return a natural representation of a timedelta or number of seconds. @@ -206,7 +206,7 @@ def naturaltime( value, future: bool = False, months: bool = True, - minimum_unit="seconds", + minimum_unit: str = "seconds", when=None, ) -> str: """Return a natural representation of a time in a resolution that makes sense. @@ -244,7 +244,7 @@ def naturaltime( return ago % delta -def naturalday(value, format="%b %d") -> str: +def naturalday(value, format: str = "%b %d") -> str: """Return a natural day. For date values that are tomorrow, today or yesterday compared to @@ -396,7 +396,9 @@ def _suppress_lower_units(min_unit, suppress): return suppress -def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> str: +def precisedelta( + value, minimum_unit: str = "seconds", suppress=(), format: str = "%0.2f" +) -> str: """Return a precise representation of a timedelta. ```pycon From a4cacf01b93816fe4edb2d4fe376cc1381cc980c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 3 May 2022 15:00:04 +0300 Subject: [PATCH 05/55] Move final return out of else --- src/humanize/time.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index c1fa6541..0158fa56 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -198,8 +198,8 @@ def naturaldelta( ) else: return _ngettext("1 year, %d day", "1 year, %d days", days) % days - else: - return _ngettext("%s year", "%s years", years) % intcomma(years) + + return _ngettext("%s year", "%s years", years) % intcomma(years) def naturaltime( From e8f0e85631adb1d1476dd2a06d9037990391d624 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 3 May 2022 18:35:53 +0300 Subject: [PATCH 06/55] Add type hints Co-authored-by: Jack Edge --- .pre-commit-config.yaml | 7 ++ src/humanize/filesize.py | 8 ++- src/humanize/i18n.py | 26 ++++---- src/humanize/number.py | 81 +++++++++++++---------- src/humanize/py.typed | 0 src/humanize/time.py | 138 ++++++++++++++++++++++----------------- tests/test_filesize.py | 3 +- tests/test_i18n.py | 13 +++- tests/test_number.py | 31 +++++---- tests/test_time.py | 82 +++++++++++++---------- 10 files changed, 234 insertions(+), 155 deletions(-) create mode 100644 src/humanize/py.typed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 812779d5..ed8a4a65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,6 +56,13 @@ repos: args: ["--convention", "google"] files: "src/" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.942 + hooks: + - id: mypy + additional_dependencies: [pytest, types-freezegun, types-setuptools] + args: [--strict] + - repo: https://github.com/asottile/setup-cfg-fmt rev: v1.20.1 hooks: diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index f392fd90..026a5a6a 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """Bits and bytes related humanization.""" +from __future__ import annotations suffixes = { "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), @@ -9,7 +10,12 @@ } -def naturalsize(value, binary: bool = False, gnu: bool = False, format: str = "%.1f"): +def naturalsize( + value: int | 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. diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 75fd92f7..6c95749d 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -1,11 +1,15 @@ """Activate, get and deactivate translations.""" +from __future__ import annotations + import gettext as gettext_module import os.path from threading import local __all__ = ["activate", "deactivate", "thousands_separator"] -_TRANSLATIONS = {None: gettext_module.NullTranslations()} +_TRANSLATIONS: dict[str | None, gettext_module.NullTranslations] = { + None: gettext_module.NullTranslations() +} _CURRENT = local() @@ -15,7 +19,7 @@ } -def _get_default_locale_path(): +def _get_default_locale_path() -> str | None: try: if __file__ is None: return None @@ -24,14 +28,14 @@ def _get_default_locale_path(): return None -def get_translation(): +def get_translation() -> gettext_module.NullTranslations: try: return _TRANSLATIONS[_CURRENT.locale] except (AttributeError, KeyError): return _TRANSLATIONS[None] -def activate(locale, path=None): +def activate(locale: str, path: str | None = None) -> gettext_module.NullTranslations: """Activate internationalisation. Set `locale` as current locale. Search for locale in directory `path`. @@ -66,7 +70,7 @@ def deactivate() -> None: _CURRENT.locale = None -def _gettext(message): +def _gettext(message: str) -> str: """Get translation. Args: @@ -78,7 +82,7 @@ def _gettext(message): return get_translation().gettext(message) -def _pgettext(msgctxt, message): +def _pgettext(msgctxt: str, message: str) -> str: """Fetches a particular translation. It works with `msgctxt` .po modifiers and allows duplicate keys with different @@ -103,13 +107,13 @@ def _pgettext(msgctxt, message): return message if translation == key else translation -def _ngettext(message, plural, num): +def _ngettext(message: str, plural: str, num: int) -> str: """Plural version of _gettext. Args: message (str): Singular text to translate. plural (str): Plural text to translate. - num (str): The number (e.g. item count) to determine translation for the + num (int): The number (e.g. item count) to determine translation for the respective grammatical number. Returns: @@ -118,7 +122,7 @@ def _ngettext(message, plural, num): return get_translation().ngettext(message, plural, num) -def _gettext_noop(message): +def _gettext_noop(message: str) -> str: """Mark a string as a translation string without translating it. Example usage: @@ -137,7 +141,7 @@ def num_name(n): return message -def _ngettext_noop(singular, plural): +def _ngettext_noop(singular: str, plural: str) -> tuple[str, str]: """Mark two strings as pluralized translations without translating them. Example usage: @@ -154,7 +158,7 @@ def num_name(n): Returns: tuple: Original text, unchanged. """ - return (singular, plural) + return singular, plural def thousands_separator() -> str: diff --git a/src/humanize/number.py b/src/humanize/number.py index 92830e81..0925761c 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -1,10 +1,13 @@ #!/usr/bin/env python """Humanizing functions for numbers.""" +from __future__ import annotations import math import re +import sys from fractions import Fraction +from typing import TYPE_CHECKING from .i18n import _gettext as _ from .i18n import _ngettext @@ -12,13 +15,23 @@ from .i18n import _pgettext as P_ from .i18n import thousands_separator +if TYPE_CHECKING: + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + from typing_extensions import TypeAlias + +# This type can be better defined by typing.SupportsInt, typing.SupportsFloat +# but that's a Python 3.8 only typing option. +NumberOrString: TypeAlias = "int | float | str" -def ordinal(value, gender: str = "male"): + +def ordinal(value: NumberOrString, gender: str = "male") -> str: """Converts an integer to its ordinal as a string. For example, 1 is "1st", 2 is "2nd", 3 is "3rd", etc. Works for any integer or - anything `int()` will turn into an integer. Anything other value will have nothing - done to it. + anything `int()` will turn into an integer. Anything else will return the output + of str(value). Examples: ```pycon @@ -38,7 +51,7 @@ def ordinal(value, gender: str = "male"): '111th' >>> ordinal("something else") 'something else' - >>> ordinal(None) is None + >>> ordinal([1, 2, 3]) == "[1, 2, 3]" True ``` @@ -52,7 +65,7 @@ def ordinal(value, gender: str = "male"): try: value = int(value) except (TypeError, ValueError): - return value + return str(value) if gender == "male": t = ( P_("0 (male)", "th"), @@ -84,7 +97,7 @@ def ordinal(value, gender: str = "male"): return f"{value}{t[value % 10]}" -def intcomma(value, ndigits=None): +def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: """Converts an integer to a string containing commas every three digits. For example, 3000 becomes "3,000" and 45000 becomes "45,000". To maintain some @@ -104,8 +117,8 @@ def intcomma(value, ndigits=None): '1,234.55' >>> intcomma(14308.40, 1) '14,308.4' - >>> intcomma(None) is None - True + >>> intcomma(None) + 'None' ``` Args: @@ -122,7 +135,7 @@ def intcomma(value, ndigits=None): else: float(value) except (TypeError, ValueError): - return value + return str(value) if ndigits: orig = "{0:.{1}f}".format(value, ndigits) @@ -153,7 +166,7 @@ def intcomma(value, ndigits=None): ) -def intword(value, format: str = "%.1f"): +def intword(value: NumberOrString, format: str = "%.1f") -> str: """Converts a large integer to a friendly text representation. Works best for numbers over 1 million. For example, 1_000_000 becomes "1.0 million", @@ -172,8 +185,8 @@ def intword(value, format: str = "%.1f"): '1.2 billion' >>> intword(8100000000000000000000000000000000) '8.1 decillion' - >>> intword(None) is None - True + >>> intword(None) + 'None' >>> intword("1234000", "%0.3f") '1.234 million' @@ -190,7 +203,7 @@ def intword(value, format: str = "%.1f"): try: value = int(value) except (TypeError, ValueError): - return value + return str(value) if value < powers[0]: return str(value) @@ -211,7 +224,7 @@ def intword(value, format: str = "%.1f"): return str(value) -def apnumber(value): +def apnumber(value: NumberOrString) -> str: """Converts an integer to Associated Press style. Examples: @@ -226,8 +239,8 @@ def apnumber(value): 'seven' >>> apnumber("foo") 'foo' - >>> apnumber(None) is None - True + >>> apnumber(None) + 'None' ``` Args: @@ -235,12 +248,13 @@ def apnumber(value): Returns: str: For numbers 0-9, the number spelled out. Otherwise, the number. This always - returns a string unless the value was not `int`-able, unlike the Django filter. + returns a string unless the value was not `int`-able, then `str(value)` + is returned. """ try: value = int(value) except (TypeError, ValueError): - return value + return str(value) if not 0 <= value < 10: return str(value) return ( @@ -257,7 +271,7 @@ def apnumber(value): )[value] -def fractional(value): +def fractional(value: NumberOrString) -> str: """Convert to fractional number. There will be some cases where one might not want to show ugly decimal places for @@ -271,6 +285,7 @@ def fractional(value): * a string representation of a fraction * or a whole number * or a mixed fraction + * or the str output of the value, if it could not be converted Examples: ```pycon @@ -284,8 +299,8 @@ def fractional(value): '1' >>> fractional("ten") 'ten' - >>> fractional(None) is None - True + >>> fractional(None) + 'None' ``` Args: @@ -297,11 +312,11 @@ def fractional(value): try: number = float(value) except (TypeError, ValueError): - return value + return str(value) whole_number = int(number) frac = Fraction(number - whole_number).limit_denominator(1000) - numerator = frac._numerator - denominator = frac._denominator + numerator = frac.numerator + denominator = frac.denominator if whole_number and not numerator and denominator == 1: # this means that an integer was passed in # (or variants of that integer like 1.0000) @@ -312,7 +327,7 @@ def fractional(value): return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" -def scientific(value, precision: int = 2): +def scientific(value: NumberOrString, precision: int = 2) -> str: """Return number in string scientific notation z.wq x 10ⁿ. Examples: @@ -331,8 +346,8 @@ def scientific(value, precision: int = 2): '9.90 x 10¹' >>> scientific("foo") 'foo' - >>> scientific(None) is None - True + >>> scientific(None) + 'None' ``` @@ -370,7 +385,7 @@ def scientific(value, precision: int = 2): n = fmt.format(value) except (ValueError, TypeError): - return value + return str(value) part1, part2 = n.split("e") if "-0" in part2: @@ -392,13 +407,13 @@ def scientific(value, precision: int = 2): def clamp( - value, + value: int | float, format: str = "{:}", - floor=None, - ceil=None, + floor: int | float | None = None, + ceil: int | float | None = None, floor_token: str = "<", ceil_token: str = ">", -): +) -> str: """Returns number with the specified format, clamped between floor and ceil. If the number is larger than ceil or smaller than floor, then the respective limit @@ -434,7 +449,7 @@ def clamp( Returns: str: Formatted number. The output is clamped between the indicated floor and - ceil. If the number if larger than ceil or smaller than floor, the output will + ceil. If the number is larger than ceil or smaller than floor, the output will be prepended with a token indicating as such. """ diff --git a/src/humanize/py.typed b/src/humanize/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/humanize/time.py b/src/humanize/time.py index 0158fa56..9665d50c 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -4,9 +4,11 @@ These are largely borrowed from Django's `contrib.humanize`. """ +from __future__ import annotations import datetime as dt import math +import typing from enum import Enum from functools import total_ordering @@ -34,17 +36,17 @@ class Unit(Enum): MONTHS = 6 YEARS = 7 - def __lt__(self, other): + def __lt__(self, other: typing.Any) -> typing.Any: if self.__class__ is other.__class__: return self.value < other.value return NotImplemented -def _now(): +def _now() -> dt.datetime: return dt.datetime.now() -def _abs_timedelta(delta): +def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta: """Return an "absolute" value for a timedelta, always representing a time distance. Args: @@ -59,7 +61,9 @@ def _abs_timedelta(delta): return delta -def _date_and_delta(value, *, now=None): +def _date_and_delta( + value: typing.Any, *, now: dt.datetime | None = None +) -> tuple[typing.Any, typing.Any]: """Turn a value into a date and a timedelta which represents how long ago it was. If that's not possible, return `(None, value)`. @@ -83,7 +87,7 @@ def _date_and_delta(value, *, now=None): def naturaldelta( - value, + value: dt.timedelta | int, months: bool = True, minimum_unit: str = "seconds", ) -> str: @@ -122,7 +126,7 @@ def naturaldelta( tmp = Unit[minimum_unit.upper()] if tmp not in (Unit.SECONDS, Unit.MILLISECONDS, Unit.MICROSECONDS): raise ValueError(f"Minimum unit '{minimum_unit}' not supported") - minimum_unit = tmp + min_unit = tmp if isinstance(value, dt.timedelta): delta = value @@ -131,7 +135,7 @@ def naturaldelta( value = int(value) delta = dt.timedelta(seconds=value) except (ValueError, TypeError): - return value + return str(value) use_months = months @@ -139,22 +143,21 @@ def naturaldelta( days = abs(delta.days) years = days // 365 days = days % 365 - months = int(days // 30.5) + num_months = int(days // 30.5) if not years and days < 1: if seconds == 0: - if minimum_unit == Unit.MICROSECONDS and delta.microseconds < 1000: + if min_unit == Unit.MICROSECONDS and delta.microseconds < 1000: return ( _ngettext("%d microsecond", "%d microseconds", delta.microseconds) % delta.microseconds ) - elif minimum_unit == Unit.MILLISECONDS or ( - minimum_unit == Unit.MICROSECONDS - and 1000 <= delta.microseconds < 1_000_000 + elif min_unit == Unit.MILLISECONDS or ( + min_unit == Unit.MICROSECONDS and 1000 <= delta.microseconds < 1_000_000 ): milliseconds = delta.microseconds / 1000 return ( - _ngettext("%d millisecond", "%d milliseconds", milliseconds) + _ngettext("%d millisecond", "%d milliseconds", int(milliseconds)) % milliseconds ) return _("a moment") @@ -178,23 +181,24 @@ def naturaldelta( if not use_months: return _ngettext("%d day", "%d days", days) % days else: - if not months: + if not num_months: return _ngettext("%d day", "%d days", days) % days - elif months == 1: + elif num_months == 1: return _("a month") else: - return _ngettext("%d month", "%d months", months) % months + return _ngettext("%d month", "%d months", num_months) % num_months elif years == 1: - if not months and not days: + if not num_months and not days: return _("a year") - elif not months: + elif not num_months: return _ngettext("1 year, %d day", "1 year, %d days", days) % days elif use_months: - if months == 1: + if num_months == 1: return _("1 year, 1 month") else: return ( - _ngettext("1 year, %d month", "1 year, %d months", months) % months + _ngettext("1 year, %d month", "1 year, %d months", num_months) + % num_months ) else: return _ngettext("1 year, %d day", "1 year, %d days", days) % days @@ -203,11 +207,11 @@ def naturaldelta( def naturaltime( - value, + value: dt.datetime | int, future: bool = False, months: bool = True, minimum_unit: str = "seconds", - when=None, + when: dt.datetime | None = None, ) -> str: """Return a natural representation of a time in a resolution that makes sense. @@ -230,7 +234,7 @@ def naturaltime( now = when or _now() date, delta = _date_and_delta(value, now=now) if date is None: - return value + return str(value) # determine tense by value only if datetime/timedelta were passed if isinstance(value, (dt.datetime, dt.timedelta)): future = date > now @@ -241,10 +245,10 @@ def naturaltime( if delta == _("a moment"): return _("now") - return ago % delta + return str(ago % delta) -def naturalday(value, format: str = "%b %d") -> str: +def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: """Return a natural day. For date values that are tomorrow, today or yesterday compared to @@ -256,10 +260,10 @@ def naturalday(value, format: str = "%b %d") -> str: value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish - return value + return str(value) except (OverflowError, ValueError): # Date arguments out of range - return value + return str(value) delta = value - dt.date.today() if delta.days == 0: return _("today") @@ -270,23 +274,29 @@ def naturalday(value, format: str = "%b %d") -> str: return value.strftime(format) -def naturaldate(value) -> str: +def naturaldate(value: dt.date | dt.datetime) -> str: """Like `naturalday`, but append a year for dates more than ~five months away.""" try: value = dt.date(value.year, value.month, value.day) except AttributeError: # Passed value wasn't date-ish - return value + return str(value) except (OverflowError, ValueError): # Date arguments out of range - return value + return str(value) delta = _abs_timedelta(value - dt.date.today()) if delta.days >= 5 * 365 / 12: return naturalday(value, "%b %d %Y") return naturalday(value) -def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): +def _quotient_and_remainder( + value: int | float, + divisor: int | float, + unit: Unit, + minimum_unit: Unit, + suppress: typing.Iterable[Unit], +) -> tuple[float, float]: """Divide `value` by `divisor` returning the quotient and remainder. If `unit` is `minimum_unit`, makes the quotient a float number and the remainder @@ -312,14 +322,21 @@ def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): """ if unit == minimum_unit: - return (value / divisor, 0) + return value / divisor, 0 elif unit in suppress: - return (0, value) + return 0, value else: return divmod(value, divisor) -def _carry(value1, value2, ratio, unit, min_unit, suppress): +def _carry( + value1: int | float, + value2: int | float, + ratio: int | float, + unit: Unit, + min_unit: Unit, + suppress: typing.Iterable[Unit], +) -> tuple[float, float]: """Return a tuple with two values. If the unit is in `suppress`, multiply `value1` by `ratio` and add it to `value2` @@ -343,14 +360,14 @@ def _carry(value1, value2, ratio, unit, min_unit, suppress): (2, 6) """ if unit == min_unit: - return (value1 + value2 / ratio, 0) + return value1 + value2 / ratio, 0 elif unit in suppress: - return (0, value2 + value1 * ratio) + return 0, value2 + value1 * ratio else: - return (value1, value2) + return value1, value2 -def _suitable_minimum_unit(min_unit, suppress): +def _suitable_minimum_unit(min_unit: Unit, suppress: typing.Iterable[Unit]) -> Unit: """Return a minimum unit suitable that is not suppressed. If not suppressed, return the same unit: @@ -380,7 +397,7 @@ def _suitable_minimum_unit(min_unit, suppress): return min_unit -def _suppress_lower_units(min_unit, suppress): +def _suppress_lower_units(min_unit: Unit, suppress: typing.Iterable[Unit]) -> set[Unit]: """Extend suppressed units (if any) with all units lower than the minimum unit. >>> from humanize.time import _suppress_lower_units, Unit @@ -397,7 +414,10 @@ def _suppress_lower_units(min_unit, suppress): def precisedelta( - value, minimum_unit: str = "seconds", suppress=(), format: str = "%0.2f" + value: dt.timedelta | int, + minimum_unit: str = "seconds", + suppress: typing.Iterable[str] = (), + format: str = "%0.2f", ) -> str: """Return a precise representation of a timedelta. @@ -467,19 +487,19 @@ def precisedelta( """ date, delta = _date_and_delta(value) if date is None: - return value + return str(value) - suppress = [Unit[s.upper()] for s in suppress] + suppress_set = {Unit[s.upper()] for s in suppress} # Find a suitable minimum unit (it can be greater the one that the # user gave us if it is suppressed). min_unit = Unit[minimum_unit.upper()] - min_unit = _suitable_minimum_unit(min_unit, suppress) + min_unit = _suitable_minimum_unit(min_unit, suppress_set) del minimum_unit # Expand the suppressed units list/set to include all the units # that are below the minimum unit - suppress = _suppress_lower_units(min_unit, suppress) + suppress_set = _suppress_lower_units(min_unit, suppress_set) # handy aliases days = delta.days @@ -502,27 +522,27 @@ def precisedelta( # years, days = divmod(years, days) # # The same applies for months, hours, minutes and milliseconds below - years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress) - months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress) + years, days = _quotient_and_remainder(days, 365, YEARS, min_unit, suppress_set) + months, days = _quotient_and_remainder(days, 30.5, MONTHS, min_unit, suppress_set) # If DAYS is not in suppress, we can represent the days but # if it is a suppressed unit, we need to carry it to a lower unit, # seconds in this case. # # The same applies for secs and usecs below - days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress) + days, secs = _carry(days, secs, 24 * 3600, DAYS, min_unit, suppress_set) - hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress) - minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress) + hours, secs = _quotient_and_remainder(secs, 3600, HOURS, min_unit, suppress_set) + minutes, secs = _quotient_and_remainder(secs, 60, MINUTES, min_unit, suppress_set) - secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress) + secs, usecs = _carry(secs, usecs, 1e6, SECONDS, min_unit, suppress_set) msecs, usecs = _quotient_and_remainder( - usecs, 1000, MILLISECONDS, min_unit, suppress + usecs, 1000, MILLISECONDS, min_unit, suppress_set ) # if _unused != 0 we had lost some precision - usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress) + usecs, _unused = _carry(usecs, 0, 1, MICROSECONDS, min_unit, suppress_set) fmts = [ ("%d year", "%d years", years), @@ -535,19 +555,19 @@ def precisedelta( ("%d microsecond", "%d microseconds", usecs), ] - texts = [] + texts: list[str] = [] for unit, fmt in zip(reversed(Unit), fmts): - singular_txt, plural_txt, value = fmt - if value > 0 or (not texts and unit == min_unit): - fmt_txt = _ngettext(singular_txt, plural_txt, value) - if unit == min_unit and math.modf(value)[0] > 0: + singular_txt, plural_txt, fmt_value = fmt + if fmt_value > 0 or (not texts and unit == min_unit): + fmt_txt = _ngettext(singular_txt, plural_txt, fmt_value) + if unit == min_unit and math.modf(fmt_value)[0] > 0: fmt_txt = fmt_txt.replace("%d", format) elif unit == YEARS: fmt_txt = fmt_txt.replace("%d", "%s") - texts.append(fmt_txt % intcomma(value)) + texts.append(fmt_txt % intcomma(fmt_value)) continue - texts.append(fmt_txt % value) + texts.append(fmt_txt % fmt_value) if unit == min_unit: break diff --git a/tests/test_filesize.py b/tests/test_filesize.py index 35006175..0119d585 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """Tests for filesize humanizing.""" +from __future__ import annotations import pytest @@ -32,7 +33,7 @@ ([10**26 * 30, True, False, "%.3f"], "2481.542 YiB"), ], ) -def test_naturalsize(test_args, expected) -> None: +def test_naturalsize(test_args: list[int] | list[int | bool], expected: str) -> None: assert humanize.naturalsize(*test_args) == expected args_with_negative = test_args diff --git a/tests/test_i18n.py b/tests/test_i18n.py index d08ae76b..8b646969 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -3,12 +3,17 @@ import importlib import pytest +from freezegun import freeze_time import humanize +with freeze_time("2020-02-02"): + NOW = dt.datetime.now() + +@freeze_time("2020-02-02") def test_i18n() -> None: - three_seconds = dt.timedelta(seconds=3) + three_seconds = NOW - dt.timedelta(seconds=3) one_min_three_seconds = dt.timedelta(milliseconds=67_000) assert humanize.naturaltime(three_seconds) == "3 seconds ago" @@ -59,7 +64,7 @@ def test_intcomma() -> None: ("es_ES", 6700000000000, "6.7 trillones"), ), ) -def test_intword_plurals(locale, number, expected_result) -> None: +def test_intword_plurals(locale: str, number: int, expected_result: str) -> None: try: humanize.i18n.activate(locale) except FileNotFoundError: @@ -82,7 +87,9 @@ def test_intword_plurals(locale, number, expected_result) -> None: ("it_IT", 8, "female", "8ª"), ), ) -def test_ordinal_genders(locale, number, gender, expected_result) -> None: +def test_ordinal_genders( + locale: str, number: int, gender: str, expected_result: str +) -> None: try: humanize.i18n.activate(locale) except FileNotFoundError: diff --git a/tests/test_number.py b/tests/test_number.py index 5d5b1721..0ad8f7a0 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,4 +1,7 @@ """Number tests.""" +from __future__ import annotations + +import typing import pytest @@ -21,10 +24,10 @@ ("103", "103rd"), ("111", "111th"), ("something else", "something else"), - (None, None), + (None, "None"), ], ) -def test_ordinal(test_input, expected) -> None: +def test_ordinal(test_input: str, expected: str) -> None: assert humanize.ordinal(test_input) == expected @@ -43,7 +46,7 @@ def test_ordinal(test_input, expected) -> None: (["10311"], "10,311"), (["1000000"], "1,000,000"), (["1234567.1234567"], "1,234,567.1234567"), - ([None], None), + ([None], "None"), ([14308.40], "14,308.4"), ([14308.40, None], "14,308.4"), ([14308.40, 1], "14,308.4"), @@ -57,7 +60,9 @@ def test_ordinal(test_input, expected) -> None: ([1234.5454545, 10], "1,234.5454545000"), ], ) -def test_intcomma(test_args, expected) -> None: +def test_intcomma( + test_args: list[int] | list[float] | list[str], expected: str +) -> None: assert humanize.intcomma(*test_args) == expected @@ -87,12 +92,12 @@ def test_intword_powers() -> None: (["1300000000000000"], "1.3 quadrillion"), (["3500000000000000000000"], "3.5 sextillion"), (["8100000000000000000000000000000000"], "8.1 decillion"), - ([None], None), + ([None], "None"), (["1230000", "%0.2f"], "1.23 million"), ([10**101], "1" + "0" * 101), ], ) -def test_intword(test_args, expected) -> None: +def test_intword(test_args: list[str], expected: str) -> None: assert humanize.intword(*test_args) == expected @@ -107,10 +112,10 @@ def test_intword(test_args, expected) -> None: (9, "nine"), (10, "10"), ("7", "seven"), - (None, None), + (None, "None"), ], ) -def test_apnumber(test_input, expected) -> None: +def test_apnumber(test_input: int | str, expected: str) -> None: assert humanize.apnumber(test_input) == expected @@ -124,14 +129,14 @@ def test_apnumber(test_input, expected) -> None: ("7", "7"), ("8.9", "8 9/10"), ("ten", "ten"), - (None, None), + (None, "None"), (1 / 3, "1/3"), (1.5, "1 1/2"), (0.3, "3/10"), (0.333, "333/1000"), ], ) -def test_fractional(test_input, expected) -> None: +def test_fractional(test_input: int | float | str, expected: str) -> None: assert humanize.fractional(test_input) == expected @@ -146,14 +151,14 @@ def test_fractional(test_input, expected) -> None: (["99"], "9.90 x 10¹"), ([float(0.3)], "3.00 x 10⁻¹"), (["foo"], "foo"), - ([None], None), + ([None], "None"), ([1000, 1], "1.0 x 10³"), ([float(0.3), 1], "3.0 x 10⁻¹"), ([1000, 0], "1 x 10³"), ([float(0.3), 0], "3 x 10⁻¹"), ], ) -def test_scientific(test_args, expected) -> None: +def test_scientific(test_args: list[typing.Any], expected: str) -> None: assert humanize.scientific(*test_args) == expected @@ -170,5 +175,5 @@ def test_scientific(test_args, expected) -> None: ([1, humanize.intword, 1e6, None, "under "], "under 1.0 million"), ], ) -def test_clamp(test_args, expected) -> None: +def test_clamp(test_args: list[typing.Any], expected: str) -> None: assert humanize.clamp(*test_args) == expected diff --git a/tests/test_time.py b/tests/test_time.py index c5e979f1..b6792aa8 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,6 +1,8 @@ """Tests for time humanizing.""" +from __future__ import annotations import datetime as dt +import typing import pytest from freezegun import freeze_time @@ -31,7 +33,7 @@ class FakeDate: - def __init__(self, year, month, day) -> None: + def __init__(self, year: int, month: int, day: int) -> None: self.year, self.month, self.day = year, month, day @@ -39,11 +41,11 @@ def __init__(self, year, month, day) -> None: OVERFLOW_ERROR_TEST = FakeDate(120390192341, 2, 2) -def assertEqualDatetime(dt1, dt2) -> None: +def assert_equal_datetime(dt1: dt.datetime, dt2: dt.datetime) -> None: assert (dt1 - dt2).seconds == 0 -def assertEqualTimedelta(td1, td2) -> None: +def assert_equal_timedelta(td1: dt.timedelta, td2: dt.timedelta) -> None: assert td1.days == td2.days assert td1.seconds == td2.seconds @@ -61,15 +63,15 @@ def test_date_and_delta() -> None: for t in (int_tests, date_tests, td_tests): for arg, result in zip(t, results): date, d = time._date_and_delta(arg) - assertEqualDatetime(date, result[0]) - assertEqualTimedelta(d, result[1]) + assert_equal_datetime(date, result[0]) + assert_equal_timedelta(d, result[1]) assert time._date_and_delta("NaN") == (None, "NaN") # Tests for the public interface of humanize.time -def nd_nomonths(d): +def nd_nomonths(d: dt.timedelta) -> str: return humanize.naturaldelta(d, months=False) @@ -82,7 +84,7 @@ def nd_nomonths(d): (dt.timedelta(days=400), "1 year, 35 days"), ], ) -def test_naturaldelta_nomonths(test_input, expected) -> None: +def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None: assert nd_nomonths(test_input) == expected @@ -124,7 +126,7 @@ def test_naturaldelta_nomonths(test_input, expected) -> None: (dt.timedelta(days=999_999_999), "2,739,726 years"), ], ) -def test_naturaldelta(test_input, expected) -> None: +def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None: assert humanize.naturaldelta(test_input) == expected @@ -160,11 +162,11 @@ def test_naturaldelta(test_input, expected) -> None: ("NaN", "NaN"), ], ) -def test_naturaltime(test_input, expected) -> None: +def test_naturaltime(test_input: dt.datetime, expected: str) -> None: assert humanize.naturaltime(test_input) == expected -def nt_nomonths(d): +def nt_nomonths(d: dt.datetime) -> str: return humanize.naturaltime(d, months=False) @@ -202,7 +204,7 @@ def nt_nomonths(d): ("NaN", "NaN"), ], ) -def test_naturaltime_nomonths(test_input, expected) -> None: +def test_naturaltime_nomonths(test_input: dt.datetime, expected: str) -> None: assert nt_nomonths(test_input) == expected @@ -216,13 +218,13 @@ def test_naturaltime_nomonths(test_input, expected) -> None: ([dt.date(TODAY.year, 3, 5)], "Mar 05"), (["02/26/1984"], "02/26/1984"), ([dt.date(1982, 6, 27), "%Y.%m.%d"], "1982.06.27"), - ([None], None), + ([None], "None"), (["Not a date at all."], "Not a date at all."), - ([VALUE_ERROR_TEST], VALUE_ERROR_TEST), - ([OVERFLOW_ERROR_TEST], OVERFLOW_ERROR_TEST), + ([VALUE_ERROR_TEST], str(VALUE_ERROR_TEST)), + ([OVERFLOW_ERROR_TEST], str(OVERFLOW_ERROR_TEST)), ], ) -def test_naturalday(test_args, expected) -> None: +def test_naturalday(test_args: list[typing.Any], expected: str) -> None: assert humanize.naturalday(*test_args) == expected @@ -235,10 +237,10 @@ def test_naturalday(test_args, expected) -> None: (YESTERDAY, "yesterday"), (dt.date(TODAY.year, 3, 5), "Mar 05"), (dt.date(1982, 6, 27), "Jun 27 1982"), - (None, None), + (None, "None"), ("Not a date at all.", "Not a date at all."), - (VALUE_ERROR_TEST, VALUE_ERROR_TEST), - (OVERFLOW_ERROR_TEST, OVERFLOW_ERROR_TEST), + (VALUE_ERROR_TEST, str(VALUE_ERROR_TEST)), + (OVERFLOW_ERROR_TEST, str(OVERFLOW_ERROR_TEST)), (dt.date(2019, 2, 2), "Feb 02 2019"), (dt.date(2019, 3, 2), "Mar 02 2019"), (dt.date(2019, 4, 2), "Apr 02 2019"), @@ -266,7 +268,7 @@ def test_naturalday(test_args, expected) -> None: (dt.date(2021, 2, 2), "Feb 02 2021"), ], ) -def test_naturaldate(test_input, expected) -> None: +def test_naturaldate(test_input: dt.date, expected: str) -> None: assert humanize.naturaldate(test_input) == expected @@ -284,7 +286,7 @@ def test_naturaldate(test_input, expected) -> None: (ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_default(seconds, expected) -> None: +def test_naturaldelta_minimum_unit_default(seconds: int | float, expected: str) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -327,7 +329,9 @@ def test_naturaldelta_minimum_unit_default(seconds, expected) -> None: ("microseconds", ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected) -> None: +def test_naturaldelta_minimum_unit_explicit( + minimum_unit: str, seconds: int | float, expected: str +) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -335,6 +339,7 @@ def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected) -> assert humanize.naturaldelta(delta, minimum_unit=minimum_unit) == expected +@freeze_time("2020-02-02") @pytest.mark.parametrize( "seconds, expected", [ @@ -349,14 +354,15 @@ def test_naturaldelta_minimum_unit_explicit(minimum_unit, seconds, expected) -> (ONE_YEAR + FOUR_MICROSECONDS, "a year ago"), ], ) -def test_naturaltime_minimum_unit_default(seconds, expected) -> None: +def test_naturaltime_minimum_unit_default(seconds: int | float, expected: str) -> None: # Arrange - delta = dt.timedelta(seconds=seconds) + datetime = NOW - dt.timedelta(seconds=seconds) # Act / Assert - assert humanize.naturaltime(delta) == expected + assert humanize.naturaltime(datetime) == expected +@freeze_time("2020-02-02") @pytest.mark.parametrize( "minimum_unit, seconds, expected", [ @@ -392,12 +398,14 @@ def test_naturaltime_minimum_unit_default(seconds, expected) -> None: ("microseconds", ONE_YEAR + FOUR_MICROSECONDS, "a year ago"), ], ) -def test_naturaltime_minimum_unit_explicit(minimum_unit, seconds, expected) -> None: +def test_naturaltime_minimum_unit_explicit( + minimum_unit: str, seconds: int | float, expected: str +) -> None: # Arrange - delta = dt.timedelta(seconds=seconds) + datetime = NOW - dt.timedelta(seconds=seconds) # Act / Assert - assert humanize.naturaltime(delta, minimum_unit=minimum_unit) == expected + assert humanize.naturaltime(datetime, minimum_unit=minimum_unit) == expected @pytest.mark.parametrize( @@ -421,7 +429,9 @@ def test_naturaltime_minimum_unit_explicit(minimum_unit, seconds, expected) -> N (3600 * 24 * 365 * 1_963, "seconds", "1,963 years"), ], ) -def test_precisedelta_one_unit_enough(val, min_unit, expected) -> None: +def test_precisedelta_one_unit_enough( + val: int | dt.timedelta, min_unit: str, expected: str +) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -475,7 +485,9 @@ def test_precisedelta_one_unit_enough(val, min_unit, expected) -> None: ), ], ) -def test_precisedelta_multiple_units(val, min_unit, expected) -> None: +def test_precisedelta_multiple_units( + val: dt.timedelta, min_unit: str, expected: str +) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit) == expected @@ -524,7 +536,9 @@ def test_precisedelta_multiple_units(val, min_unit, expected) -> None: (dt.timedelta(days=183), "years", "%0.1f", "0.5 years"), ], ) -def test_precisedelta_custom_format(val, min_unit, fmt, expected) -> None: +def test_precisedelta_custom_format( + val: dt.timedelta, min_unit: str, fmt: str, expected: str +) -> None: assert humanize.precisedelta(val, minimum_unit=min_unit, format=fmt) == expected @@ -599,15 +613,15 @@ def test_precisedelta_custom_format(val, min_unit, fmt, expected) -> None: ), ], ) -def test_precisedelta_suppress_units(val, min_unit, suppress, expected) -> None: +def test_precisedelta_suppress_units( + val: dt.timedelta, min_unit: str, suppress: list[str], expected: str +) -> None: assert ( humanize.precisedelta(val, minimum_unit=min_unit, suppress=suppress) == expected ) def test_precisedelta_bogus_call() -> None: - assert humanize.precisedelta(None) is None - with pytest.raises(ValueError): humanize.precisedelta(1, minimum_unit="years", suppress=["years"]) @@ -622,4 +636,4 @@ def test_time_unit() -> None: assert minutes == minutes with pytest.raises(TypeError): - years < "foo" + assert years < "foo" From ab5bfe5299b0fbf6c335d9ef3905dbbc377e5953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Wed, 4 May 2022 09:42:39 +0200 Subject: [PATCH 07/55] Remove redundant `wheel` dependency from `pyproject.toml` The `wheel` dependency in `pyproject.toml` is not necessary, and modern setuptools documentation advises against adding it. The PEP517 backend automatically exposes the `wheel` dependency, and a future version of setuptools may no longer use it. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b44c906..1dd2e5c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ build-backend = "setuptools.build_meta" requires = [ "setuptools>=42", "setuptools_scm[toml]>=3.4", - "wheel", ] [tool.black] From 0a86628fbe4e7969639c38ad14c5d3edd0bcd81e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 4 May 2022 14:40:59 +0300 Subject: [PATCH 08/55] Silence PyCharm more generically Co-authored-by: coiax --- tests/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_time.py b/tests/test_time.py index b6792aa8..a4cc9dce 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -636,4 +636,4 @@ def test_time_unit() -> None: assert minutes == minutes with pytest.raises(TypeError): - assert years < "foo" + _ = years < "foo" From c86b13825c805a09aa98d46b5c8a6a085b928d3e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 4 May 2022 14:52:12 +0300 Subject: [PATCH 09/55] Replace deprecated typing.Iterable with collections.abc.Iterable --- src/humanize/time.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 9665d50c..c611dc21 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import collections.abc import datetime as dt import math import typing @@ -295,7 +296,7 @@ def _quotient_and_remainder( divisor: int | float, unit: Unit, minimum_unit: Unit, - suppress: typing.Iterable[Unit], + suppress: collections.abc.Iterable[Unit], ) -> tuple[float, float]: """Divide `value` by `divisor` returning the quotient and remainder. From 54af2f9c309d342d450ed1b3cb5c973b45579653 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 4 May 2022 14:59:47 +0300 Subject: [PATCH 10/55] Replace 'int | float' with 'float' --- src/humanize/filesize.py | 2 +- src/humanize/number.py | 8 ++++---- src/humanize/time.py | 10 +++++----- tests/test_number.py | 2 +- tests/test_time.py | 8 ++++---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 026a5a6a..14496005 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -11,7 +11,7 @@ def naturalsize( - value: int | float | str, + value: float | str, binary: bool = False, gnu: bool = False, format: str = "%.1f", diff --git a/src/humanize/number.py b/src/humanize/number.py index 0925761c..3f6070f8 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -23,7 +23,7 @@ # This type can be better defined by typing.SupportsInt, typing.SupportsFloat # but that's a Python 3.8 only typing option. -NumberOrString: TypeAlias = "int | float | str" +NumberOrString: TypeAlias = "float | str" def ordinal(value: NumberOrString, gender: str = "male") -> str: @@ -407,10 +407,10 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: def clamp( - value: int | float, + value: float, format: str = "{:}", - floor: int | float | None = None, - ceil: int | float | None = None, + floor: float | None = None, + ceil: float | None = None, floor_token: str = "<", ceil_token: str = ">", ) -> str: diff --git a/src/humanize/time.py b/src/humanize/time.py index c611dc21..5e14db8a 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -292,8 +292,8 @@ def naturaldate(value: dt.date | dt.datetime) -> str: def _quotient_and_remainder( - value: int | float, - divisor: int | float, + value: float, + divisor: float, unit: Unit, minimum_unit: Unit, suppress: collections.abc.Iterable[Unit], @@ -331,9 +331,9 @@ def _quotient_and_remainder( def _carry( - value1: int | float, - value2: int | float, - ratio: int | float, + value1: float, + value2: float, + ratio: float, unit: Unit, min_unit: Unit, suppress: typing.Iterable[Unit], diff --git a/tests/test_number.py b/tests/test_number.py index 0ad8f7a0..9b08d4d5 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -136,7 +136,7 @@ def test_apnumber(test_input: int | str, expected: str) -> None: (0.333, "333/1000"), ], ) -def test_fractional(test_input: int | float | str, expected: str) -> None: +def test_fractional(test_input: float | str, expected: str) -> None: assert humanize.fractional(test_input) == expected diff --git a/tests/test_time.py b/tests/test_time.py index a4cc9dce..30539f9c 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -286,7 +286,7 @@ def test_naturaldate(test_input: dt.date, expected: str) -> None: (ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_default(seconds: int | float, expected: str) -> None: +def test_naturaldelta_minimum_unit_default(seconds: float, expected: str) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -330,7 +330,7 @@ def test_naturaldelta_minimum_unit_default(seconds: int | float, expected: str) ], ) def test_naturaldelta_minimum_unit_explicit( - minimum_unit: str, seconds: int | float, expected: str + minimum_unit: str, seconds: float, expected: str ) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -354,7 +354,7 @@ def test_naturaldelta_minimum_unit_explicit( (ONE_YEAR + FOUR_MICROSECONDS, "a year ago"), ], ) -def test_naturaltime_minimum_unit_default(seconds: int | float, expected: str) -> None: +def test_naturaltime_minimum_unit_default(seconds: float, expected: str) -> None: # Arrange datetime = NOW - dt.timedelta(seconds=seconds) @@ -399,7 +399,7 @@ def test_naturaltime_minimum_unit_default(seconds: int | float, expected: str) - ], ) def test_naturaltime_minimum_unit_explicit( - minimum_unit: str, seconds: int | float, expected: str + minimum_unit: str, seconds: float, expected: str ) -> None: # Arrange datetime = NOW - dt.timedelta(seconds=seconds) From dfc69cbd818c1446bdd16f76b080f852bdb105bd Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 12 Jun 2022 14:46:42 +0300 Subject: [PATCH 11/55] Use the new experimental handler instead of the legacy one to fix typing bug --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 92f49c0f..dea686f2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ mkdocs>=1.1 mkdocs-material -mkdocstrings[python-legacy]>=0.18 +mkdocstrings[python]>=0.18 mkdocs-include-markdown-plugin pygments pymdown-extensions>=9.2 From 5641b0e59200e0327c87c976ff55360bc23ba9a3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 12 Jun 2022 14:48:19 +0300 Subject: [PATCH 12/55] Fix WARNING - griffe: humanize/time.py:104: Parameter 'when' does not appear in the function signature --- src/humanize/time.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 5e14db8a..373657df 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -101,8 +101,6 @@ def naturaldelta( months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. - when (datetime.datetime): Removed in version 4.0; If you need to - construct a timedelta, do it inline as the first argument. Returns: str (str or `value`): A natural representation of the amount of time From f550e987456456327d1479f08a1244c0796c0a28 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 12 Jun 2022 14:51:31 +0300 Subject: [PATCH 13/55] Fix GHA caching --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a68b22c8..e9d5c4d4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,8 +13,8 @@ jobs: uses: actions/setup-python@v3 with: python-version: "3.x" - pip: cache - pip-dependency-path: tox.ini + cache: pip + cache-dependency-path: tox.ini - name: Install dependencies run: | From 43ebe21b5f55f0218a75074227020fb63dd45e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=A9nainn=20Woodsend?= Date: Thu, 16 Jun 2022 08:33:05 +0100 Subject: [PATCH 14/55] Fix scientific() on small positive numbers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking a number is negative using `'-' in string_value` leads to confusion because 1e-30 and -1e30 both contain a '-' but only one of them is negative. This bug had found its way into the tests and the docstring. Additionally, the removal of redundant leading '0's and '+'s from the exponent would only kick in if both were present so that 1e20 would become 10⁺²⁰ instead of just 10²⁰ and the insertion of negative exponents could lead to outputs such as 3.00 x 10⁻⁺²⁰. --- src/humanize/number.py | 28 ++++++---------------------- tests/test_number.py | 6 +++++- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 3f6070f8..511060ae 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -337,7 +337,7 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: >>> scientific(int(500)) '5.00 x 10²' >>> scientific(-1000) - '1.00 x 10⁻³' + '-1.00 x 10³' >>> scientific(1000, 1) '1.0 x 10³' >>> scientific(1000, 3) @@ -369,35 +369,19 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: "7": "⁷", "8": "⁸", "9": "⁹", - "+": "⁺", "-": "⁻", } - negative = False try: - if "-" in str(value): - value = str(value).replace("-", "") - negative = True - - if isinstance(value, str): - value = float(value) - - fmt = "{:.%se}" % str(int(precision)) - n = fmt.format(value) - + value = float(value) except (ValueError, TypeError): return str(value) - + fmt = "{:.%se}" % str(int(precision)) + n = fmt.format(value) part1, part2 = n.split("e") - if "-0" in part2: - part2 = part2.replace("-0", "-") - - if "+0" in part2: - part2 = part2.replace("+0", "") + # Remove redundant leading '+' or '0's (preserving the last '0' for 10⁰). + part2 = re.sub(r"^\+?(\-?)0*(.+)$", r"\1\2", part2) new_part2 = [] - if negative: - new_part2.append(exponents["-"]) - for char in part2: new_part2.append(exponents[char]) diff --git a/tests/test_number.py b/tests/test_number.py index 9b08d4d5..b69e9cef 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -144,7 +144,7 @@ def test_fractional(test_input: float | str, expected: str) -> None: "test_args, expected", [ ([1000], "1.00 x 10³"), - ([-1000], "1.00 x 10⁻³"), + ([-1000], "-1.00 x 10³"), ([5.5], "5.50 x 10⁰"), ([5781651000], "5.78 x 10⁹"), (["1000"], "1.00 x 10³"), @@ -156,6 +156,10 @@ def test_fractional(test_input: float | str, expected: str) -> None: ([float(0.3), 1], "3.0 x 10⁻¹"), ([1000, 0], "1 x 10³"), ([float(0.3), 0], "3 x 10⁻¹"), + ([float(1e20)], "1.00 x 10²⁰"), + ([float(2e-20)], "2.00 x 10⁻²⁰"), + ([float(-3e20)], "-3.00 x 10²⁰"), + ([float(-4e-20)], "-4.00 x 10⁻²⁰"), ], ) def test_scientific(test_args: list[typing.Any], expected: str) -> None: From 19726a0fefa5beb3c4024cc6ce64d6df7b93dfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=A9nainn=20Woodsend?= Date: Fri, 17 Jun 2022 23:25:01 +0100 Subject: [PATCH 15/55] Add humanize.metric() for converting big/small numbers to SI units. --- src/humanize/__init__.py | 2 ++ src/humanize/number.py | 56 ++++++++++++++++++++++++++++++++++++++++ tests/test_number.py | 37 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index dda543da..25c0ba19 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -8,6 +8,7 @@ fractional, intcomma, intword, + metric, ordinal, scientific, ) @@ -38,6 +39,7 @@ "fractional", "intcomma", "intword", + "metric", "naturaldate", "naturalday", "naturaldelta", diff --git a/src/humanize/number.py b/src/humanize/number.py index 511060ae..08f0cd87 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -458,3 +458,59 @@ def clamp( "Invalid format. Must be either a valid formatting string, or a function " "that accepts value and returns a string." ) + + +def metric(value: float, unit: str = "", precision: int = 3) -> str: + """Return a value with a metric SI unit-prefix appended. + + Examples: + ```pycon + >>> metric(1500, "V") + '1.50 kV' + >>> metric(2e8, "W") + '200 MW' + >>> metric(220e-6, "F") + '220 μF' + >>> metric(1e-14, precision=4) + '10.00 f' + + ``` + + The unit prefix is always chosen so that non-significant zero digits are required. + i.e. `123,000` will become `123k` instead of `0.123M` and `1,230,000` will become + `1.23M` instead of `1230K`. For numbers that are either too huge or too tiny to + represent without resorting to either leading or trailing zeroes, it falls back to + `scientific()`. + ```pycon + >>> metric(1e40) + '1.00 x 10⁴⁰' + + ``` + + Args: + value (int, float): Input number. + unit (str): Optional base unit. + precision (int): The number of digits the output should contain. + + Returns: + str: + """ + exponent = int(math.floor(math.log10(abs(value)))) + + if exponent >= 27 or exponent < -24: + return scientific(value, precision - 1) + unit + + value /= 10 ** (exponent // 3 * 3) + if exponent >= 3: + ordinal = "kMGTPEZY"[exponent // 3 - 1] + elif exponent < 0: + ordinal = "mμnpfazy"[(-exponent - 1) // 3] + else: + ordinal = "" + value_ = format(value, ".%if" % (precision - (exponent % 3) - 1)) + if not (unit or ordinal) or unit in ("°", "′", "″"): + space = "" + else: + space = " " + + return f"{value_}{space}{ordinal}{unit}" diff --git a/tests/test_number.py b/tests/test_number.py index b69e9cef..4ffa706b 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -181,3 +181,40 @@ def test_scientific(test_args: list[typing.Any], expected: str) -> None: ) def test_clamp(test_args: list[typing.Any], expected: str) -> None: assert humanize.clamp(*test_args) == expected + + +@pytest.mark.parametrize( + "test_args, expected", + [ + ([1, "Hz"], "1.00 Hz"), + ([1.0, "W"], "1.00 W"), + ([3, "C"], "3.00 C"), + ([3, "W", 5], "3.0000 W"), + ([1.23456], "1.23"), + ([12.3456], "12.3"), + ([123.456], "123"), + ([1234.56], "1.23 k"), + ([12345, "", 6], "12.3450 k"), + ([200_000], "200 k"), + ([1e25, "m"], "10.0 Ym"), + ([1e26, "m"], "100 Ym"), + ([1e27, "A"], "1.00 x 10²⁷A"), + ([1.234e28, "A"], "1.23 x 10²⁸A"), + ([-1500, "V"], "-1.50 kV"), + ([0.12], "120 m"), + ([0.012], "12.0 m"), + ([0.0012], "1.20 m"), + ([0.00012], "120 μ"), + ([1e-23], "10.0 y"), + ([1e-24], "1.00 y"), + ([1e-25], "1.00 x 10⁻²⁵"), + ([1e-26], "1.00 x 10⁻²⁶"), + ([1, "°"], "1.00°"), + ([0.1, "°"], "100m°"), + ([100], "100"), + ([0.1], "100 m"), + ], + ids=str, +) +def test_metric(test_args: list[typing.Any], expected: str) -> None: + assert humanize.metric(*test_args) == expected From 6c1d0c7dfc87189374b5b9095bfdc97ed218a702 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Sun, 19 Jun 2022 20:33:06 -0500 Subject: [PATCH 16/55] BUG: Use %d for year translations convert to string for intcomma after This patch fixes a bug introduced in 3.14.0, where the format string was changed from %d to %s to add separators to the year. However, this needs to happen after translation because the translator uses the format strings as part of the translation. Closes #21 --- 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 373657df..22dbb1e2 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -202,7 +202,7 @@ def naturaldelta( else: return _ngettext("1 year, %d day", "1 year, %d days", days) % days - return _ngettext("%s year", "%s years", years) % intcomma(years) + return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) def naturaltime( From 03863fed6c2ba02cfd13d5b1e0b161f680c7b966 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 21 Jun 2022 10:21:43 +0300 Subject: [PATCH 17/55] Fix intcomma with ndigits=0 --- src/humanize/number.py | 4 ++-- tests/test_number.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 08f0cd87..77475e31 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -126,7 +126,7 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: ndigits (int, None): Digits of precision for rounding after the decimal point. Returns: - str: string containing commas every three digits. + str: String containing commas every three digits. """ sep = thousands_separator() try: @@ -137,7 +137,7 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: except (TypeError, ValueError): return str(value) - if ndigits: + if ndigits is not None: orig = "{0:.{1}f}".format(value, ndigits) else: orig = str(value) diff --git a/tests/test_number.py b/tests/test_number.py index 4ffa706b..86767cf6 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -54,6 +54,7 @@ def test_ordinal(test_input: str, expected: str) -> None: ([14308.40, 3], "14,308.400"), ([1234.5454545], "1,234.5454545"), ([1234.5454545, None], "1,234.5454545"), + ([1234.5454545, 0], "1,235"), ([1234.5454545, 1], "1,234.5"), ([1234.5454545, 2], "1,234.55"), ([1234.5454545, 3], "1,234.545"), From cfdfb81de8bb8bc02b57ba9ecbacc2f66252ac17 Mon Sep 17 00:00:00 2001 From: Daniel Ching Date: Tue, 21 Jun 2022 21:23:32 -0500 Subject: [PATCH 18/55] TST: Add translation test for naturaldelta --- tests/test_i18n.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 8b646969..b4c57cb4 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -52,6 +52,23 @@ def test_intcomma() -> None: humanize.i18n.deactivate() assert humanize.intcomma(number) == "10,000,000" +def test_naturaldelta() -> None: + seconds = 1234 * 365 * 24 * 60 * 60 + + assert humanize.naturaldelta(seconds) == "1,234 years" + + try: + humanize.i18n.activate("fr_FR") + assert humanize.naturaldelta(seconds) == "1 234 ans" + humanize.i18n.activate("es_ES") + assert humanize.naturaldelta(seconds) == "1,234 años" + + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + + finally: + humanize.i18n.deactivate() + assert humanize.naturaldelta(seconds) == "1,234 years" @pytest.mark.parametrize( ("locale", "number", "expected_result"), From e34f1ccc070e322f327d314e167144281e5a5af3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jun 2022 02:24:05 +0000 Subject: [PATCH 19/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_i18n.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index b4c57cb4..e4f30f02 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -52,6 +52,7 @@ def test_intcomma() -> None: humanize.i18n.deactivate() assert humanize.intcomma(number) == "10,000,000" + def test_naturaldelta() -> None: seconds = 1234 * 365 * 24 * 60 * 60 @@ -70,6 +71,7 @@ def test_naturaldelta() -> None: humanize.i18n.deactivate() assert humanize.naturaldelta(seconds) == "1,234 years" + @pytest.mark.parametrize( ("locale", "number", "expected_result"), ( From f7d4130758d69c3795771041a1634c0a1997019b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 22 Jun 2022 16:28:46 +0300 Subject: [PATCH 20/55] Rename Arabic locale from ar_SA to ar to enable fallbacks --- .../{ar_SA => ar}/LC_MESSAGES/humanize.po | 0 tests/test_i18n.py | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+) rename src/humanize/locale/{ar_SA => ar}/LC_MESSAGES/humanize.po (100%) diff --git a/src/humanize/locale/ar_SA/LC_MESSAGES/humanize.po b/src/humanize/locale/ar/LC_MESSAGES/humanize.po similarity index 100% rename from src/humanize/locale/ar_SA/LC_MESSAGES/humanize.po rename to src/humanize/locale/ar/LC_MESSAGES/humanize.po diff --git a/tests/test_i18n.py b/tests/test_i18n.py index e4f30f02..87ce3bb9 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -94,6 +94,29 @@ def test_intword_plurals(locale: str, number: int, expected_result: str) -> None humanize.i18n.deactivate() +@pytest.mark.parametrize( + ("locale", "expected_result"), + ( + ("ar", "5خامس"), + ("ar_SA", "5خامس"), + ("fr", "5e"), + ("fr_FR", "5e"), + ("pt", "5º"), + ("pt_BR", "5º"), + ("pt_PT", "5º"), + ), +) +def test_langauge_codes(locale: str, expected_result: str) -> None: + try: + humanize.i18n.activate(locale) + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.ordinal(5) == expected_result + finally: + humanize.i18n.deactivate() + + @pytest.mark.parametrize( ("locale", "number", "gender", "expected_result"), ( From b1f8793d24b579d16a898f6f3f5a6d5992b95378 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 25 Jun 2022 11:31:20 +0300 Subject: [PATCH 21/55] Do not shadow 'bytes' builtin --- src/humanize/filesize.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 14496005..050bed06 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -55,22 +55,22 @@ def naturalsize( suffix = suffixes["decimal"] base = 1024 if (gnu or binary) else 1000 - bytes = float(value) - abs_bytes = abs(bytes) + bytes_ = float(value) + abs_bytes = abs(bytes_) if abs_bytes == 1 and not gnu: - return "%d Byte" % bytes + return "%d Byte" % bytes_ elif abs_bytes < base and not gnu: - return "%d Bytes" % bytes + return "%d Bytes" % bytes_ elif abs_bytes < base and gnu: - return "%dB" % bytes + return "%dB" % bytes_ for i, s in enumerate(suffix): unit = base ** (i + 2) if abs_bytes < unit and not gnu: - return (format + " %s") % ((base * bytes / unit), s) + return (format + " %s") % ((base * bytes_ / unit), s) elif abs_bytes < unit and gnu: - return (format + "%s") % ((base * bytes / unit), s) + return (format + "%s") % ((base * bytes_ / unit), s) if gnu: - return (format + "%s") % ((base * bytes / unit), s) - return (format + " %s") % ((base * bytes / unit), s) + return (format + "%s") % ((base * bytes_ / unit), s) + return (format + " %s") % ((base * bytes_ / unit), s) From 7cc024567ebf1a4197db3613010226b1b12e65e5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 25 Jun 2022 11:41:59 +0300 Subject: [PATCH 22/55] naturadelta and naturaltime can also accept a float --- src/humanize/time.py | 8 ++++---- tests/test_time.py | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 373657df..32bfda37 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -88,7 +88,7 @@ def _date_and_delta( def naturaldelta( - value: dt.timedelta | int, + value: dt.timedelta | float, months: bool = True, minimum_unit: str = "seconds", ) -> str: @@ -97,7 +97,7 @@ def naturaldelta( This is similar to `naturaltime`, but does not add tense to the result. Args: - value (datetime.timedelta or int): A timedelta or a number of seconds. + value (datetime.timedelta, int or float): A timedelta or a number of seconds. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. @@ -206,7 +206,7 @@ def naturaldelta( def naturaltime( - value: dt.datetime | int, + value: dt.datetime | float, future: bool = False, months: bool = True, minimum_unit: str = "seconds", @@ -217,7 +217,7 @@ def naturaltime( This is more or less compatible with Django's `naturaltime` filter. Args: - value (datetime.datetime, int): A `datetime` or a number of seconds. + value (datetime.datetime, int or float): A `datetime` or a number of seconds. future (bool): Ignored for `datetime`s, where the tense is always figured out based on the current time. For integers, the return value will be past tense by default, unless future is `True`. diff --git a/tests/test_time.py b/tests/test_time.py index 30539f9c..99168653 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -93,6 +93,7 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None: [ (0, "a moment"), (1, "a second"), + (23.5, "23 seconds"), (30, "30 seconds"), (dt.timedelta(minutes=1, seconds=30), "a minute"), (dt.timedelta(minutes=2), "2 minutes"), @@ -156,6 +157,7 @@ def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None: # regression tests for bugs in post-release humanize (NOW + dt.timedelta(days=10000), "27 years from now"), (NOW - dt.timedelta(days=365 + 35), "1 year, 1 month ago"), + (23.5, "23 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), From cecce62d26ba96c535201eb713012b235cbd4417 Mon Sep 17 00:00:00 2001 From: Nuz / Lovegood Date: Tue, 28 Jun 2022 23:11:35 -0700 Subject: [PATCH 23/55] naturaltime can also accept a timedelta Updated annotations, docs, and tests. --- src/humanize/time.py | 11 ++++++----- tests/test_time.py | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 75500903..bb85f490 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -206,7 +206,7 @@ def naturaldelta( def naturaltime( - value: dt.datetime | float, + value: dt.datetime | dt.timedelta | float, future: bool = False, months: bool = True, minimum_unit: str = "seconds", @@ -217,10 +217,11 @@ def naturaltime( This is more or less compatible with Django's `naturaltime` filter. Args: - value (datetime.datetime, int or float): A `datetime` or a number of seconds. - future (bool): Ignored for `datetime`s, where the tense is always figured out - based on the current time. For integers, the return value will be past tense - by default, unless future is `True`. + value (datetime.datetime, datetime.timedelta, int or float): A `datetime`, a + `timedelta`, or a number of seconds. + future (bool): Ignored for `datetime`s and `timedelta`s, where the tense is + always figured out based on the current time. For integers and floats, the + return value will be past tense by default, unless future is `True`. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. diff --git a/tests/test_time.py b/tests/test_time.py index 99168653..924df985 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -157,6 +157,8 @@ def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None: # regression tests for bugs in post-release humanize (NOW + dt.timedelta(days=10000), "27 years from now"), (NOW - dt.timedelta(days=365 + 35), "1 year, 1 month ago"), + (dt.timedelta(days=-10000), "27 years from now"), + (dt.timedelta(days=365 + 35), "1 year, 1 month ago"), (23.5, "23 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), @@ -200,6 +202,9 @@ def nt_nomonths(d: dt.datetime) -> str: # regression tests for bugs in post-release humanize (NOW + dt.timedelta(days=10000), "27 years from now"), (NOW - dt.timedelta(days=365 + 35), "1 year, 35 days ago"), + (dt.timedelta(days=-10000), "27 years from now"), + (dt.timedelta(days=365 + 35), "1 year, 35 days ago"), + (23.5, "23 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), From 90b3a019548a405e9d288fc79d5a9f8cd198235b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 30 Jun 2022 11:42:36 +0300 Subject: [PATCH 24/55] Update 'twine upload' command ~/.pypirc looks like: ``` [testpypi] repository = https://test.pypi.org/legacy/ username = __token__ [pypi] repository = https://upload.pypi.org/legacy/ username = __token__ ``` --- RELEASING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index e620b76b..5217c004 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,7 +22,7 @@ scripts/generate-translation-binaries.sh pip install -U pip build keyring twine rm -rf build dist python -m build -twine check --strict dist/* && twine upload --repository-url https://test.pypi.org/legacy/ dist/* +twine check --strict dist/* && twine upload --repository testpypi dist/* ``` - [ ] (Optional) Check **test** installation: @@ -45,7 +45,7 @@ git tag -a 2.1.0 -m "Release 2.1.0" pip install -U pip build keyring twine rm -rf build dist python -m build -twine check --strict dist/* && twine upload -r pypi dist/* +twine check --strict dist/* && twine upload --repository pypi dist/* ``` * [ ] Check installation: From 8115c35d4edd3ae3554f2945760999d2906f2df5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:44:15 +0000 Subject: [PATCH 25/55] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..39a2b6e9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From 1ecba15ec4b3f6c0274c0fe080c6f57ce9b4bfe7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 1 Jul 2022 16:53:42 +0300 Subject: [PATCH 26/55] Renovate: add labels, schedule, group updates --- .github/renovate.json | 13 +++++++++++++ renovate.json | 6 ------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 .github/renovate.json delete mode 100644 renovate.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..2d2f2769 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "labels": ["changelog: skip", "dependencies"], + "packageRules": [ + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "separateMajorMinor": "false" + } + ], + "schedule": ["on the first day of the month"] +} diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 39a2b6e9..00000000 --- a/renovate.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ] -} From 44ccdf58e533c28f35445491e1b81320b2bf0a7f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 1 Jul 2022 16:54:02 +0300 Subject: [PATCH 27/55] Replace Dependabot with Renovate --- .github/dependabot.yml | 20 -------------------- .pre-commit-config.yaml | 1 + 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index be3d199c..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: monthly - time: "03:00" - open-pull-requests-limit: 10 - labels: - - "changelog: skip" - - "dependencies" - - package-ecosystem: pip - directory: "/" - schedule: - interval: monthly - time: "03:00" - open-pull-requests-limit: 10 - labels: - - "changelog: skip" - - "dependencies" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed8a4a65..8a016d68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.2.0 hooks: + - id: check-json - id: check-merge-conflict - id: check-toml - id: check-yaml From d9d7c99dd306ecaa2938e75a2b74215346a6dae5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Jul 2022 14:02:53 +0000 Subject: [PATCH 28/55] Update github-actions --- .github/workflows/docs.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/require-pr-label.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e9d5c4d4..76d097d7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.x" cache: pip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 649ca662..3ded5424 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,5 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - - uses: pre-commit/action@v2.0.3 + - uses: actions/setup-python@v4 + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index a2c74d50..1079f3fd 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v1 + - uses: mheap/github-action-required-labels@v2 with: mode: minimum count: 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5054a46..1a19d3ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip From 78e2e1e0a8c3f3d41b7db1fc85fd5b0e3e476b9e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 1 Jul 2022 17:09:42 +0300 Subject: [PATCH 29/55] actions/setup-python@v4 requires python-version --- .github/workflows/lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3ded5424..8ee0cd11 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,4 +9,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: "3.x" - uses: pre-commit/action@v3.0.0 From 1c6269b7b57c1515ef61c40663865080927e89a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Jul 2022 14:17:06 +0000 Subject: [PATCH 30/55] Pin dependencies --- docs/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index dea686f2..0122416a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -mkdocs>=1.1 +mkdocs==1.3.0 mkdocs-material -mkdocstrings[python]>=0.18 +mkdocstrings[python]==0.19.0 mkdocs-include-markdown-plugin pygments -pymdown-extensions>=9.2 +pymdown-extensions==9.5 From 21cb4a030f0ff2f05adf32c55955d844d3ea287e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jul 2022 16:44:55 +0300 Subject: [PATCH 31/55] Fix filename --- .github/{FUNDING.md => FUNDING.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{FUNDING.md => FUNDING.yml} (100%) diff --git a/.github/FUNDING.md b/.github/FUNDING.yml similarity index 100% rename from .github/FUNDING.md rename to .github/FUNDING.yml From c934f702ab135564d7d10c01ed03723d418866ae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Jul 2022 22:57:41 +0000 Subject: [PATCH 32/55] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.32.0 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.34.0) - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) - [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0) - [github.com/pre-commit/mirrors-mypy: v0.942 → v0.961](https://github.com/pre-commit/mirrors-mypy/compare/v0.942...v0.961) - [github.com/tox-dev/pyproject-fmt: 0.3.3 → 0.3.4](https://github.com/tox-dev/pyproject-fmt/compare/0.3.3...0.3.4) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a016d68..d1c1b855 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.0 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black args: [--target-version=py37] @@ -42,7 +42,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-json - id: check-merge-conflict @@ -58,7 +58,7 @@ repos: files: "src/" - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.942 + rev: v0.961 hooks: - id: mypy additional_dependencies: [pytest, types-freezegun, types-setuptools] @@ -71,7 +71,7 @@ repos: args: [--max-py-version=3.11] - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.3.3 + rev: 0.3.4 hooks: - id: pyproject-fmt From 520aac16c448f3cfd72f7c5812b04a3238e77bb4 Mon Sep 17 00:00:00 2001 From: vishket Date: Fri, 8 Jul 2022 12:51:52 +0200 Subject: [PATCH 33/55] Fix intword for negative numbers --- src/humanize/number.py | 25 +++++++++++++++++++++++-- tests/test_number.py | 7 +++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 77475e31..87d42815 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -205,23 +205,44 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: except (TypeError, ValueError): return str(value) + is_negative = value < 0 + + if is_negative: + value *= -1 + if value < powers[0]: - return str(value) + return "-" + str(value) if is_negative else str(value) for ordinal, power in enumerate(powers[1:], 1): if value < power: chopped = value / float(powers[ordinal - 1]) if float(format % chopped) == float(10**3): chopped = value / float(powers[ordinal]) singular, plural = human_powers[ordinal] + if is_negative: + return ( + "-" + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) + ) % chopped + return ( " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) ) % chopped else: singular, plural = human_powers[ordinal - 1] + if is_negative: + return ( + "-" + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) + ) % chopped return ( " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) ) % chopped - return str(value) + + return "-" + str(value) if is_negative else str(value) def apnumber(value: NumberOrString) -> str: diff --git a/tests/test_number.py b/tests/test_number.py index 86767cf6..6cf35084 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -75,24 +75,31 @@ def test_intword_powers() -> None: @pytest.mark.parametrize( "test_args, expected", [ + (["0"], "0"), (["100"], "100"), + (["-100"], "-100"), (["1000"], "1.0 thousand"), (["12400"], "12.4 thousand"), (["12490"], "12.5 thousand"), (["1000000"], "1.0 million"), + (["-1000000"], "-1.0 million"), (["1200000"], "1.2 million"), (["1290000"], "1.3 million"), (["999999999"], "1.0 billion"), (["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"), (["3500000000000000000000"], "3.5 sextillion"), (["8100000000000000000000000000000000"], "8.1 decillion"), + (["-8100000000000000000000000000000000"], "-8.1 decillion"), ([None], "None"), (["1230000", "%0.2f"], "1.23 million"), ([10**101], "1" + "0" * 101), From 4c76d35c30238057d28ae8ecdc7c5f022abcc00e Mon Sep 17 00:00:00 2001 From: vishket Date: Mon, 11 Jul 2022 09:42:33 +0200 Subject: [PATCH 34/55] refactor --- src/humanize/number.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 87d42815..0c71e822 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -205,44 +205,36 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: except (TypeError, ValueError): return str(value) - is_negative = value < 0 - - if is_negative: + if value < 0: value *= -1 + negative_prefix = "-" + else: + negative_prefix = "" if value < powers[0]: - return "-" + str(value) if is_negative else str(value) + return negative_prefix + str(value) for ordinal, power in enumerate(powers[1:], 1): if value < power: chopped = value / float(powers[ordinal - 1]) if float(format % chopped) == float(10**3): chopped = value / float(powers[ordinal]) singular, plural = human_powers[ordinal] - if is_negative: - return ( - "-" - + " ".join( - [format, _ngettext(singular, plural, math.ceil(chopped))] - ) - ) % chopped - return ( - " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) + negative_prefix + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) ) % chopped else: singular, plural = human_powers[ordinal - 1] - if is_negative: - return ( - "-" - + " ".join( - [format, _ngettext(singular, plural, math.ceil(chopped))] - ) - ) % chopped return ( - " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) + negative_prefix + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) ) % chopped - return "-" + str(value) if is_negative else str(value) + return negative_prefix + str(value) def apnumber(value: NumberOrString) -> str: From fef2fe1f84524b51fad779270bed6b285340410d Mon Sep 17 00:00:00 2001 From: Yurii De Date: Sun, 17 Jul 2022 18:38:59 +0300 Subject: [PATCH 35/55] add thousand, fix big numbers --- .../locale/pl_PL/LC_MESSAGES/humanize.po | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po index 1d4720e2..5f6f7dcf 100644 --- a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po @@ -122,86 +122,86 @@ msgstr "." #: src/humanize/number.py:140 msgid "thousand" msgid_plural "thousand" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "tysiąc" +msgstr[1] "tysiąc" +msgstr[2] "tysięcy" #: src/humanize/number.py:141 msgid "million" msgid_plural "million" msgstr[0] "milion" -msgstr[1] "milion" -msgstr[2] "milion" +msgstr[1] "miliony" +msgstr[2] "milionów" #: src/humanize/number.py:142 msgid "billion" msgid_plural "billion" msgstr[0] "bilion" -msgstr[1] "bilion" -msgstr[2] "bilion" +msgstr[1] "biliony" +msgstr[2] "bilionów" #: src/humanize/number.py:143 msgid "trillion" msgid_plural "trillion" msgstr[0] "trylion" -msgstr[1] "trylion" -msgstr[2] "trylion" +msgstr[1] "tryliony" +msgstr[2] "trylionów" #: src/humanize/number.py:144 msgid "quadrillion" msgid_plural "quadrillion" msgstr[0] "kwadrylion" -msgstr[1] "kwadrylion" -msgstr[2] "kwadrylion" +msgstr[1] "kwadryliony" +msgstr[2] "kwadrylionów" #: src/humanize/number.py:145 msgid "quintillion" msgid_plural "quintillion" msgstr[0] "kwintylion" -msgstr[1] "kwintylion" -msgstr[2] "kwintylion" +msgstr[1] "kwintyliony" +msgstr[2] "kwintylionów" #: src/humanize/number.py:146 msgid "sextillion" msgid_plural "sextillion" msgstr[0] "sekstylion" -msgstr[1] "sekstylion" -msgstr[2] "sekstylion" +msgstr[1] "sekstyliony" +msgstr[2] "sekstylionów" #: src/humanize/number.py:147 msgid "septillion" msgid_plural "septillion" msgstr[0] "septylion" -msgstr[1] "septylion" -msgstr[2] "septylion" +msgstr[1] "septyliony" +msgstr[2] "septylionów" #: src/humanize/number.py:148 msgid "octillion" msgid_plural "octillion" msgstr[0] "oktylion" -msgstr[1] "oktylion" -msgstr[2] "oktylion" +msgstr[1] "oktyliony" +msgstr[2] "oktylionów" #: src/humanize/number.py:149 msgid "nonillion" msgid_plural "nonillion" msgstr[0] "nonilion" -msgstr[1] "nonilion" -msgstr[2] "nonilion" +msgstr[1] "noniliony" +msgstr[2] "nonilionów" #: src/humanize/number.py:150 msgid "decillion" msgid_plural "decillion" msgstr[0] "decylion" -msgstr[1] "decylion" -msgstr[2] "decylion" +msgstr[1] "decyliony" +msgstr[2] "decylionów" #: src/humanize/number.py:151 msgid "googol" msgid_plural "googol" msgstr[0] "googol" -msgstr[1] "googol" -msgstr[2] "googol" +msgstr[1] "googoly" +msgstr[2] "googolów" #: src/humanize/number.py:246 msgid "zero" From caa11fc73018bc7490687d5a790043ac4146287b Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Tue, 26 Jul 2022 22:36:37 +0800 Subject: [PATCH 36/55] Fix markdown issue and typo Resolve MD014 Dollar signs used before commands without showing output See https://github.com/markdownlint/markdownlint/blob/master/docs/RULES.md --- README.md | 10 +++++----- src/humanize/time.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0c54565d..9e18002a 100644 --- a/README.md +++ b/README.md @@ -207,15 +207,15 @@ FileNotFoundError: [Errno 2] No translation file found for domain: 'humanize' How to add new phrases to existing locale files: -```console -$ xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases -$ msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files +```sh +xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases +msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files ``` How to add a new locale: -```console -$ msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale +```sh +msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale ``` Where `` is a locale abbreviation, eg. `en_GB`, `pt_BR` or just `ru`, `fr` diff --git a/src/humanize/time.py b/src/humanize/time.py index bb85f490..1d1222f4 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -376,7 +376,7 @@ def _suitable_minimum_unit(min_unit: Unit, suppress: typing.Iterable[Unit]) -> U >>> _suitable_minimum_unit(Unit.HOURS, []).name 'HOURS' - But if suppressed, find a unit greather than the original one that is not + But if suppressed, find a unit greater than the original one that is not suppressed: >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS]).name From 9756d6b67b5cfb4f2c96e30610df8b0799792601 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Aug 2022 02:27:22 +0000 Subject: [PATCH 37/55] Update dependency mkdocs to v1.3.1 --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0122416a..896ed5e1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ -mkdocs==1.3.0 +mkdocs==1.3.1 mkdocs-material mkdocstrings[python]==0.19.0 mkdocs-include-markdown-plugin From 01bca3dca85bd2a91e8c90389e83f61a06a98aaf Mon Sep 17 00:00:00 2001 From: Isaak Tsalicoglou <76704620+waseigo@users.noreply.github.com> Date: Fri, 5 Aug 2022 01:41:17 +0300 Subject: [PATCH 38/55] Greek translation --- README.md | 1 + .../locale/el_GR/LC_MESSAGES/humanize.po | 365 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/humanize/locale/el_GR/LC_MESSAGES/humanize.po diff --git a/README.md b/README.md index 9e18002a..189c5c90 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ human-readable size or throughput. It is localized to: - Finnish - French - German +- Greek - Indonesian - Italian - Japanese diff --git a/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po b/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po new file mode 100644 index 00000000..d7e1a0c9 --- /dev/null +++ b/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po @@ -0,0 +1,365 @@ +# Greek translations for PACKAGE package. +# Copyright (C) 2022 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Isaak Tsalicoglou , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: humanize\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 01:06+0300\n" +"PO-Revision-Date: 2022-08-05 01:09+0300\n" +"Last-Translator: Isaak Tsalicoglou \n" +"Language-Team: Greek \n" +"Language: el\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: Isaak Tsalicoglou\n" +"X-Generator: Mousepad 0.5.9\n" + +#: src/humanize/number.py:71 +msgctxt "0 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:72 +msgctxt "1 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:73 +msgctxt "2 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:74 +msgctxt "3 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:75 +msgctxt "4 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:76 +msgctxt "5 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:77 +msgctxt "6 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:78 +msgctxt "7 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:79 +msgctxt "8 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:80 +msgctxt "9 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:84 +msgctxt "0 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:85 +msgctxt "1 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:86 +msgctxt "2 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:87 +msgctxt "3 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:88 +msgctxt "4 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:89 +msgctxt "5 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:90 +msgctxt "6 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:91 +msgctxt "7 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:92 +msgctxt "8 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:93 +msgctxt "9 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:140 +msgid "thousand" +msgid_plural "thousand" +msgstr[0] "χιλιάδα" +msgstr[1] "χιλιάδες" + +#: src/humanize/number.py:141 +msgid "million" +msgid_plural "million" +msgstr[0] "εκατομμύριο" +msgstr[1] "εκατομμύρια" + +#: src/humanize/number.py:142 +msgid "billion" +msgid_plural "billion" +msgstr[0] "δισεκατομμύριο" +msgstr[1] "δισεκατομμύρια" + +#: src/humanize/number.py:143 +msgid "trillion" +msgid_plural "trillion" +msgstr[0] "τρισεκατομμύριο" +msgstr[1] "τρισεκατομμύρια" + +#: src/humanize/number.py:144 +msgid "quadrillion" +msgid_plural "quadrillion" +msgstr[0] "τετράκις εκατομμύριο" +msgstr[1] "τετράκις εκατομμύρια" + +#: src/humanize/number.py:145 +msgid "quintillion" +msgid_plural "quintillion" +msgstr[0] "πεντάκις εκατομμύριο" +msgstr[1] "πεντάκις εκατομμύρια" + +#: src/humanize/number.py:146 +msgid "sextillion" +msgid_plural "sextillion" +msgstr[0] "εξάκις εκατομμύριο" +msgstr[1] "εξάκις εκατομμύρια" + +#: src/humanize/number.py:147 +msgid "septillion" +msgid_plural "septillion" +msgstr[0] "επτάκις εκατομμύριο" +msgstr[1] "επτάκις εκατομμύρια" + +#: src/humanize/number.py:148 +msgid "octillion" +msgid_plural "octillion" +msgstr[0] "οκτάκις εκατομμύριο" +msgstr[1] "οκτάκις εκατομμύρια" + +#: src/humanize/number.py:149 +msgid "nonillion" +msgid_plural "nonillion" +msgstr[0] "εννεάκις εκατομμύριο" +msgstr[1] "εννεάκις εκατομμύρια" + +#: src/humanize/number.py:150 +msgid "decillion" +msgid_plural "decillion" +msgstr[0] "δεκάκις εκατομμύριο" +msgstr[1] "δεκάκις εκατομμύρια" + +#: src/humanize/number.py:151 +msgid "googol" +msgid_plural "googol" +msgstr[0] "δέκα τριακονταδυάκις εκατομμύριο" +msgstr[1] "δέκα τριακονταδυάκις εκατομμύρια" + +#: src/humanize/number.py:246 +msgid "zero" +msgstr "μηδέν" + +#: src/humanize/number.py:275 +msgid "one" +msgstr "ένα" + +#: src/humanize/number.py:276 +msgid "two" +msgstr "δύο" + +#: src/humanize/number.py:277 +msgid "three" +msgstr "τρία" + +#: src/humanize/number.py:278 +msgid "four" +msgstr "τέσσερα" + +#: src/humanize/number.py:279 +msgid "five" +msgstr "πέντε" + +#: src/humanize/number.py:280 +msgid "six" +msgstr "έξι" + +#: src/humanize/number.py:281 +msgid "seven" +msgstr "επτά" + +#: src/humanize/number.py:254 +msgid "eight" +msgstr "οκτώ" + +#: src/humanize/number.py:255 +msgid "nine" +msgstr "εννέα" + +#: src/humanize/time.py:133 +#, fuzzy, python-format +msgid "%d microsecond" +msgid_plural "%d microseconds" +msgstr[0] "%d εκατομμυριοστό του δευτερολέπτου" +msgstr[1] "%d εκατομμυριοστά του δευτερολέπτου" + +#: src/humanize/time.py:142 +#, fuzzy, python-format +msgid "%d millisecond" +msgid_plural "%d milliseconds" +msgstr[0] "%d χιλιοστό του δευτερολέπτου" +msgstr[1] "%d χιλιοστά του δευτερολέπτου" + +#: src/humanize/time.py:145 src/humanize/time.py:220 +msgid "a moment" +msgstr "μια στιγμή" + +#: src/humanize/time.py:147 +msgid "a second" +msgstr "ένα δευτερόλεπτο" + +#: src/humanize/time.py:149 +#, python-format +msgid "%d second" +msgid_plural "%d seconds" +msgstr[0] "%d δευτερόλεπτο" +msgstr[1] "%d δευτερόλεπτα" + +#: src/humanize/time.py:151 +msgid "a minute" +msgstr "ένα λεπτό" + +#: src/humanize/time.py:154 +#, python-format +msgid "%d minute" +msgid_plural "%d minutes" +msgstr[0] "%d λεπτό" +msgstr[1] "%d λεπτά" + +#: src/humanize/time.py:156 +msgid "an hour" +msgstr "μία ώρα" + +#: src/humanize/time.py:159 +#, python-format +msgid "%d hour" +msgid_plural "%d hours" +msgstr[0] "%d ώρα" +msgstr[1] "%d ώρες" + +#: src/humanize/time.py:162 +msgid "a day" +msgstr "μία ημέρα" + +#: src/humanize/time.py:164 src/humanize/time.py:167 +#, python-format +msgid "%d day" +msgid_plural "%d days" +msgstr[0] "%d ημέρα" +msgstr[1] "%d ημέρες" + +#: src/humanize/time.py:169 +msgid "a month" +msgstr "ένα μήνα" + +#: src/humanize/time.py:171 +#, python-format +msgid "%d month" +msgid_plural "%d months" +msgstr[0] "%d μήνα" +msgstr[1] "%d μήνες" + +#: src/humanize/time.py:174 +msgid "a year" +msgstr "ένα έτος" + +#: src/humanize/time.py:176 src/humanize/time.py:185 +#, python-format +msgid "1 year, %d day" +msgid_plural "1 year, %d days" +msgstr[0] "ένα έτος και %d ημέρα" +msgstr[1] "ένα έτος και %d ημέρες" + +#: src/humanize/time.py:179 +msgid "1 year, 1 month" +msgstr "ένα έτος και ένα μήνα" + +#: src/humanize/time.py:182 +#, python-format +msgid "1 year, %d month" +msgid_plural "1 year, %d months" +msgstr[0] "ένα έτος και %d μήνα" +msgstr[1] "ένα έτος και %d μήνες" + +#: src/humanize/time.py:187 +#, python-format +msgid "%d year" +msgid_plural "%d years" +msgstr[0] "%d έτος" +msgstr[1] "%d έτη" + +#: src/humanize/time.py:217 +#, python-format +msgid "%s from now" +msgstr "σε %s από τώρα" + +#: src/humanize/time.py:242 +#, python-format +msgid "%s ago" +msgstr "πριν από %s" + +#: src/humanize/time.py:246 +msgid "now" +msgstr "τώρα" + +#: src/humanize/time.py:269 +msgid "today" +msgstr "σήμερα" + +#: src/humanize/time.py:271 +msgid "tomorrow" +msgstr "αύριο" + +#: src/humanize/time.py:273 +msgid "yesterday" +msgstr "χθες" + +#: src/humanize/time.py:581 +#, python-format +msgid "%s and %s" +msgstr "%s και %s" From 9cb903a8ebc9c44385e156b96ebe1a38a5ef7744 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 8 Jul 2022 19:00:41 +0300 Subject: [PATCH 39/55] Add missing assert to test --- tests/test_i18n.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 87ce3bb9..4824d05b 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -156,7 +156,7 @@ def test_default_locale_path_null__file__() -> None: def test_default_locale_path_undefined__file__() -> None: i18n = importlib.import_module("humanize.i18n") del i18n.__file__ - i18n._get_default_locale_path() is None + assert i18n._get_default_locale_path() is None class TestActivate: From b134d014ed54422f450c8f381552d390396da0f3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 8 Jul 2022 19:01:59 +0300 Subject: [PATCH 40/55] Remove redundant comparison --- tests/test_time.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_time.py b/tests/test_time.py index 924df985..5f8da7a9 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -640,7 +640,6 @@ def test_time_unit() -> None: years, minutes = time.Unit["YEARS"], time.Unit["MINUTES"] assert minutes < years assert years > minutes - assert minutes == minutes with pytest.raises(TypeError): _ = years < "foo" From 387fb7d359a41299de7c4abe278309fb4b28b357 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 8 Jul 2022 19:18:40 +0300 Subject: [PATCH 41/55] Don't shadow ordinal function --- src/humanize/number.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 0c71e822..fd6f9e0c 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -213,12 +213,12 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: if value < powers[0]: return negative_prefix + str(value) - for ordinal, power in enumerate(powers[1:], 1): + for ordinal_, power in enumerate(powers[1:], 1): if value < power: - chopped = value / float(powers[ordinal - 1]) + chopped = value / float(powers[ordinal_ - 1]) if float(format % chopped) == float(10**3): - chopped = value / float(powers[ordinal]) - singular, plural = human_powers[ordinal] + chopped = value / float(powers[ordinal_]) + singular, plural = human_powers[ordinal_] return ( negative_prefix + " ".join( @@ -226,7 +226,7 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: ) ) % chopped else: - singular, plural = human_powers[ordinal - 1] + singular, plural = human_powers[ordinal_ - 1] return ( negative_prefix + " ".join( @@ -515,15 +515,15 @@ def metric(value: float, unit: str = "", precision: int = 3) -> str: value /= 10 ** (exponent // 3 * 3) if exponent >= 3: - ordinal = "kMGTPEZY"[exponent // 3 - 1] + ordinal_ = "kMGTPEZY"[exponent // 3 - 1] elif exponent < 0: - ordinal = "mμnpfazy"[(-exponent - 1) // 3] + ordinal_ = "mμnpfazy"[(-exponent - 1) // 3] else: - ordinal = "" + ordinal_ = "" value_ = format(value, ".%if" % (precision - (exponent % 3) - 1)) - if not (unit or ordinal) or unit in ("°", "′", "″"): + if not (unit or ordinal_) or unit in ("°", "′", "″"): space = "" else: space = " " - return f"{value_}{space}{ordinal}{unit}" + return f"{value_}{space}{ordinal_}{unit}" From 6eca2e3332badc9ce4839483260fee4f743413f1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 8 Jul 2022 19:33:09 +0300 Subject: [PATCH 42/55] More descriptive variable name --- src/humanize/time.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 1d1222f4..bd146e6d 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -405,10 +405,10 @@ def _suppress_lower_units(min_unit: Unit, suppress: typing.Iterable[Unit]) -> se ['MICROSECONDS', 'MILLISECONDS', 'DAYS'] """ suppress = set(suppress) - for u in Unit: - if u == min_unit: + for unit in Unit: + if unit == min_unit: break - suppress.add(u) + suppress.add(unit) return suppress From 52499cb09b4605dc22028ec51340cddf927d27c9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 8 Jul 2022 19:45:38 +0300 Subject: [PATCH 43/55] Remove unnecessary elif and else after return --- src/humanize/filesize.py | 11 ++++-- src/humanize/number.py | 41 ++++++++++---------- src/humanize/time.py | 81 +++++++++++++++++++++++++--------------- 3 files changed, 80 insertions(+), 53 deletions(-) diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 050bed06..6e1f57e0 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -60,17 +60,22 @@ def naturalsize( if abs_bytes == 1 and not gnu: return "%d Byte" % bytes_ - elif abs_bytes < base and not gnu: + + if abs_bytes < base and not gnu: return "%d Bytes" % bytes_ - elif abs_bytes < base and gnu: + + if abs_bytes < base and gnu: return "%dB" % bytes_ for i, s in enumerate(suffix): unit = base ** (i + 2) + if abs_bytes < unit and not gnu: return (format + " %s") % ((base * bytes_ / unit), s) - elif abs_bytes < unit and gnu: + + if abs_bytes < unit and gnu: return (format + "%s") % ((base * bytes_ / unit), s) + if gnu: return (format + "%s") % ((base * bytes_ / unit), s) return (format + " %s") % ((base * bytes_ / unit), s) diff --git a/src/humanize/number.py b/src/humanize/number.py index fd6f9e0c..d67d17f3 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -145,8 +145,8 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig) if orig == new: return new - else: - return intcomma(new) + + return intcomma(new) powers = [10**x for x in (3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] @@ -213,6 +213,7 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: if value < powers[0]: return negative_prefix + str(value) + for ordinal_, power in enumerate(powers[1:], 1): if value < power: chopped = value / float(powers[ordinal_ - 1]) @@ -225,14 +226,14 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: [format, _ngettext(singular, plural, math.ceil(chopped))] ) ) % chopped - else: - singular, plural = human_powers[ordinal_ - 1] - return ( - negative_prefix - + " ".join( - [format, _ngettext(singular, plural, math.ceil(chopped))] - ) - ) % chopped + + singular, plural = human_powers[ordinal_ - 1] + return ( + negative_prefix + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) + ) % chopped return negative_prefix + str(value) @@ -334,10 +335,11 @@ def fractional(value: NumberOrString) -> str: # this means that an integer was passed in # (or variants of that integer like 1.0000) return f"{whole_number:.0f}" - elif not whole_number: + + if not whole_number: return f"{numerator:.0f}/{denominator:.0f}" - else: - return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" + + return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" def scientific(value: NumberOrString, precision: int = 2) -> str: @@ -464,13 +466,14 @@ def clamp( if isinstance(format, str): return token + format.format(value) - elif callable(format): + + if callable(format): return token + format(value) - else: - raise ValueError( - "Invalid format. Must be either a valid formatting string, or a function " - "that accepts value and returns a string." - ) + + raise ValueError( + "Invalid format. Must be either a valid formatting string, or a function " + "that accepts value and returns a string." + ) def metric(value: float, unit: str = "", precision: int = 3) -> str: diff --git a/src/humanize/time.py b/src/humanize/time.py index bd146e6d..b074710c 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -151,7 +151,8 @@ def naturaldelta( _ngettext("%d microsecond", "%d microseconds", delta.microseconds) % delta.microseconds ) - elif min_unit == Unit.MILLISECONDS or ( + + if min_unit == Unit.MILLISECONDS or ( min_unit == Unit.MICROSECONDS and 1000 <= delta.microseconds < 1_000_000 ): milliseconds = delta.microseconds / 1000 @@ -160,47 +161,59 @@ def naturaldelta( % milliseconds ) return _("a moment") - elif seconds == 1: + + if seconds == 1: return _("a second") - elif seconds < 60: + + if seconds < 60: return _ngettext("%d second", "%d seconds", seconds) % seconds - elif 60 <= seconds < 120: + + if 60 <= seconds < 120: return _("a minute") - elif 120 <= seconds < 3600: + + if 120 <= seconds < 3600: minutes = seconds // 60 return _ngettext("%d minute", "%d minutes", minutes) % minutes - elif 3600 <= seconds < 3600 * 2: + + if 3600 <= seconds < 3600 * 2: return _("an hour") - elif 3600 < seconds: + + if 3600 < seconds: hours = seconds // 3600 return _ngettext("%d hour", "%d hours", hours) % hours + elif years == 0: if days == 1: return _("a day") + if not use_months: return _ngettext("%d day", "%d days", days) % days - else: - if not num_months: - return _ngettext("%d day", "%d days", days) % days - elif num_months == 1: - return _("a month") - else: - return _ngettext("%d month", "%d months", num_months) % num_months + + if not num_months: + return _ngettext("%d day", "%d days", days) % days + + if num_months == 1: + return _("a month") + + return _ngettext("%d month", "%d months", num_months) % num_months + elif years == 1: if not num_months and not days: return _("a year") - elif not num_months: + + if not num_months: return _ngettext("1 year, %d day", "1 year, %d days", days) % days - elif use_months: + + if use_months: if num_months == 1: return _("1 year, 1 month") - else: - return ( - _ngettext("1 year, %d month", "1 year, %d months", num_months) - % num_months - ) - else: - return _ngettext("1 year, %d day", "1 year, %d days", days) % days + + return ( + _ngettext("1 year, %d month", "1 year, %d months", num_months) + % num_months + ) + + return _ngettext("1 year, %d day", "1 year, %d days", days) % days return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) @@ -265,12 +278,16 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: # Date arguments out of range return str(value) delta = value - dt.date.today() + if delta.days == 0: return _("today") - elif delta.days == 1: + + if delta.days == 1: return _("tomorrow") - elif delta.days == -1: + + if delta.days == -1: return _("yesterday") + return value.strftime(format) @@ -323,10 +340,11 @@ def _quotient_and_remainder( """ if unit == minimum_unit: return value / divisor, 0 - elif unit in suppress: + + if unit in suppress: return 0, value - else: - return divmod(value, divisor) + + return divmod(value, divisor) def _carry( @@ -361,10 +379,11 @@ def _carry( """ if unit == min_unit: return value1 + value2 / ratio, 0 - elif unit in suppress: + + if unit in suppress: return 0, value2 + value1 * ratio - else: - return value1, value2 + + return value1, value2 def _suitable_minimum_unit(min_unit: Unit, suppress: typing.Iterable[Unit]) -> Unit: From 840faac27bd0dae6f5e23ac0b3dd375d328e42a6 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 9 Jul 2022 12:52:30 +0300 Subject: [PATCH 44/55] Test precisedelta with None to increase coverage --- src/humanize/time.py | 2 +- tests/test_time.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index b074710c..c28d5aa3 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -433,7 +433,7 @@ def _suppress_lower_units(min_unit: Unit, suppress: typing.Iterable[Unit]) -> se def precisedelta( - value: dt.timedelta | int, + value: dt.timedelta | int | None, minimum_unit: str = "seconds", suppress: typing.Iterable[str] = (), format: str = "%0.2f", diff --git a/tests/test_time.py b/tests/test_time.py index 5f8da7a9..d37fb6a3 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -629,6 +629,8 @@ def test_precisedelta_suppress_units( def test_precisedelta_bogus_call() -> None: + assert humanize.precisedelta(None) == "None" + with pytest.raises(ValueError): humanize.precisedelta(1, minimum_unit="years", suppress=["years"]) From 289f8eed8b170c8765abb550e525146f912dcc97 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Jul 2022 09:58:37 +0000 Subject: [PATCH 45/55] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/humanize/number.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index d67d17f3..c62a6142 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -230,9 +230,7 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: singular, plural = human_powers[ordinal_ - 1] return ( negative_prefix - + " ".join( - [format, _ngettext(singular, plural, math.ceil(chopped))] - ) + + " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) ) % chopped return negative_prefix + str(value) From b1ee6875820839a59f1b6f4a5fedc7cad8883834 Mon Sep 17 00:00:00 2001 From: liukun Date: Mon, 8 Aug 2022 16:36:54 +0800 Subject: [PATCH 46/55] Fix metric(0) crash. Call metrics(0) gives `ValueError: math domain error` before this patch. --- src/humanize/number.py | 2 +- tests/test_number.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 0c71e822..372f9322 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -508,7 +508,7 @@ def metric(value: float, unit: str = "", precision: int = 3) -> str: Returns: str: """ - exponent = int(math.floor(math.log10(abs(value)))) + exponent = int(math.floor(math.log10(abs(value)))) if value != 0 else 0 if exponent >= 27 or exponent < -24: return scientific(value, precision - 1) + unit diff --git a/tests/test_number.py b/tests/test_number.py index 6cf35084..7022fd63 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -194,6 +194,7 @@ def test_clamp(test_args: list[typing.Any], expected: str) -> None: @pytest.mark.parametrize( "test_args, expected", [ + ([0], "0.00"), ([1, "Hz"], "1.00 Hz"), ([1.0, "W"], "1.00 W"), ([3, "C"], "3.00 C"), From e4fcb7e34a2867a0bbcbe8bed62f04e5183445a3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 24 Aug 2022 15:53:07 +0300 Subject: [PATCH 47/55] Update pre-commit --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1c1b855..d1228e4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v2.37.3 hooks: - id: pyupgrade args: [--py37-plus] @@ -31,7 +31,7 @@ repos: files: \.py$ - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -58,20 +58,20 @@ repos: files: "src/" - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.961 + rev: v0.971 hooks: - id: mypy additional_dependencies: [pytest, types-freezegun, types-setuptools] args: [--strict] - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.1 + rev: v2.0.0 hooks: - id: setup-cfg-fmt - args: [--max-py-version=3.11] + args: [--max-py-version=3.11, --include-version-classifiers] - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.3.4 + rev: 0.3.5 hooks: - id: pyproject-fmt From 3d0f5274a19020358c57756c776d1a9f2459af8c Mon Sep 17 00:00:00 2001 From: Luflosi Date: Wed, 24 Aug 2022 13:30:10 +0200 Subject: [PATCH 48/55] Internationalise intcomma for de_DE locale --- src/humanize/i18n.py | 1 + tests/test_i18n.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 6c95749d..8c02923b 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -15,6 +15,7 @@ # Mapping of locale to thousands separator _THOUSANDS_SEPARATOR = { + "de_DE": ".", "fr_FR": " ", } diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 87ce3bb9..3f219bf5 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -42,6 +42,8 @@ def test_intcomma() -> None: assert humanize.intcomma(number) == "10,000,000" try: + humanize.i18n.activate("de_DE") + assert humanize.intcomma(number) == "10.000.000" humanize.i18n.activate("fr_FR") assert humanize.intcomma(number) == "10 000 000" From 33005e5abf19a5a1f0dd33441d73ff43fa906ca5 Mon Sep 17 00:00:00 2001 From: Luflosi Date: Thu, 25 Aug 2022 21:18:37 +0200 Subject: [PATCH 49/55] Fix intcomma() failing with a string as input when ndigits is not None Calling `humanize.intcomma("1", 0)` would fail with the error message ``` ValueError: Unknown format code 'f' for object of type 'str' ``` Fix this by first converting the string to a `float` or `int`. Always converting the string to a `float` would not work for cases like `humanize.intcomma("1")`, as this would output `'1.0'` instead of the desired output `'1'`. This also requires using a while loop instead of recursion, as calling this function again would now remove the thousands separator, leading to an infinite recursion. This also makes it easier to reason about IMO. Also add some new test cases covering this and a new example. --- src/humanize/number.py | 19 ++++++++++++------- tests/test_number.py | 4 ++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index e1fcaf44..ac134e3a 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -117,6 +117,8 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: '1,234.55' >>> intcomma(14308.40, 1) '14,308.4' + >>> intcomma("14308.40", 1) + '14,308.4' >>> intcomma(None) 'None' @@ -131,7 +133,11 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: sep = thousands_separator() try: if isinstance(value, str): - float(value.replace(sep, "")) + value = value.replace(sep, "") + if "." in value: + value = float(value) + else: + value = int(value) else: float(value) except (TypeError, ValueError): @@ -141,12 +147,11 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: orig = "{0:.{1}f}".format(value, ndigits) else: orig = str(value) - - new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig) - if orig == new: - return new - - return intcomma(new) + while True: + new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig) + if orig == new: + return new + orig = new powers = [10**x for x in (3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] diff --git a/tests/test_number.py b/tests/test_number.py index 7022fd63..b4299313 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -46,6 +46,10 @@ def test_ordinal(test_input: str, expected: str) -> None: (["10311"], "10,311"), (["1000000"], "1,000,000"), (["1234567.1234567"], "1,234,567.1234567"), + (["1234567.1234567", 0], "1,234,567"), + (["1234567.1234567", 1], "1,234,567.1"), + (["1234567.1234567", 10], "1,234,567.1234567000"), + (["1234567", 1], "1,234,567.0"), ([None], "None"), ([14308.40], "14,308.4"), ([14308.40, None], "14,308.4"), From 862748a7acd3db1257418101eec626688e0d3c78 Mon Sep 17 00:00:00 2001 From: Luflosi Date: Tue, 23 Aug 2022 17:50:28 +0200 Subject: [PATCH 50/55] Internationalise the decimal separator in intcomma() --- src/humanize/__init__.py | 3 ++- src/humanize/i18n.py | 20 +++++++++++++++++++- src/humanize/number.py | 10 ++++++---- tests/test_i18n.py | 6 ++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 25c0ba19..7d3ae654 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -1,7 +1,7 @@ """Main package for humanize.""" from humanize.filesize import naturalsize -from humanize.i18n import activate, deactivate, thousands_separator +from humanize.i18n import activate, deactivate, decimal_separator, thousands_separator from humanize.number import ( apnumber, clamp, @@ -36,6 +36,7 @@ "apnumber", "clamp", "deactivate", + "decimal_separator", "fractional", "intcomma", "intword", diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 8c02923b..664b0742 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -5,7 +5,7 @@ import os.path from threading import local -__all__ = ["activate", "deactivate", "thousands_separator"] +__all__ = ["activate", "deactivate", "decimal_separator", "thousands_separator"] _TRANSLATIONS: dict[str | None, gettext_module.NullTranslations] = { None: gettext_module.NullTranslations() @@ -19,6 +19,11 @@ "fr_FR": " ", } +# Mapping of locale to decimal separator +_DECIMAL_SEPARATOR = { + "de_DE": ",", +} + def _get_default_locale_path() -> str | None: try: @@ -173,3 +178,16 @@ def thousands_separator() -> str: except (AttributeError, KeyError): sep = "," return sep + + +def decimal_separator() -> str: + """Return the decimal separator for a locale, default to dot. + + Returns: + str: Decimal separator. + """ + try: + sep = _DECIMAL_SEPARATOR[_CURRENT.locale] + except (AttributeError, KeyError): + sep = "." + return sep diff --git a/src/humanize/number.py b/src/humanize/number.py index ac134e3a..bfd2b489 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -13,7 +13,7 @@ from .i18n import _ngettext from .i18n import _ngettext_noop as NS_ from .i18n import _pgettext as P_ -from .i18n import thousands_separator +from .i18n import decimal_separator, thousands_separator if TYPE_CHECKING: if sys.version_info >= (3, 10): @@ -130,10 +130,11 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: Returns: str: String containing commas every three digits. """ - sep = thousands_separator() + thousands_sep = thousands_separator() + decimal_sep = decimal_separator() try: if isinstance(value, str): - value = value.replace(sep, "") + value = value.replace(thousands_sep, "").replace(decimal_sep, ".") if "." in value: value = float(value) else: @@ -147,8 +148,9 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: orig = "{0:.{1}f}".format(value, ndigits) else: orig = str(value) + orig = orig.replace(".", decimal_sep) while True: - new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig) + new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{thousands_sep}\g<2>", orig) if orig == new: return new orig = new diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 4a60af16..f8782f85 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -44,6 +44,12 @@ def test_intcomma() -> None: try: humanize.i18n.activate("de_DE") assert humanize.intcomma(number) == "10.000.000" + assert humanize.intcomma(1_234_567.8901) == "1.234.567,8901" + assert humanize.intcomma(1_234_567.89) == "1.234.567,89" + assert humanize.intcomma("1234567,89") == "1.234.567,89" + assert humanize.intcomma("1.234.567,89") == "1.234.567,89" + assert humanize.intcomma("1.234.567,8") == "1.234.567,8" + humanize.i18n.activate("fr_FR") assert humanize.intcomma(number) == "10 000 000" From d0dde8f2e4ab13de51c6920f6ed523eadd2d4d3c Mon Sep 17 00:00:00 2001 From: mjmikulski Date: Thu, 1 Sep 2022 00:51:57 +0200 Subject: [PATCH 51/55] Replace short scale with long scale for Polish reference: https://en.wikipedia.org/wiki/Long_and_short_scales --- .../locale/pl_PL/LC_MESSAGES/humanize.po | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po index 5f6f7dcf..2d6b356a 100644 --- a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po @@ -136,65 +136,65 @@ msgstr[2] "milionów" #: src/humanize/number.py:142 msgid "billion" msgid_plural "billion" -msgstr[0] "bilion" -msgstr[1] "biliony" -msgstr[2] "bilionów" +msgstr[0] "miliard" +msgstr[1] "miliardy" +msgstr[2] "miliardów" #: src/humanize/number.py:143 msgid "trillion" msgid_plural "trillion" -msgstr[0] "trylion" -msgstr[1] "tryliony" -msgstr[2] "trylionów" +msgstr[0] "bilion" +msgstr[1] "biliony" +msgstr[2] "bilionów" #: src/humanize/number.py:144 msgid "quadrillion" msgid_plural "quadrillion" -msgstr[0] "kwadrylion" -msgstr[1] "kwadryliony" -msgstr[2] "kwadrylionów" +msgstr[0] "biliard" +msgstr[1] "biliardy" +msgstr[2] "biliardów" #: src/humanize/number.py:145 msgid "quintillion" msgid_plural "quintillion" -msgstr[0] "kwintylion" -msgstr[1] "kwintyliony" -msgstr[2] "kwintylionów" +msgstr[0] "trylion" +msgstr[1] "tryliony" +msgstr[2] "trylionów" #: src/humanize/number.py:146 msgid "sextillion" msgid_plural "sextillion" -msgstr[0] "sekstylion" -msgstr[1] "sekstyliony" -msgstr[2] "sekstylionów" +msgstr[0] "tryliard" +msgstr[1] "tryliard" +msgstr[2] "tryliard" #: src/humanize/number.py:147 msgid "septillion" msgid_plural "septillion" -msgstr[0] "septylion" -msgstr[1] "septyliony" -msgstr[2] "septylionów" +msgstr[0] "kwadrylion" +msgstr[1] "kwadryliony" +msgstr[2] "kwadrylionów" #: src/humanize/number.py:148 msgid "octillion" msgid_plural "octillion" -msgstr[0] "oktylion" -msgstr[1] "oktyliony" -msgstr[2] "oktylionów" +msgstr[0] "kwadryliard" +msgstr[1] "kwadryliardy" +msgstr[2] "kwadryliardów" #: src/humanize/number.py:149 msgid "nonillion" msgid_plural "nonillion" -msgstr[0] "nonilion" -msgstr[1] "noniliony" -msgstr[2] "nonilionów" +msgstr[0] "kwintylion" +msgstr[1] "kwintyliony" +msgstr[2] "kwintylionów" #: src/humanize/number.py:150 msgid "decillion" msgid_plural "decillion" -msgstr[0] "decylion" -msgstr[1] "decyliony" -msgstr[2] "decylionów" +msgstr[0] "kwintyliard" +msgstr[1] "kwintyliardy" +msgstr[2] "kwintyliardów" #: src/humanize/number.py:151 msgid "googol" From 7b0d9be9e472b5e99e4db2ed15dc5716639c2121 Mon Sep 17 00:00:00 2001 From: mjmikulski Date: Thu, 1 Sep 2022 00:52:43 +0200 Subject: [PATCH 52/55] Fix wrong declinations of googol and a year --- src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po index 2d6b356a..5acfa578 100644 --- a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po @@ -200,8 +200,8 @@ msgstr[2] "kwintyliardów" msgid "googol" msgid_plural "googol" msgstr[0] "googol" -msgstr[1] "googoly" -msgstr[2] "googolów" +msgstr[1] "googole" +msgstr[2] "googoli" #: src/humanize/number.py:246 msgid "zero" @@ -352,8 +352,8 @@ msgstr[2] "1 rok, %d miesięcy" msgid "%d year" msgid_plural "%d years" msgstr[0] "%d rok" -msgstr[1] "%d lat" -msgstr[2] "%d lata" +msgstr[1] "%d lata" +msgstr[2] "%d lat" #: src/humanize/time.py:217 #, python-format From a52beedd9ec52a40797b86e6624ba8a194cbcacc Mon Sep 17 00:00:00 2001 From: mjmikulski Date: Thu, 1 Sep 2022 00:56:15 +0200 Subject: [PATCH 53/55] Add author notice --- src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po | 1 + 1 file changed, 1 insertion(+) diff --git a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po index 5acfa578..dea532a4 100644 --- a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po @@ -3,6 +3,7 @@ # This file is distributed under the same license as the PACKAGE package. # Bartosz Bubak , 2020. # Added missing strings by Krystian Postek , 2020. +# Replace short scale with long scale by Maciej J. Mikulski (mjmikulski), 2022. # msgid "" msgstr "" From c0591aeb25a067fcfb3419e5e524f4ebd72c1380 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 6 Sep 2022 09:07:28 +0300 Subject: [PATCH 54/55] Add installation instructions --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 189c5c90..d63929a6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,22 @@ human-readable size or throughput. It is localized to: +## Installation + +### From PyPI + +```bash +python3 -m pip install --upgrade humanize +``` + +### From source + +```bash +git clone https://github.com/python-humanize/humanize +cd humanize +python3 -m pip install -e . +``` + ## Usage ### Integer humanization From 5553ff5308c363aeaa923362b11b7a8e299129f5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 11 Sep 2022 11:16:04 +0300 Subject: [PATCH 55/55] Update FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a7597cb4..aa4bcd7f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: hugovk tidelift: "pypi/humanize"