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/12] 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 0953c6d..dda543d 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/12] 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 1e76a69..75fd92f 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 3e31de0..3500617 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 64e9b2a..d08ae76 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 eec6217..5d5b172 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 defb8e5..c5e979f 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/12] 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 5497e8e..e58c61f 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 3fbefed..1df8a1d 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/12] 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 e58c61f..f392fd9 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 6611a21..92830e8 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 1df8a1d..c1fa654 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/12] 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 c1fa654..0158fa5 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/12] 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 812779d..ed8a4a6 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 f392fd9..026a5a6 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 75fd92f..6c95749 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 92830e8..0925761 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 0000000..e69de29 diff --git a/src/humanize/time.py b/src/humanize/time.py index 0158fa5..9665d50 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 3500617..0119d58 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 d08ae76..8b64696 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 5d5b172..0ad8f7a 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 c5e979f..b6792aa 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 0a86628fbe4e7969639c38ad14c5d3edd0bcd81e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 4 May 2022 14:40:59 +0300 Subject: [PATCH 07/12] 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 b6792aa..a4cc9dc 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 08/12] 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 9665d50..c611dc2 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 09/12] 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 026a5a6..1449600 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 0925761..3f6070f 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 c611dc2..5e14db8 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 0ad8f7a..9b08d4d 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 a4cc9dc..30539f9 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 10/12] 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 92f49c0..dea686f 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 11/12] 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 5e14db8..373657d 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 12/12] 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 a68b22c..e9d5c4d 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: |