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: | 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/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 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: # 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 1e76a69..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`. @@ -61,12 +65,12 @@ def activate(locale, path=None): return _TRANSLATIONS[locale] -def deactivate(): +def deactivate() -> None: """Deactivate internationalisation.""" _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 6611a21..3f6070f 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 = "float | str" -def ordinal(value, gender="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="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="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="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="%.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="%.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="%.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="%.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=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=2): '9.90 x 10¹' >>> scientific("foo") 'foo' - >>> scientific(None) is None - True + >>> scientific(None) + 'None' ``` @@ -370,7 +385,7 @@ def scientific(value, precision=2): n = fmt.format(value) except (ValueError, TypeError): - return value + return str(value) part1, part2 = n.split("e") if "-0" in part2: @@ -391,7 +406,14 @@ def scientific(value, precision=2): return final_str -def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_token=">"): +def clamp( + value: float, + format: str = "{:}", + floor: float | None = None, + ceil: 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 @@ -427,7 +449,7 @@ def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_toke 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 3fbefed..373657d 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -4,9 +4,12 @@ These are largely borrowed from Django's `contrib.humanize`. """ +from __future__ import annotations +import collections.abc import datetime as dt import math +import typing from enum import Enum from functools import total_ordering @@ -34,17 +37,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 +62,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,9 +88,9 @@ def _date_and_delta(value, *, now=None): def naturaldelta( - value, - months=True, - minimum_unit="seconds", + value: dt.timedelta | int, + months: bool = True, + minimum_unit: str = "seconds", ) -> str: """Return a natural representation of a timedelta or number of seconds. @@ -96,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 @@ -122,7 +125,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 +134,7 @@ def naturaldelta( value = int(value) delta = dt.timedelta(seconds=value) except (ValueError, TypeError): - return value + return str(value) use_months = months @@ -139,22 +142,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,36 +180,37 @@ 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 - else: - return _ngettext("%s year", "%s years", years) % intcomma(years) + + return _ngettext("%s year", "%s years", years) % intcomma(years) def naturaltime( - value, - future=False, - months=True, - minimum_unit="seconds", - when=None, + value: dt.datetime | int, + future: bool = False, + months: bool = True, + minimum_unit: str = "seconds", + when: dt.datetime | None = None, ) -> str: """Return a natural representation of a time in a resolution that makes sense. @@ -230,7 +233,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 +244,10 @@ def naturaltime( if delta == _("a moment"): return _("now") - return ago % delta + return str(ago % delta) -def naturalday(value, format="%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 +259,10 @@ def naturalday(value, format="%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 +273,29 @@ def naturalday(value, format="%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: float, + divisor: float, + unit: Unit, + minimum_unit: Unit, + suppress: collections.abc.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 +321,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: float, + value2: float, + ratio: 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 +359,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 +396,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 @@ -396,7 +412,12 @@ def _suppress_lower_units(min_unit, suppress): return suppress -def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> str: +def precisedelta( + value: dt.timedelta | int, + minimum_unit: str = "seconds", + suppress: typing.Iterable[str] = (), + format: str = "%0.2f", +) -> str: """Return a precise representation of a timedelta. ```pycon @@ -465,19 +486,19 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> """ 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 @@ -500,27 +521,27 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> # 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), @@ -533,19 +554,19 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> ("%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 3e31de0..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): +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 64e9b2a..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() -def test_i18n(): - three_seconds = dt.timedelta(seconds=3) + +@freeze_time("2020-02-02") +def test_i18n() -> None: + three_seconds = NOW - dt.timedelta(seconds=3) one_min_three_seconds = dt.timedelta(milliseconds=67_000) assert humanize.naturaltime(three_seconds) == "3 seconds ago" @@ -31,7 +36,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 +64,7 @@ def test_intcomma(): ("es_ES", 6700000000000, "6.7 trillones"), ), ) -def test_intword_plurals(locale, number, expected_result): +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): ("it_IT", 8, "female", "8ª"), ), ) -def test_ordinal_genders(locale, number, gender, expected_result): +def test_ordinal_genders( + locale: str, number: int, gender: str, expected_result: str +) -> None: try: humanize.i18n.activate(locale) except FileNotFoundError: @@ -93,18 +100,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 +123,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 +131,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..9b08d4d 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): +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): (["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,11 +60,13 @@ def test_ordinal(test_input, expected): ([1234.5454545, 10], "1,234.5454545000"), ], ) -def test_intcomma(test_args, expected): +def test_intcomma( + test_args: list[int] | list[float] | list[str], expected: str +) -> 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) @@ -87,12 +92,12 @@ def test_intword_powers(): (["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): +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): (9, "nine"), (10, "10"), ("7", "seven"), - (None, None), + (None, "None"), ], ) -def test_apnumber(test_input, expected): +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): ("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): +def test_fractional(test_input: float | str, expected: str) -> None: assert humanize.fractional(test_input) == expected @@ -146,14 +151,14 @@ def test_fractional(test_input, expected): (["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): +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): ([1, humanize.intword, 1e6, None, "under "], "under 1.0 million"), ], ) -def test_clamp(test_args, expected): +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 defb8e5..30539f9 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): + 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): OVERFLOW_ERROR_TEST = FakeDate(120390192341, 2, 2) -def assertEqualDatetime(dt1, dt2): +def assert_equal_datetime(dt1: dt.datetime, dt2: dt.datetime) -> None: assert (dt1 - dt2).seconds == 0 -def assertEqualTimedelta(td1, td2): +def assert_equal_timedelta(td1: dt.timedelta, td2: dt.timedelta) -> None: assert td1.days == td2.days assert td1.seconds == td2.seconds @@ -51,7 +53,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) @@ -61,15 +63,15 @@ def test_date_and_delta(): 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): +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): (dt.timedelta(days=999_999_999), "2,739,726 years"), ], ) -def test_naturaldelta(test_input, expected): +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): ("NaN", "NaN"), ], ) -def test_naturaltime(test_input, expected): +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): +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): ([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): +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): (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): (dt.date(2021, 2, 2), "Feb 02 2021"), ], ) -def test_naturaldate(test_input, expected): +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): (ONE_YEAR + FOUR_MICROSECONDS, "a year"), ], ) -def test_naturaldelta_minimum_unit_default(seconds, expected): +def test_naturaldelta_minimum_unit_default(seconds: float, expected: str) -> None: # Arrange delta = dt.timedelta(seconds=seconds) @@ -327,7 +329,9 @@ 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: str, seconds: 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): +def test_naturaltime_minimum_unit_default(seconds: 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): ("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: str, seconds: 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): (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: 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): ), ], ) -def test_precisedelta_multiple_units(val, min_unit, expected): +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): (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: 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): ), ], ) -def test_precisedelta_suppress_units(val, min_unit, suppress, expected): +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(): - assert humanize.precisedelta(None) is None - +def test_precisedelta_bogus_call() -> None: with pytest.raises(ValueError): humanize.precisedelta(1, minimum_unit="years", suppress=["years"]) @@ -615,11 +629,11 @@ 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 assert minutes == minutes with pytest.raises(TypeError): - years < "foo" + _ = years < "foo"