From 1cdd1d9e8582bfe0a740b56b337f6fa11f99f2fc Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Wed, 26 Aug 2020 11:54:36 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=91=20Add=20seperate=20typing=20hi?= =?UTF-8?q?nts=20for=20humanize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a collection of `.pyi` files that describe the expected type signatures of the publicly available `humanize` functions. A `MANIFEST.in` file has been added to include the new `.pyi` files and the `py.typed` file to indicate to mypy that this package supports typing. mypy has been added to the pre-commit configuration to check the stubs. Some of the signatures are slightly optimistic, since depending on error conditions, different types are returned. --- .pre-commit-config.yaml | 5 +++++ MANIFEST.in | 2 ++ src/humanize/__init__.pyi | 7 +++++++ src/humanize/filesize.py | 2 +- src/humanize/filesize.pyi | 10 ++++++++++ src/humanize/i18n.pyi | 6 ++++++ src/humanize/number.pyi | 12 ++++++++++++ src/humanize/py.typed | 0 src/humanize/time.pyi | 41 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 MANIFEST.in create mode 100644 src/humanize/__init__.pyi create mode 100644 src/humanize/filesize.pyi create mode 100644 src/humanize/i18n.pyi create mode 100644 src/humanize/number.pyi create mode 100644 src/humanize/py.typed create mode 100644 src/humanize/time.pyi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2875cc30..548cb99e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,3 +38,8 @@ repos: - id: check-merge-conflict - id: check-toml - id: check-yaml + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.782 + hooks: + - id: mypy diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..86dde97c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include src/humanize/py.typed +include src/humanize/*.pyi diff --git a/src/humanize/__init__.pyi b/src/humanize/__init__.pyi new file mode 100644 index 00000000..f02e3204 --- /dev/null +++ b/src/humanize/__init__.pyi @@ -0,0 +1,7 @@ +from humanize.filesize import naturalsize as naturalsize +from humanize.i18n import activate as activate, deactivate as deactivate +from humanize.number import apnumber as apnumber, fractional as fractional, intcomma as intcomma, intword as intword, ordinal as ordinal, scientific as scientific +from humanize.time import naturaldate as naturaldate, naturalday as naturalday, naturaldelta as naturaldelta, naturaltime as naturaltime, precisedelta as precisedelta + +VERSION: str +__version__: str diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 149e2c40..453e3f72 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -5,7 +5,7 @@ suffixes = { "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"), - "gnu": "KMGTPEZY", + "gnu": ("K", "M", "G", "T", "P", "E", "Z", "Y"), } diff --git a/src/humanize/filesize.pyi b/src/humanize/filesize.pyi new file mode 100644 index 00000000..9dc11687 --- /dev/null +++ b/src/humanize/filesize.pyi @@ -0,0 +1,10 @@ +from typing import Any, Dict, SupportsFloat, Tuple, Union + +suffixes: Dict[str, Tuple[str, ...]] + +def naturalsize( + value: Union[str, SupportsFloat], + binary: bool = ..., + gnu: bool = ..., + format: str = ... +) -> str: ... diff --git a/src/humanize/i18n.pyi b/src/humanize/i18n.pyi new file mode 100644 index 00000000..961fba3e --- /dev/null +++ b/src/humanize/i18n.pyi @@ -0,0 +1,6 @@ +from typing import Any, Dict, Optional + +def activate(locale: str, path: Optional[str] = ...) -> Dict[Any, Any]: ... +def deactivate() -> None: ... +def gettext(message: str) -> str: ... +def ngettext(message: str, plural: str, num: str) -> str: ... diff --git a/src/humanize/number.pyi b/src/humanize/number.pyi new file mode 100644 index 00000000..f4781e16 --- /dev/null +++ b/src/humanize/number.pyi @@ -0,0 +1,12 @@ +from typing import Any, List, SupportsInt, SupportsFloat, Tuple, Union, Optional + +def ordinal(value: Union[str, SupportsInt]) -> str: ... +def intcomma(value: Union[str, SupportsFloat], ndigits: Optional[int] = ...): ... + +powers: List[int] +human_powers: Tuple[str, ...] + +def intword(value: Union[str, SupportsInt], format: str = ...) -> str: ... +def apnumber(value: Union[str, SupportsInt]) -> str: ... +def fractional(value: Union[str, SupportsFloat]) -> str: ... +def scientific(value: Union[str, SupportsFloat], precision: int = ...) -> str: ... diff --git a/src/humanize/py.typed b/src/humanize/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/humanize/time.pyi b/src/humanize/time.pyi new file mode 100644 index 00000000..59a77eab --- /dev/null +++ b/src/humanize/time.pyi @@ -0,0 +1,41 @@ +from datetime import date, datetime, timedelta +from enum import Enum +from typing import Any, Iterable, Literal, Union + +class Unit(Enum): + MICROSECONDS: int = ... + MILLISECONDS: int = ... + SECONDS: int = ... + MINUTES: int = ... + HOURS: int = ... + DAYS: int = ... + MONTHS: int = ... + YEARS: int = ... + def __lt__(self, other: Any) -> Any: ... + +def naturaldelta( + value: Union[timedelta, int], + months: bool = ..., + minimum_unit: Literal["seconds", "milliseconds", "microseconds"] = ... +) -> str: ... + +def naturaltime( + value: Union[datetime, int], + future: bool = ..., + months: bool = ..., + minimum_unit: Literal["seconds", "milliseconds", "microseconds"] = ... +) -> str: ... + +def naturalday( + value: Union[date, datetime], + format: str = ... +) -> str: ... + +def naturaldate(value: Union[date, datetime]) -> str: ... + +def precisedelta( + value: Union[timedelta, int], + minimum_unit: Literal["microseconds", "milliseconds", "seconds", "minutes", "hours", "days", "months", "years"] = ..., + suppress: Iterable[Literal["microseconds", "milliseconds", "seconds", "minutes", "hours", "days", "months", "years"]] = ..., + format: str = ... +) -> str: ... From 50918355a0dee9412dbc86a5577ab5613043a700 Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Fri, 2 Oct 2020 11:07:39 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=91=20Remove=20stubs,=20add=20typi?= =?UTF-8?q?ng=20to=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is better to include typing hints directly in the functions that they're describing; which allows mypy to actually check the functions, both in their execution, and that they are actually returning the appropriate values. The major change here is that previously a number of the functions, if they encountered a conversion error, would return the value unchanged. However, that would require just declaring the return type as Any, which although correct, isn't particularly useful. So instead, any failed, unconvertable, inconsistent values will just have `str` called on them, and the output will returned. The function will always return a string, although sometimes it won't be in the format you expect. In addition, some internal variables inside the function have been rearranged and moved around, to avoid changing the type of an already defined variable. --- src/humanize/__init__.pyi | 7 -- src/humanize/filesize.py | 11 ++- src/humanize/filesize.pyi | 10 --- src/humanize/i18n.py | 23 +++--- src/humanize/i18n.pyi | 6 -- src/humanize/number.py | 51 ++++++++------ src/humanize/number.pyi | 12 ---- src/humanize/time.py | 142 ++++++++++++++++++++++++-------------- src/humanize/time.pyi | 41 ----------- 9 files changed, 143 insertions(+), 160 deletions(-) delete mode 100644 src/humanize/__init__.pyi delete mode 100644 src/humanize/filesize.pyi delete mode 100644 src/humanize/i18n.pyi delete mode 100644 src/humanize/number.pyi delete mode 100644 src/humanize/time.pyi diff --git a/src/humanize/__init__.pyi b/src/humanize/__init__.pyi deleted file mode 100644 index f02e3204..00000000 --- a/src/humanize/__init__.pyi +++ /dev/null @@ -1,7 +0,0 @@ -from humanize.filesize import naturalsize as naturalsize -from humanize.i18n import activate as activate, deactivate as deactivate -from humanize.number import apnumber as apnumber, fractional as fractional, intcomma as intcomma, intword as intword, ordinal as ordinal, scientific as scientific -from humanize.time import naturaldate as naturaldate, naturalday as naturalday, naturaldelta as naturaldelta, naturaltime as naturaltime, precisedelta as precisedelta - -VERSION: str -__version__: str diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index 1e94ac7a..50ffd7be 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -2,14 +2,21 @@ """Bits and bytes related humanization.""" -suffixes = { +import typing + +suffixes: typing.Dict[str, typing.Tuple[str, ...]] = { "decimal": ("kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), "binary": ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"), "gnu": ("K", "M", "G", "T", "P", "E", "Z", "Y"), } -def naturalsize(value, binary=False, gnu=False, format="%.1f"): +def naturalsize( + value: typing.Union[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/filesize.pyi b/src/humanize/filesize.pyi deleted file mode 100644 index 9dc11687..00000000 --- a/src/humanize/filesize.pyi +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any, Dict, SupportsFloat, Tuple, Union - -suffixes: Dict[str, Tuple[str, ...]] - -def naturalsize( - value: Union[str, SupportsFloat], - binary: bool = ..., - gnu: bool = ..., - format: str = ... -) -> str: ... diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 95e7be94..2e79238a 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -1,11 +1,14 @@ """Activate, get and deactivate translations.""" import gettext as gettext_module import os.path +import typing from threading import local __all__ = ["activate", "deactivate", "gettext", "ngettext"] -_TRANSLATIONS = {None: gettext_module.NullTranslations()} +_TRANSLATIONS: typing.Dict[typing.Optional[str], gettext_module.NullTranslations] = { + None: gettext_module.NullTranslations() +} _CURRENT = local() @@ -18,14 +21,16 @@ 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: typing.Optional[str] = None +) -> gettext_module.NullTranslations: """Activate internationalisation. Set `locale` as current locale. Search for locale in directory `path`. @@ -55,12 +60,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: @@ -72,7 +77,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 @@ -97,13 +102,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: @@ -112,7 +117,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: diff --git a/src/humanize/i18n.pyi b/src/humanize/i18n.pyi deleted file mode 100644 index 961fba3e..00000000 --- a/src/humanize/i18n.pyi +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Any, Dict, Optional - -def activate(locale: str, path: Optional[str] = ...) -> Dict[Any, Any]: ... -def deactivate() -> None: ... -def gettext(message: str) -> str: ... -def ngettext(message: str, plural: str, num: str) -> str: ... diff --git a/src/humanize/number.py b/src/humanize/number.py index e108ebcf..25de233e 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -3,19 +3,24 @@ """Humanizing functions for numbers.""" import re +import typing from fractions import Fraction from .i18n import gettext as _ from .i18n import gettext_noop as N_ from .i18n import pgettext as P_ +# This type can be better defined by typing.SupportsInt, typing.SupportsFloat +# but that's a Python 3.8 only typing option. +NumberOrString = typing.Union[str, float, int] -def ordinal(value): + +def ordinal(value: NumberOrString) -> 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 just return + the output of str(value). done to it. Args: value (int, str, float): Integer to convert. @@ -26,7 +31,7 @@ def ordinal(value): try: value = int(value) except (TypeError, ValueError): - return value + return str(value) t = ( P_("0", "th"), P_("1", "st"), @@ -44,7 +49,7 @@ def ordinal(value): return f"{value}{t[value % 10]}" -def intcomma(value, ndigits=None): +def intcomma(value: NumberOrString, ndigits: typing.Optional[int] = 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 @@ -63,7 +68,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) @@ -77,8 +82,10 @@ def intcomma(value, ndigits=None): return intcomma(new) -powers = [10 ** x for x in (6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] -human_powers = ( +powers: typing.List[int] = [ + 10 ** x for x in (6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100) +] +human_powers: typing.Tuple[str, ...] = ( N_("million"), N_("billion"), N_("trillion"), @@ -93,7 +100,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", @@ -107,12 +114,12 @@ def intword(value, format="%.1f"): Returns: str: Friendly text representation as a string, unless the value passed could not - be coaxed into an `int`. + be coaxed into an `int`, in which case, `str(value)` is returned. """ try: value = int(value) except (TypeError, ValueError): - return value + return str(value) if value < powers[0]: return str(value) @@ -127,7 +134,7 @@ def intword(value, format="%.1f"): return str(value) -def apnumber(value): +def apnumber(value: NumberOrString) -> str: """Converts an integer to Associated Press style. Args: @@ -135,12 +142,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. If 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 ( @@ -157,7 +165,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 @@ -171,6 +179,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 @@ -182,6 +191,8 @@ def fractional(value): '1/3' >>> fractional(1) '1' + >>> fractional("fallback") + 'fallback' ``` Args: value (int, float, str): Integer to convert. @@ -192,11 +203,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) @@ -207,7 +218,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: @@ -252,7 +263,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: diff --git a/src/humanize/number.pyi b/src/humanize/number.pyi deleted file mode 100644 index f4781e16..00000000 --- a/src/humanize/number.pyi +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any, List, SupportsInt, SupportsFloat, Tuple, Union, Optional - -def ordinal(value: Union[str, SupportsInt]) -> str: ... -def intcomma(value: Union[str, SupportsFloat], ndigits: Optional[int] = ...): ... - -powers: List[int] -human_powers: Tuple[str, ...] - -def intword(value: Union[str, SupportsInt], format: str = ...) -> str: ... -def apnumber(value: Union[str, SupportsInt]) -> str: ... -def fractional(value: Union[str, SupportsFloat]) -> str: ... -def scientific(value: Union[str, SupportsFloat], precision: int = ...) -> str: ... diff --git a/src/humanize/time.py b/src/humanize/time.py index b0bf85be..36e1de7f 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -7,6 +7,7 @@ import datetime as dt import math +import typing from enum import Enum from functools import total_ordering @@ -33,17 +34,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: @@ -58,7 +59,9 @@ def abs_timedelta(delta): return delta -def date_and_delta(value, *, now=None): +def date_and_delta( + value, *, now: typing.Optional[dt.datetime] = None +) -> typing.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)`. @@ -81,7 +84,11 @@ def date_and_delta(value, *, now=None): return date, abs_timedelta(delta) -def naturaldelta(value, months=True, minimum_unit="seconds"): +def naturaldelta( + value: typing.Union[dt.timedelta, int], + months: bool = True, + minimum_unit: typing.Literal["seconds", "milliseconds", "microseconds"] = "seconds", +) -> str: """Return a natural representation of a timedelta or number of seconds. This is similar to `naturaltime`, but does not add tense to the result. @@ -98,11 +105,11 @@ def naturaldelta(value, months=True, minimum_unit="seconds"): 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_u = tmp date, delta = date_and_delta(value) if date is None: - return value + return str(value) use_months = months @@ -110,18 +117,17 @@ def naturaldelta(value, months=True, minimum_unit="seconds"): 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_u == 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_u == Unit.MILLISECONDS or ( + min_u == Unit.MICROSECONDS and 1000 <= delta.microseconds < 1_000_000 ): milliseconds = delta.microseconds / 1000 return ( @@ -140,7 +146,7 @@ def naturaldelta(value, months=True, minimum_unit="seconds"): return ngettext("%d minute", "%d minutes", minutes) % minutes elif 3600 <= seconds < 3600 * 2: return _("an hour") - elif 3600 < seconds: + else: hours = seconds // 3600 return ngettext("%d hour", "%d hours", hours) % hours elif years == 0: @@ -149,23 +155,24 @@ def naturaldelta(value, months=True, minimum_unit="seconds"): 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 @@ -173,7 +180,12 @@ def naturaldelta(value, months=True, minimum_unit="seconds"): return ngettext("%d year", "%d years", years) % years -def naturaltime(value, future=False, months=True, minimum_unit="seconds"): +def naturaltime( + value: typing.Union[dt.datetime, int], + future: bool = False, + months: bool = True, + minimum_unit: typing.Literal["seconds", "milliseconds", "microseconds"] = "seconds", +) -> str: """Return a natural representation of a time in a resolution that makes sense. This is more or less compatible with Django's `naturaltime` filter. @@ -193,7 +205,7 @@ def naturaltime(value, future=False, months=True, minimum_unit="seconds"): now = _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 @@ -207,7 +219,7 @@ def naturaltime(value, future=False, months=True, minimum_unit="seconds"): return ago % delta -def naturalday(value, format="%b %d"): +def naturalday(value: typing.Union[dt.date, dt.datetime], format: str = "%b %d") -> str: """Return a natural day. For date values that are tomorrow, today or yesterday compared to @@ -218,10 +230,10 @@ def naturalday(value, format="%b %d"): 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") @@ -232,23 +244,25 @@ def naturalday(value, format="%b %d"): return value.strftime(format) -def naturaldate(value): +def naturaldate(value: typing.Union[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, divisor, unit, minimum_unit, suppress +) -> typing.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 @@ -281,7 +295,9 @@ def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): return divmod(value, divisor) -def _carry(value1, value2, ratio, unit, min_unit, suppress): +def _carry( + value1, value2, ratio, unit, min_unit, suppress +) -> typing.Tuple[float, float]: """Return a tuple with two values. If the unit is in `suppress`, multiply `value1` by `ratio` and add it to `value2` @@ -312,7 +328,7 @@ def _carry(value1, value2, ratio, unit, min_unit, suppress): 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: @@ -330,9 +346,10 @@ def _suitable_minimum_unit(min_unit, suppress): >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS, Unit.DAYS]) """ - if min_unit in suppress: + suppress_set = set(suppress) + if min_unit in suppress_set: for unit in Unit: - if unit > min_unit and unit not in suppress: + if unit > min_unit and unit not in suppress_set: return unit raise ValueError( @@ -342,7 +359,9 @@ 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] +) -> typing.Set[Unit]: """Extend suppressed units (if any) with all units lower than the minimum unit. >>> from humanize.time import _suppress_lower_units, Unit @@ -358,7 +377,24 @@ def _suppress_lower_units(min_unit, suppress): return suppress -def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f"): +UnitString = typing.Literal[ + "microseconds", + "milliseconds", + "seconds", + "minutes", + "hours", + "days", + "months", + "years", +] + + +def precisedelta( + value: typing.Union[dt.timedelta, int], + minimum_unit: UnitString, + suppress: typing.Iterable[UnitString] = (), + format: str = "%0.2f", +) -> str: """Return a precise representation of a timedelta. ```pycon @@ -408,19 +444,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 @@ -443,27 +479,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 DAYS is not in suppress_set, 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), @@ -478,13 +514,13 @@ def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f"): texts = [] for unit, fmt in zip(reversed(Unit), fmts): - singular_txt, plural_txt, value = fmt - if value > 0: - 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: + 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) - texts.append(fmt_txt % value) + texts.append(fmt_txt % fmt_value) if unit == min_unit: break diff --git a/src/humanize/time.pyi b/src/humanize/time.pyi deleted file mode 100644 index 59a77eab..00000000 --- a/src/humanize/time.pyi +++ /dev/null @@ -1,41 +0,0 @@ -from datetime import date, datetime, timedelta -from enum import Enum -from typing import Any, Iterable, Literal, Union - -class Unit(Enum): - MICROSECONDS: int = ... - MILLISECONDS: int = ... - SECONDS: int = ... - MINUTES: int = ... - HOURS: int = ... - DAYS: int = ... - MONTHS: int = ... - YEARS: int = ... - def __lt__(self, other: Any) -> Any: ... - -def naturaldelta( - value: Union[timedelta, int], - months: bool = ..., - minimum_unit: Literal["seconds", "milliseconds", "microseconds"] = ... -) -> str: ... - -def naturaltime( - value: Union[datetime, int], - future: bool = ..., - months: bool = ..., - minimum_unit: Literal["seconds", "milliseconds", "microseconds"] = ... -) -> str: ... - -def naturalday( - value: Union[date, datetime], - format: str = ... -) -> str: ... - -def naturaldate(value: Union[date, datetime]) -> str: ... - -def precisedelta( - value: Union[timedelta, int], - minimum_unit: Literal["microseconds", "milliseconds", "seconds", "minutes", "hours", "days", "months", "years"] = ..., - suppress: Iterable[Literal["microseconds", "milliseconds", "seconds", "minutes", "hours", "days", "months", "years"]] = ..., - format: str = ... -) -> str: ... From 0239209aac7246c5e3672fbe232b67045a45767c Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Wed, 7 Oct 2020 16:12:20 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=92=80=20Remove=20non-required=20MANI?= =?UTF-8?q?FEST.in=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originally only included this file to include the `.pyi` files in the package, but it turns out it was redundant, and we don't have `.pyi` files anymore. --- MANIFEST.in | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 86dde97c..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include src/humanize/py.typed -include src/humanize/*.pyi From fb53a7ed0ba9116a69cee0afcf15c88fd85c1326 Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Wed, 7 Oct 2020 16:17:01 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=8C=89=20Use=20typing=5Fextensions=20?= =?UTF-8?q?for=20Literal=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of using `typing.Literal`, which was only added in Python 3.7, make the type annotations strings, preventing their execution in the interpreter, and use `typing_extensions` to provide Literal. --- .pre-commit-config.yaml | 1 + src/humanize/time.py | 35 +++++++++++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d0d547d..95b943ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,3 +45,4 @@ repos: rev: v0.782 hooks: - id: mypy + additional_dependencies: [typing-extensions==3.7.4.3] diff --git a/src/humanize/time.py b/src/humanize/time.py index 7b816ccf..18f6a65b 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -14,6 +14,21 @@ from .i18n import gettext as _ from .i18n import ngettext +if typing.TYPE_CHECKING: + from typing_extensions import Literal + + UnitString = Literal[ + "microseconds", + "milliseconds", + "seconds", + "minutes", + "hours", + "days", + "months", + "years", + ] + + __all__ = [ "naturaldelta", "naturaltime", @@ -87,7 +102,7 @@ def date_and_delta( def naturaldelta( value: typing.Union[dt.timedelta, int], months: bool = True, - minimum_unit: typing.Literal["seconds", "milliseconds", "microseconds"] = "seconds", + minimum_unit: 'Literal["seconds", "milliseconds", "microseconds"]' = "seconds", when: typing.Optional[dt.datetime] = None, ) -> str: """Return a natural representation of a timedelta or number of seconds. @@ -199,7 +214,7 @@ def naturaltime( value: typing.Union[dt.datetime, int], future: bool = False, months: bool = True, - minimum_unit: typing.Literal["seconds", "milliseconds", "microseconds"] = "seconds", + minimum_unit: 'Literal["seconds", "milliseconds", "microseconds"]' = "seconds", when: typing.Optional[dt.datetime] = None, ) -> str: """Return a natural representation of a time in a resolution that makes sense. @@ -396,22 +411,10 @@ def _suppress_lower_units( return suppress -UnitString = typing.Literal[ - "microseconds", - "milliseconds", - "seconds", - "minutes", - "hours", - "days", - "months", - "years", -] - - def precisedelta( value: typing.Union[dt.timedelta, int], - minimum_unit: UnitString, - suppress: typing.Iterable[UnitString] = (), + minimum_unit: "UnitString", + suppress: typing.Iterable["UnitString"] = (), format: str = "%0.2f", ) -> str: """Return a precise representation of a timedelta. From 899bdfaa583e1b4ead44338b31bd942f34ad417f Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Wed, 7 Oct 2020 16:33:03 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20test=20failures=20thro?= =?UTF-8?q?ugh=20change=20in=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to make the typing accurate, the failure case was changed in multiple functions from "return the object unchanged", to "return the string representation of the object". As such, a number of tests all needed to be updated. --- src/humanize/number.py | 22 +++++++++++----------- src/humanize/time.py | 2 +- tests/test_number.py | 12 ++++++------ tests/test_time.py | 15 ++++++++------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 3b2552f2..e46714d5 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -40,7 +40,7 @@ def ordinal(value: NumberOrString) -> str: '111th' >>> ordinal("something else") 'something else' - >>> ordinal(None) is None + >>> ordinal([1, 2, 3]) == "[1, 2, 3]" True ``` @@ -91,8 +91,8 @@ def intcomma(value: NumberOrString, ndigits: typing.Optional[int] = None) -> str '1,234.55' >>> intcomma(14308.40, 1) '14,308.4' - >>> intcomma(None) is None - True + >>> intcomma(None) + 'None' ``` Args: @@ -157,8 +157,8 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: '1.2 billion' >>> intword(8100000000000000000000000000000000) '8.1 decillion' - >>> intword(None) is None - True + >>> intword(None) + 'None' >>> intword("1234000", "%0.3f") '1.234 million' @@ -205,8 +205,8 @@ def apnumber(value: NumberOrString) -> str: 'seven' >>> apnumber("foo") 'foo' - >>> apnumber(None) is None - True + >>> apnumber(None) + 'None' ``` Args: @@ -265,8 +265,8 @@ def fractional(value: NumberOrString) -> str: '1' >>> fractional("ten") 'ten' - >>> fractional(None) == str(None) - True + >>> fractional(None) + 'None' ``` Args: @@ -312,8 +312,8 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: '9.90 x 10¹' >>> scientific("foo") 'foo' - >>> scientific(None) is None - True + >>> scientific(None) + 'None' ``` diff --git a/src/humanize/time.py b/src/humanize/time.py index 18f6a65b..f7b9563a 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -413,7 +413,7 @@ def _suppress_lower_units( def precisedelta( value: typing.Union[dt.timedelta, int], - minimum_unit: "UnitString", + minimum_unit: "UnitString" = "seconds", suppress: typing.Iterable["UnitString"] = (), format: str = "%0.2f", ) -> str: diff --git a/tests/test_number.py b/tests/test_number.py index 347217d2..bcead0a6 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -22,7 +22,7 @@ ("103", "103rd"), ("111", "111th"), ("something else", "something else"), - (None, None), + (None, "None"), ], ) def test_ordinal(test_input, expected): @@ -44,7 +44,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"), @@ -85,7 +85,7 @@ 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), ], @@ -105,7 +105,7 @@ def test_intword(test_args, expected): (9, "nine"), (10, "10"), ("7", "seven"), - (None, None), + (None, "None"), ], ) def test_apnumber(test_input, expected): @@ -122,7 +122,7 @@ 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"), @@ -144,7 +144,7 @@ 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³"), diff --git a/tests/test_time.py b/tests/test_time.py index 90c01300..a4bb257d 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -214,10 +214,10 @@ 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): @@ -233,10 +233,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, str(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"), @@ -629,7 +629,8 @@ def test_precisedelta_suppress_units(val, min_unit, suppress, expected): def test_precisedelta_bogus_call(): - assert humanize.precisedelta(None) is None + bogus = object() + assert humanize.precisedelta(bogus) == str(bogus) with pytest.raises(ValueError): humanize.precisedelta(1, minimum_unit="years", suppress=["years"])