diff --git a/.github/FUNDING.md b/.github/FUNDING.yml similarity index 63% rename from .github/FUNDING.md rename to .github/FUNDING.yml index a7597cb4..aa4bcd7f 100644 --- a/.github/FUNDING.md +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: hugovk tidelift: "pypi/humanize" diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index be3d199c..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: monthly - time: "03:00" - open-pull-requests-limit: 10 - labels: - - "changelog: skip" - - "dependencies" - - package-ecosystem: pip - directory: "/" - schedule: - interval: monthly - time: "03:00" - open-pull-requests-limit: 10 - labels: - - "changelog: skip" - - "dependencies" diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..2d2f2769 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "labels": ["changelog: skip", "dependencies"], + "packageRules": [ + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "separateMajorMinor": "false" + } + ], + "schedule": ["on the first day of the month"] +} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a68b22c8..76d097d7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,11 +10,11 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 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/.github/workflows/lint.yml b/.github/workflows/lint.yml index 649ca662..8ee0cd11 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,7 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - - uses: pre-commit/action@v2.0.3 + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index a2c74d50..1079f3fd 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v1 + - uses: mheap/github-action-required-labels@v2 with: mode: minimum count: 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5054a46..1a19d3ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 812779d5..d1228e4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.0 + rev: v2.37.3 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black args: [--target-version=py37] @@ -31,7 +31,7 @@ repos: files: \.py$ - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -42,8 +42,9 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: + - id: check-json - id: check-merge-conflict - id: check-toml - id: check-yaml @@ -56,14 +57,21 @@ repos: args: ["--convention", "google"] files: "src/" + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.971 + hooks: + - id: mypy + additional_dependencies: [pytest, types-freezegun, types-setuptools] + args: [--strict] + - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.1 + rev: v2.0.0 hooks: - id: setup-cfg-fmt - args: [--max-py-version=3.11] + args: [--max-py-version=3.11, --include-version-classifiers] - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.3.3 + rev: 0.3.5 hooks: - id: pyproject-fmt diff --git a/README.md b/README.md index 0c54565d..d63929a6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ human-readable size or throughput. It is localized to: - Finnish - French - German +- Greek - Indonesian - Italian - Japanese @@ -45,6 +46,22 @@ human-readable size or throughput. It is localized to: +## Installation + +### From PyPI + +```bash +python3 -m pip install --upgrade humanize +``` + +### From source + +```bash +git clone https://github.com/python-humanize/humanize +cd humanize +python3 -m pip install -e . +``` + ## Usage ### Integer humanization @@ -207,15 +224,15 @@ FileNotFoundError: [Errno 2] No translation file found for domain: 'humanize' How to add new phrases to existing locale files: -```console -$ xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases -$ msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files +```sh +xgettext --from-code=UTF-8 -o humanize.pot -k'_' -k'N_' -k'P_:1c,2' -l python src/humanize/*.py # extract new phrases +msgmerge -U src/humanize/locale/ru_RU/LC_MESSAGES/humanize.po humanize.pot # add them to locale files ``` How to add a new locale: -```console -$ msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale +```sh +msginit -i humanize.pot -o humanize/locale//LC_MESSAGES/humanize.po --locale ``` Where `` is a locale abbreviation, eg. `en_GB`, `pt_BR` or just `ru`, `fr` diff --git a/RELEASING.md b/RELEASING.md index e620b76b..5217c004 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,7 +22,7 @@ scripts/generate-translation-binaries.sh pip install -U pip build keyring twine rm -rf build dist python -m build -twine check --strict dist/* && twine upload --repository-url https://test.pypi.org/legacy/ dist/* +twine check --strict dist/* && twine upload --repository testpypi dist/* ``` - [ ] (Optional) Check **test** installation: @@ -45,7 +45,7 @@ git tag -a 2.1.0 -m "Release 2.1.0" pip install -U pip build keyring twine rm -rf build dist python -m build -twine check --strict dist/* && twine upload -r pypi dist/* +twine check --strict dist/* && twine upload --repository pypi dist/* ``` * [ ] Check installation: diff --git a/docs/requirements.txt b/docs/requirements.txt index 92f49c0f..896ed5e1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -mkdocs>=1.1 +mkdocs==1.3.1 mkdocs-material -mkdocstrings[python-legacy]>=0.18 +mkdocstrings[python]==0.19.0 mkdocs-include-markdown-plugin pygments -pymdown-extensions>=9.2 +pymdown-extensions==9.5 diff --git a/pyproject.toml b/pyproject.toml index 3b44c906..1dd2e5c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ build-backend = "setuptools.build_meta" requires = [ "setuptools>=42", "setuptools_scm[toml]>=3.4", - "wheel", ] [tool.black] diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 0953c6de..7d3ae654 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -1,13 +1,14 @@ """Main package for humanize.""" from humanize.filesize import naturalsize -from humanize.i18n import activate, deactivate, thousands_separator +from humanize.i18n import activate, deactivate, decimal_separator, thousands_separator from humanize.number import ( apnumber, clamp, fractional, intcomma, intword, + metric, ordinal, scientific, ) @@ -24,7 +25,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. @@ -49,22 +55,27 @@ def naturalsize(value, binary=False, gnu=False, format="%.1f"): suffix = suffixes["decimal"] base = 1024 if (gnu or binary) else 1000 - bytes = float(value) - abs_bytes = abs(bytes) + bytes_ = float(value) + abs_bytes = abs(bytes_) if abs_bytes == 1 and not gnu: - return "%d Byte" % bytes - elif abs_bytes < base and not gnu: - return "%d Bytes" % bytes - elif abs_bytes < base and gnu: - return "%dB" % bytes + return "%d Byte" % bytes_ + + if abs_bytes < base and not gnu: + return "%d Bytes" % bytes_ + + if abs_bytes < base and gnu: + return "%dB" % bytes_ for i, s in enumerate(suffix): unit = base ** (i + 2) + if abs_bytes < unit and not gnu: - return (format + " %s") % ((base * bytes / unit), s) - elif abs_bytes < unit and gnu: - return (format + "%s") % ((base * bytes / unit), s) + return (format + " %s") % ((base * bytes_ / unit), s) + + if abs_bytes < unit and gnu: + return (format + "%s") % ((base * bytes_ / unit), s) + if gnu: - return (format + "%s") % ((base * bytes / unit), s) - return (format + " %s") % ((base * bytes / unit), s) + return (format + "%s") % ((base * bytes_ / unit), s) + return (format + " %s") % ((base * bytes_ / unit), s) diff --git a/src/humanize/i18n.py b/src/humanize/i18n.py index 1e76a698..664b0742 100644 --- a/src/humanize/i18n.py +++ b/src/humanize/i18n.py @@ -1,21 +1,31 @@ """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"] +__all__ = ["activate", "deactivate", "decimal_separator", "thousands_separator"] -_TRANSLATIONS = {None: gettext_module.NullTranslations()} +_TRANSLATIONS: dict[str | None, gettext_module.NullTranslations] = { + None: gettext_module.NullTranslations() +} _CURRENT = local() # Mapping of locale to thousands separator _THOUSANDS_SEPARATOR = { + "de_DE": ".", "fr_FR": " ", } +# Mapping of locale to decimal separator +_DECIMAL_SEPARATOR = { + "de_DE": ",", +} + -def _get_default_locale_path(): +def _get_default_locale_path() -> str | None: try: if __file__ is None: return None @@ -24,14 +34,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 +71,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 +88,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 +113,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 +128,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 +147,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 +164,7 @@ def num_name(n): Returns: tuple: Original text, unchanged. """ - return (singular, plural) + return singular, plural def thousands_separator() -> str: @@ -168,3 +178,16 @@ def thousands_separator() -> str: except (AttributeError, KeyError): sep = "," return sep + + +def decimal_separator() -> str: + """Return the decimal separator for a locale, default to dot. + + Returns: + str: Decimal separator. + """ + try: + sep = _DECIMAL_SEPARATOR[_CURRENT.locale] + except (AttributeError, KeyError): + sep = "." + return sep diff --git a/src/humanize/locale/ar_SA/LC_MESSAGES/humanize.po b/src/humanize/locale/ar/LC_MESSAGES/humanize.po similarity index 100% rename from src/humanize/locale/ar_SA/LC_MESSAGES/humanize.po rename to src/humanize/locale/ar/LC_MESSAGES/humanize.po diff --git a/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po b/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po new file mode 100644 index 00000000..d7e1a0c9 --- /dev/null +++ b/src/humanize/locale/el_GR/LC_MESSAGES/humanize.po @@ -0,0 +1,365 @@ +# Greek translations for PACKAGE package. +# Copyright (C) 2022 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Isaak Tsalicoglou , 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: humanize\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-05 01:06+0300\n" +"PO-Revision-Date: 2022-08-05 01:09+0300\n" +"Last-Translator: Isaak Tsalicoglou \n" +"Language-Team: Greek \n" +"Language: el\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: Isaak Tsalicoglou\n" +"X-Generator: Mousepad 0.5.9\n" + +#: src/humanize/number.py:71 +msgctxt "0 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:72 +msgctxt "1 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:73 +msgctxt "2 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:74 +msgctxt "3 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:75 +msgctxt "4 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:76 +msgctxt "5 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:77 +msgctxt "6 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:78 +msgctxt "7 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:79 +msgctxt "8 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:80 +msgctxt "9 (male)" +msgid "ος" +msgstr "." + +#: src/humanize/number.py:84 +msgctxt "0 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:85 +msgctxt "1 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:86 +msgctxt "2 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:87 +msgctxt "3 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:88 +msgctxt "4 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:89 +msgctxt "5 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:90 +msgctxt "6 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:91 +msgctxt "7 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:92 +msgctxt "8 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:93 +msgctxt "9 (female)" +msgid "η" +msgstr "." + +#: src/humanize/number.py:140 +msgid "thousand" +msgid_plural "thousand" +msgstr[0] "χιλιάδα" +msgstr[1] "χιλιάδες" + +#: src/humanize/number.py:141 +msgid "million" +msgid_plural "million" +msgstr[0] "εκατομμύριο" +msgstr[1] "εκατομμύρια" + +#: src/humanize/number.py:142 +msgid "billion" +msgid_plural "billion" +msgstr[0] "δισεκατομμύριο" +msgstr[1] "δισεκατομμύρια" + +#: src/humanize/number.py:143 +msgid "trillion" +msgid_plural "trillion" +msgstr[0] "τρισεκατομμύριο" +msgstr[1] "τρισεκατομμύρια" + +#: src/humanize/number.py:144 +msgid "quadrillion" +msgid_plural "quadrillion" +msgstr[0] "τετράκις εκατομμύριο" +msgstr[1] "τετράκις εκατομμύρια" + +#: src/humanize/number.py:145 +msgid "quintillion" +msgid_plural "quintillion" +msgstr[0] "πεντάκις εκατομμύριο" +msgstr[1] "πεντάκις εκατομμύρια" + +#: src/humanize/number.py:146 +msgid "sextillion" +msgid_plural "sextillion" +msgstr[0] "εξάκις εκατομμύριο" +msgstr[1] "εξάκις εκατομμύρια" + +#: src/humanize/number.py:147 +msgid "septillion" +msgid_plural "septillion" +msgstr[0] "επτάκις εκατομμύριο" +msgstr[1] "επτάκις εκατομμύρια" + +#: src/humanize/number.py:148 +msgid "octillion" +msgid_plural "octillion" +msgstr[0] "οκτάκις εκατομμύριο" +msgstr[1] "οκτάκις εκατομμύρια" + +#: src/humanize/number.py:149 +msgid "nonillion" +msgid_plural "nonillion" +msgstr[0] "εννεάκις εκατομμύριο" +msgstr[1] "εννεάκις εκατομμύρια" + +#: src/humanize/number.py:150 +msgid "decillion" +msgid_plural "decillion" +msgstr[0] "δεκάκις εκατομμύριο" +msgstr[1] "δεκάκις εκατομμύρια" + +#: src/humanize/number.py:151 +msgid "googol" +msgid_plural "googol" +msgstr[0] "δέκα τριακονταδυάκις εκατομμύριο" +msgstr[1] "δέκα τριακονταδυάκις εκατομμύρια" + +#: src/humanize/number.py:246 +msgid "zero" +msgstr "μηδέν" + +#: src/humanize/number.py:275 +msgid "one" +msgstr "ένα" + +#: src/humanize/number.py:276 +msgid "two" +msgstr "δύο" + +#: src/humanize/number.py:277 +msgid "three" +msgstr "τρία" + +#: src/humanize/number.py:278 +msgid "four" +msgstr "τέσσερα" + +#: src/humanize/number.py:279 +msgid "five" +msgstr "πέντε" + +#: src/humanize/number.py:280 +msgid "six" +msgstr "έξι" + +#: src/humanize/number.py:281 +msgid "seven" +msgstr "επτά" + +#: src/humanize/number.py:254 +msgid "eight" +msgstr "οκτώ" + +#: src/humanize/number.py:255 +msgid "nine" +msgstr "εννέα" + +#: src/humanize/time.py:133 +#, fuzzy, python-format +msgid "%d microsecond" +msgid_plural "%d microseconds" +msgstr[0] "%d εκατομμυριοστό του δευτερολέπτου" +msgstr[1] "%d εκατομμυριοστά του δευτερολέπτου" + +#: src/humanize/time.py:142 +#, fuzzy, python-format +msgid "%d millisecond" +msgid_plural "%d milliseconds" +msgstr[0] "%d χιλιοστό του δευτερολέπτου" +msgstr[1] "%d χιλιοστά του δευτερολέπτου" + +#: src/humanize/time.py:145 src/humanize/time.py:220 +msgid "a moment" +msgstr "μια στιγμή" + +#: src/humanize/time.py:147 +msgid "a second" +msgstr "ένα δευτερόλεπτο" + +#: src/humanize/time.py:149 +#, python-format +msgid "%d second" +msgid_plural "%d seconds" +msgstr[0] "%d δευτερόλεπτο" +msgstr[1] "%d δευτερόλεπτα" + +#: src/humanize/time.py:151 +msgid "a minute" +msgstr "ένα λεπτό" + +#: src/humanize/time.py:154 +#, python-format +msgid "%d minute" +msgid_plural "%d minutes" +msgstr[0] "%d λεπτό" +msgstr[1] "%d λεπτά" + +#: src/humanize/time.py:156 +msgid "an hour" +msgstr "μία ώρα" + +#: src/humanize/time.py:159 +#, python-format +msgid "%d hour" +msgid_plural "%d hours" +msgstr[0] "%d ώρα" +msgstr[1] "%d ώρες" + +#: src/humanize/time.py:162 +msgid "a day" +msgstr "μία ημέρα" + +#: src/humanize/time.py:164 src/humanize/time.py:167 +#, python-format +msgid "%d day" +msgid_plural "%d days" +msgstr[0] "%d ημέρα" +msgstr[1] "%d ημέρες" + +#: src/humanize/time.py:169 +msgid "a month" +msgstr "ένα μήνα" + +#: src/humanize/time.py:171 +#, python-format +msgid "%d month" +msgid_plural "%d months" +msgstr[0] "%d μήνα" +msgstr[1] "%d μήνες" + +#: src/humanize/time.py:174 +msgid "a year" +msgstr "ένα έτος" + +#: src/humanize/time.py:176 src/humanize/time.py:185 +#, python-format +msgid "1 year, %d day" +msgid_plural "1 year, %d days" +msgstr[0] "ένα έτος και %d ημέρα" +msgstr[1] "ένα έτος και %d ημέρες" + +#: src/humanize/time.py:179 +msgid "1 year, 1 month" +msgstr "ένα έτος και ένα μήνα" + +#: src/humanize/time.py:182 +#, python-format +msgid "1 year, %d month" +msgid_plural "1 year, %d months" +msgstr[0] "ένα έτος και %d μήνα" +msgstr[1] "ένα έτος και %d μήνες" + +#: src/humanize/time.py:187 +#, python-format +msgid "%d year" +msgid_plural "%d years" +msgstr[0] "%d έτος" +msgstr[1] "%d έτη" + +#: src/humanize/time.py:217 +#, python-format +msgid "%s from now" +msgstr "σε %s από τώρα" + +#: src/humanize/time.py:242 +#, python-format +msgid "%s ago" +msgstr "πριν από %s" + +#: src/humanize/time.py:246 +msgid "now" +msgstr "τώρα" + +#: src/humanize/time.py:269 +msgid "today" +msgstr "σήμερα" + +#: src/humanize/time.py:271 +msgid "tomorrow" +msgstr "αύριο" + +#: src/humanize/time.py:273 +msgid "yesterday" +msgstr "χθες" + +#: src/humanize/time.py:581 +#, python-format +msgid "%s and %s" +msgstr "%s και %s" diff --git a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po index 1d4720e2..dea532a4 100644 --- a/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po +++ b/src/humanize/locale/pl_PL/LC_MESSAGES/humanize.po @@ -3,6 +3,7 @@ # This file is distributed under the same license as the PACKAGE package. # Bartosz Bubak , 2020. # Added missing strings by Krystian Postek , 2020. +# Replace short scale with long scale by Maciej J. Mikulski (mjmikulski), 2022. # msgid "" msgstr "" @@ -122,86 +123,86 @@ msgstr "." #: src/humanize/number.py:140 msgid "thousand" msgid_plural "thousand" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "tysiąc" +msgstr[1] "tysiąc" +msgstr[2] "tysięcy" #: src/humanize/number.py:141 msgid "million" msgid_plural "million" msgstr[0] "milion" -msgstr[1] "milion" -msgstr[2] "milion" +msgstr[1] "miliony" +msgstr[2] "milionów" #: src/humanize/number.py:142 msgid "billion" msgid_plural "billion" -msgstr[0] "bilion" -msgstr[1] "bilion" -msgstr[2] "bilion" +msgstr[0] "miliard" +msgstr[1] "miliardy" +msgstr[2] "miliardów" #: src/humanize/number.py:143 msgid "trillion" msgid_plural "trillion" -msgstr[0] "trylion" -msgstr[1] "trylion" -msgstr[2] "trylion" +msgstr[0] "bilion" +msgstr[1] "biliony" +msgstr[2] "bilionów" #: src/humanize/number.py:144 msgid "quadrillion" msgid_plural "quadrillion" -msgstr[0] "kwadrylion" -msgstr[1] "kwadrylion" -msgstr[2] "kwadrylion" +msgstr[0] "biliard" +msgstr[1] "biliardy" +msgstr[2] "biliardów" #: src/humanize/number.py:145 msgid "quintillion" msgid_plural "quintillion" -msgstr[0] "kwintylion" -msgstr[1] "kwintylion" -msgstr[2] "kwintylion" +msgstr[0] "trylion" +msgstr[1] "tryliony" +msgstr[2] "trylionów" #: src/humanize/number.py:146 msgid "sextillion" msgid_plural "sextillion" -msgstr[0] "sekstylion" -msgstr[1] "sekstylion" -msgstr[2] "sekstylion" +msgstr[0] "tryliard" +msgstr[1] "tryliard" +msgstr[2] "tryliard" #: src/humanize/number.py:147 msgid "septillion" msgid_plural "septillion" -msgstr[0] "septylion" -msgstr[1] "septylion" -msgstr[2] "septylion" +msgstr[0] "kwadrylion" +msgstr[1] "kwadryliony" +msgstr[2] "kwadrylionów" #: src/humanize/number.py:148 msgid "octillion" msgid_plural "octillion" -msgstr[0] "oktylion" -msgstr[1] "oktylion" -msgstr[2] "oktylion" +msgstr[0] "kwadryliard" +msgstr[1] "kwadryliardy" +msgstr[2] "kwadryliardów" #: src/humanize/number.py:149 msgid "nonillion" msgid_plural "nonillion" -msgstr[0] "nonilion" -msgstr[1] "nonilion" -msgstr[2] "nonilion" +msgstr[0] "kwintylion" +msgstr[1] "kwintyliony" +msgstr[2] "kwintylionów" #: src/humanize/number.py:150 msgid "decillion" msgid_plural "decillion" -msgstr[0] "decylion" -msgstr[1] "decylion" -msgstr[2] "decylion" +msgstr[0] "kwintyliard" +msgstr[1] "kwintyliardy" +msgstr[2] "kwintyliardów" #: src/humanize/number.py:151 msgid "googol" msgid_plural "googol" msgstr[0] "googol" -msgstr[1] "googol" -msgstr[2] "googol" +msgstr[1] "googole" +msgstr[2] "googoli" #: src/humanize/number.py:246 msgid "zero" @@ -352,8 +353,8 @@ msgstr[2] "1 rok, %d miesięcy" msgid "%d year" msgid_plural "%d years" msgstr[0] "%d rok" -msgstr[1] "%d lat" -msgstr[2] "%d lata" +msgstr[1] "%d lata" +msgstr[2] "%d lat" #: src/humanize/time.py:217 #, python-format diff --git a/src/humanize/number.py b/src/humanize/number.py index 6611a21f..bfd2b489 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -1,24 +1,37 @@ #!/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 from .i18n import _ngettext_noop as NS_ from .i18n import _pgettext as P_ -from .i18n import thousands_separator +from .i18n import decimal_separator, thousands_separator +if TYPE_CHECKING: + if sys.version_info >= (3, 10): + 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,10 @@ def intcomma(value, ndigits=None): '1,234.55' >>> intcomma(14308.40, 1) '14,308.4' - >>> intcomma(None) is None - True + >>> intcomma("14308.40", 1) + '14,308.4' + >>> intcomma(None) + 'None' ``` Args: @@ -113,27 +128,32 @@ def intcomma(value, ndigits=None): ndigits (int, None): Digits of precision for rounding after the decimal point. Returns: - str: string containing commas every three digits. + str: String containing commas every three digits. """ - sep = thousands_separator() + thousands_sep = thousands_separator() + decimal_sep = decimal_separator() try: if isinstance(value, str): - float(value.replace(sep, "")) + value = value.replace(thousands_sep, "").replace(decimal_sep, ".") + if "." in value: + value = float(value) + else: + value = int(value) else: float(value) except (TypeError, ValueError): - return value + return str(value) - if ndigits: + if ndigits is not None: orig = "{0:.{1}f}".format(value, ndigits) else: orig = str(value) - - new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig) - if orig == new: - return new - else: - return intcomma(new) + orig = orig.replace(".", decimal_sep) + while True: + new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{thousands_sep}\g<2>", orig) + if orig == new: + return new + orig = new powers = [10**x for x in (3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 100)] @@ -153,7 +173,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 +192,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,28 +210,40 @@ def intword(value, format="%.1f"): try: value = int(value) except (TypeError, ValueError): - return value + return str(value) + + if value < 0: + value *= -1 + negative_prefix = "-" + else: + negative_prefix = "" if value < powers[0]: - return str(value) - for ordinal, power in enumerate(powers[1:], 1): + return negative_prefix + str(value) + + for ordinal_, power in enumerate(powers[1:], 1): if value < power: - chopped = value / float(powers[ordinal - 1]) + chopped = value / float(powers[ordinal_ - 1]) if float(format % chopped) == float(10**3): - chopped = value / float(powers[ordinal]) - singular, plural = human_powers[ordinal] - return ( - " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) - ) % chopped - else: - singular, plural = human_powers[ordinal - 1] + chopped = value / float(powers[ordinal_]) + singular, plural = human_powers[ordinal_] return ( - " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) + negative_prefix + + " ".join( + [format, _ngettext(singular, plural, math.ceil(chopped))] + ) ) % chopped - return str(value) + singular, plural = human_powers[ordinal_ - 1] + return ( + negative_prefix + + " ".join([format, _ngettext(singular, plural, math.ceil(chopped))]) + ) % chopped -def apnumber(value): + return negative_prefix + str(value) + + +def apnumber(value: NumberOrString) -> str: """Converts an integer to Associated Press style. Examples: @@ -226,8 +258,8 @@ def apnumber(value): 'seven' >>> apnumber("foo") 'foo' - >>> apnumber(None) is None - True + >>> apnumber(None) + 'None' ``` Args: @@ -235,12 +267,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 +290,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 +304,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 +318,8 @@ def fractional(value): '1' >>> fractional("ten") 'ten' - >>> fractional(None) is None - True + >>> fractional(None) + 'None' ``` Args: @@ -297,22 +331,23 @@ 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) return f"{whole_number:.0f}" - elif not whole_number: + + if not whole_number: return f"{numerator:.0f}/{denominator:.0f}" - else: - return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" + return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" -def scientific(value, precision=2): + +def scientific(value: NumberOrString, precision: int = 2) -> str: """Return number in string scientific notation z.wq x 10ⁿ. Examples: @@ -322,7 +357,7 @@ def scientific(value, precision=2): >>> scientific(int(500)) '5.00 x 10²' >>> scientific(-1000) - '1.00 x 10⁻³' + '-1.00 x 10³' >>> scientific(1000, 1) '1.0 x 10³' >>> scientific(1000, 3) @@ -331,8 +366,8 @@ def scientific(value, precision=2): '9.90 x 10¹' >>> scientific("foo") 'foo' - >>> scientific(None) is None - True + >>> scientific(None) + 'None' ``` @@ -354,35 +389,19 @@ def scientific(value, precision=2): "7": "⁷", "8": "⁸", "9": "⁹", - "+": "⁺", "-": "⁻", } - negative = False try: - if "-" in str(value): - value = str(value).replace("-", "") - negative = True - - if isinstance(value, str): - value = float(value) - - fmt = "{:.%se}" % str(int(precision)) - n = fmt.format(value) - + value = float(value) except (ValueError, TypeError): - return value - + return str(value) + fmt = "{:.%se}" % str(int(precision)) + n = fmt.format(value) part1, part2 = n.split("e") - if "-0" in part2: - part2 = part2.replace("-0", "-") - - if "+0" in part2: - part2 = part2.replace("+0", "") + # Remove redundant leading '+' or '0's (preserving the last '0' for 10⁰). + part2 = re.sub(r"^\+?(\-?)0*(.+)$", r"\1\2", part2) new_part2 = [] - if negative: - new_part2.append(exponents["-"]) - for char in part2: new_part2.append(exponents[char]) @@ -391,7 +410,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 +453,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. """ @@ -445,10 +471,67 @@ def clamp(value, format="{:}", floor=None, ceil=None, floor_token="<", ceil_toke if isinstance(format, str): return token + format.format(value) - elif callable(format): + + if callable(format): return token + format(value) + + raise ValueError( + "Invalid format. Must be either a valid formatting string, or a function " + "that accepts value and returns a string." + ) + + +def metric(value: float, unit: str = "", precision: int = 3) -> str: + """Return a value with a metric SI unit-prefix appended. + + Examples: + ```pycon + >>> metric(1500, "V") + '1.50 kV' + >>> metric(2e8, "W") + '200 MW' + >>> metric(220e-6, "F") + '220 μF' + >>> metric(1e-14, precision=4) + '10.00 f' + + ``` + + The unit prefix is always chosen so that non-significant zero digits are required. + i.e. `123,000` will become `123k` instead of `0.123M` and `1,230,000` will become + `1.23M` instead of `1230K`. For numbers that are either too huge or too tiny to + represent without resorting to either leading or trailing zeroes, it falls back to + `scientific()`. + ```pycon + >>> metric(1e40) + '1.00 x 10⁴⁰' + + ``` + + Args: + value (int, float): Input number. + unit (str): Optional base unit. + precision (int): The number of digits the output should contain. + + Returns: + str: + """ + exponent = int(math.floor(math.log10(abs(value)))) if value != 0 else 0 + + if exponent >= 27 or exponent < -24: + return scientific(value, precision - 1) + unit + + value /= 10 ** (exponent // 3 * 3) + if exponent >= 3: + ordinal_ = "kMGTPEZY"[exponent // 3 - 1] + elif exponent < 0: + ordinal_ = "mμnpfazy"[(-exponent - 1) // 3] else: - raise ValueError( - "Invalid format. Must be either a valid formatting string, or a function " - "that accepts value and returns a string." - ) + ordinal_ = "" + value_ = format(value, ".%if" % (precision - (exponent % 3) - 1)) + if not (unit or ordinal_) or unit in ("°", "′", "″"): + space = "" + else: + space = " " + + return f"{value_}{space}{ordinal_}{unit}" diff --git a/src/humanize/py.typed b/src/humanize/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/humanize/time.py b/src/humanize/time.py index 3fbefed6..c28d5aa3 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,21 +88,19 @@ def _date_and_delta(value, *, now=None): def naturaldelta( - value, - months=True, - minimum_unit="seconds", + value: dt.timedelta | float, + months: bool = True, + minimum_unit: str = "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. Args: - value (datetime.timedelta or int): A timedelta or a number of seconds. + value (datetime.timedelta, int or float): A timedelta or a number of seconds. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. - 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,85 +142,99 @@ 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 + + if 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") - elif seconds == 1: + + if seconds == 1: return _("a second") - elif seconds < 60: + + if seconds < 60: return _ngettext("%d second", "%d seconds", seconds) % seconds - elif 60 <= seconds < 120: + + if 60 <= seconds < 120: return _("a minute") - elif 120 <= seconds < 3600: + + if 120 <= seconds < 3600: minutes = seconds // 60 return _ngettext("%d minute", "%d minutes", minutes) % minutes - elif 3600 <= seconds < 3600 * 2: + + if 3600 <= seconds < 3600 * 2: return _("an hour") - elif 3600 < seconds: + + if 3600 < seconds: hours = seconds // 3600 return _ngettext("%d hour", "%d hours", hours) % hours + elif years == 0: if days == 1: return _("a day") + if not use_months: return _ngettext("%d day", "%d days", days) % days - else: - if not months: - return _ngettext("%d day", "%d days", days) % days - elif months == 1: - return _("a month") - else: - return _ngettext("%d month", "%d months", months) % months + + if not num_months: + return _ngettext("%d day", "%d days", days) % days + + if num_months == 1: + return _("a month") + + return _ngettext("%d month", "%d months", num_months) % num_months + elif years == 1: - if not months and not days: + if not num_months and not days: return _("a year") - elif not months: + + if not num_months: return _ngettext("1 year, %d day", "1 year, %d days", days) % days - elif use_months: - if months == 1: + + if use_months: + if num_months == 1: return _("1 year, 1 month") - else: - return ( - _ngettext("1 year, %d month", "1 year, %d months", months) % 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("1 year, %d month", "1 year, %d months", num_months) + % num_months + ) + + return _ngettext("1 year, %d day", "1 year, %d days", days) % days + + return _ngettext("%d year", "%d years", years).replace("%d", "%s") % intcomma(years) def naturaltime( - value, - future=False, - months=True, - minimum_unit="seconds", - when=None, + value: dt.datetime | dt.timedelta | float, + 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. This is more or less compatible with Django's `naturaltime` filter. Args: - value (datetime.datetime, int): A `datetime` or a number of seconds. - future (bool): Ignored for `datetime`s, where the tense is always figured out - based on the current time. For integers, the return value will be past tense - by default, unless future is `True`. + value (datetime.datetime, datetime.timedelta, int or float): A `datetime`, a + `timedelta`, or a number of seconds. + future (bool): Ignored for `datetime`s and `timedelta`s, where the tense is + always figured out based on the current time. For integers and floats, the + return value will be past tense by default, unless future is `True`. months (bool): If `True`, then a number of months (based on 30.5 days) will be used for fuzziness between years. minimum_unit (str): The lowest unit that can be used. @@ -230,7 +247,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 +258,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,37 +273,47 @@ 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") - elif delta.days == 1: + + if delta.days == 1: return _("tomorrow") - elif delta.days == -1: + + if delta.days == -1: return _("yesterday") + 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 +339,22 @@ def _quotient_and_remainder(value, divisor, unit, minimum_unit, suppress): """ if unit == minimum_unit: - return (value / divisor, 0) - elif unit in suppress: - return (0, value) - else: - return divmod(value, divisor) + return value / divisor, 0 + + if unit in suppress: + return 0, value + 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 +378,15 @@ def _carry(value1, value2, ratio, unit, min_unit, suppress): (2, 6) """ if unit == min_unit: - return (value1 + value2 / ratio, 0) - elif unit in suppress: - return (0, value2 + value1 * ratio) - else: - return (value1, value2) + return value1 + value2 / ratio, 0 + if unit in suppress: + return 0, value2 + value1 * ratio -def _suitable_minimum_unit(min_unit, suppress): + return value1, value2 + + +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: @@ -359,7 +395,7 @@ def _suitable_minimum_unit(min_unit, suppress): >>> _suitable_minimum_unit(Unit.HOURS, []).name 'HOURS' - But if suppressed, find a unit greather than the original one that is not + But if suppressed, find a unit greater than the original one that is not suppressed: >>> _suitable_minimum_unit(Unit.HOURS, [Unit.HOURS]).name @@ -380,7 +416,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 @@ -388,15 +424,20 @@ def _suppress_lower_units(min_unit, suppress): ['MICROSECONDS', 'MILLISECONDS', 'DAYS'] """ suppress = set(suppress) - for u in Unit: - if u == min_unit: + for unit in Unit: + if unit == min_unit: break - suppress.add(u) + suppress.add(unit) return suppress -def precisedelta(value, minimum_unit="seconds", suppress=(), format="%0.2f") -> str: +def precisedelta( + value: dt.timedelta | int | None, + minimum_unit: str = "seconds", + suppress: typing.Iterable[str] = (), + format: str = "%0.2f", +) -> str: """Return a precise representation of a timedelta. ```pycon @@ -465,19 +506,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 +541,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 +574,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 3e31de0a..0119d585 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """Tests for filesize humanizing.""" +from __future__ import annotations import pytest @@ -32,7 +33,7 @@ ([10**26 * 30, True, False, "%.3f"], "2481.542 YiB"), ], ) -def test_naturalsize(test_args, expected): +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 64e9b2ac..f8782f85 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,12 +36,20 @@ 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" try: + humanize.i18n.activate("de_DE") + assert humanize.intcomma(number) == "10.000.000" + assert humanize.intcomma(1_234_567.8901) == "1.234.567,8901" + assert humanize.intcomma(1_234_567.89) == "1.234.567,89" + assert humanize.intcomma("1234567,89") == "1.234.567,89" + assert humanize.intcomma("1.234.567,89") == "1.234.567,89" + assert humanize.intcomma("1.234.567,8") == "1.234.567,8" + humanize.i18n.activate("fr_FR") assert humanize.intcomma(number) == "10 000 000" @@ -48,6 +61,25 @@ def test_intcomma(): assert humanize.intcomma(number) == "10,000,000" +def test_naturaldelta() -> None: + seconds = 1234 * 365 * 24 * 60 * 60 + + assert humanize.naturaldelta(seconds) == "1,234 years" + + try: + humanize.i18n.activate("fr_FR") + assert humanize.naturaldelta(seconds) == "1 234 ans" + humanize.i18n.activate("es_ES") + assert humanize.naturaldelta(seconds) == "1,234 años" + + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + + finally: + humanize.i18n.deactivate() + assert humanize.naturaldelta(seconds) == "1,234 years" + + @pytest.mark.parametrize( ("locale", "number", "expected_result"), ( @@ -59,7 +91,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: @@ -70,6 +102,29 @@ def test_intword_plurals(locale, number, expected_result): humanize.i18n.deactivate() +@pytest.mark.parametrize( + ("locale", "expected_result"), + ( + ("ar", "5خامس"), + ("ar_SA", "5خامس"), + ("fr", "5e"), + ("fr_FR", "5e"), + ("pt", "5º"), + ("pt_BR", "5º"), + ("pt_PT", "5º"), + ), +) +def test_langauge_codes(locale: str, expected_result: str) -> None: + try: + humanize.i18n.activate(locale) + except FileNotFoundError: + pytest.skip("Generate .mo with scripts/generate-translation-binaries.sh") + else: + assert humanize.ordinal(5) == expected_result + finally: + humanize.i18n.deactivate() + + @pytest.mark.parametrize( ("locale", "number", "gender", "expected_result"), ( @@ -82,7 +137,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,21 +150,21 @@ 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 + assert i18n._get_default_locale_path() is None class TestActivate: @@ -116,7 +173,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 +181,7 @@ def test_default_locale_path_null__file__(self): i18n.activate("ru_RU") assert str(excinfo.value) == self.expected_msg - def test_default_locale_path_undefined__file__(self): + def test_default_locale_path_undefined__file__(self) -> None: i18n = importlib.import_module("humanize.i18n") del i18n.__file__ diff --git a/tests/test_number.py b/tests/test_number.py index eec6217b..b4299313 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,11 @@ def test_ordinal(test_input, expected): (["10311"], "10,311"), (["1000000"], "1,000,000"), (["1234567.1234567"], "1,234,567.1234567"), - ([None], None), + (["1234567.1234567", 0], "1,234,567"), + (["1234567.1234567", 1], "1,234,567.1"), + (["1234567.1234567", 10], "1,234,567.1234567000"), + (["1234567", 1], "1,234,567.0"), + ([None], "None"), ([14308.40], "14,308.4"), ([14308.40, None], "14,308.4"), ([14308.40, 1], "14,308.4"), @@ -51,17 +58,20 @@ def test_ordinal(test_input, expected): ([14308.40, 3], "14,308.400"), ([1234.5454545], "1,234.5454545"), ([1234.5454545, None], "1,234.5454545"), + ([1234.5454545, 0], "1,235"), ([1234.5454545, 1], "1,234.5"), ([1234.5454545, 2], "1,234.55"), ([1234.5454545, 3], "1,234.545"), ([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) @@ -69,30 +79,37 @@ def test_intword_powers(): @pytest.mark.parametrize( "test_args, expected", [ + (["0"], "0"), (["100"], "100"), + (["-100"], "-100"), (["1000"], "1.0 thousand"), (["12400"], "12.4 thousand"), (["12490"], "12.5 thousand"), (["1000000"], "1.0 million"), + (["-1000000"], "-1.0 million"), (["1200000"], "1.2 million"), (["1290000"], "1.3 million"), (["999999999"], "1.0 billion"), (["1000000000"], "1.0 billion"), + (["-1000000000"], "-1.0 billion"), (["2000000000"], "2.0 billion"), (["999999999999"], "1.0 trillion"), (["1000000000000"], "1.0 trillion"), (["6000000000000"], "6.0 trillion"), + (["-6000000000000"], "-6.0 trillion"), (["999999999999999"], "1.0 quadrillion"), (["1000000000000000"], "1.0 quadrillion"), (["1300000000000000"], "1.3 quadrillion"), + (["-1300000000000000"], "-1.3 quadrillion"), (["3500000000000000000000"], "3.5 sextillion"), (["8100000000000000000000000000000000"], "8.1 decillion"), - ([None], None), + (["-8100000000000000000000000000000000"], "-8.1 decillion"), + ([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 +124,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 +141,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 @@ -139,21 +156,25 @@ def test_fractional(test_input, expected): "test_args, expected", [ ([1000], "1.00 x 10³"), - ([-1000], "1.00 x 10⁻³"), + ([-1000], "-1.00 x 10³"), ([5.5], "5.50 x 10⁰"), ([5781651000], "5.78 x 10⁹"), (["1000"], "1.00 x 10³"), (["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⁻¹"), + ([float(1e20)], "1.00 x 10²⁰"), + ([float(2e-20)], "2.00 x 10⁻²⁰"), + ([float(-3e20)], "-3.00 x 10²⁰"), + ([float(-4e-20)], "-4.00 x 10⁻²⁰"), ], ) -def test_scientific(test_args, expected): +def test_scientific(test_args: list[typing.Any], expected: str) -> None: assert humanize.scientific(*test_args) == expected @@ -170,5 +191,43 @@ 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 + + +@pytest.mark.parametrize( + "test_args, expected", + [ + ([0], "0.00"), + ([1, "Hz"], "1.00 Hz"), + ([1.0, "W"], "1.00 W"), + ([3, "C"], "3.00 C"), + ([3, "W", 5], "3.0000 W"), + ([1.23456], "1.23"), + ([12.3456], "12.3"), + ([123.456], "123"), + ([1234.56], "1.23 k"), + ([12345, "", 6], "12.3450 k"), + ([200_000], "200 k"), + ([1e25, "m"], "10.0 Ym"), + ([1e26, "m"], "100 Ym"), + ([1e27, "A"], "1.00 x 10²⁷A"), + ([1.234e28, "A"], "1.23 x 10²⁸A"), + ([-1500, "V"], "-1.50 kV"), + ([0.12], "120 m"), + ([0.012], "12.0 m"), + ([0.0012], "1.20 m"), + ([0.00012], "120 μ"), + ([1e-23], "10.0 y"), + ([1e-24], "1.00 y"), + ([1e-25], "1.00 x 10⁻²⁵"), + ([1e-26], "1.00 x 10⁻²⁶"), + ([1, "°"], "1.00°"), + ([0.1, "°"], "100m°"), + ([100], "100"), + ([0.1], "100 m"), + ], + ids=str, +) +def test_metric(test_args: list[typing.Any], expected: str) -> None: + assert humanize.metric(*test_args) == expected diff --git a/tests/test_time.py b/tests/test_time.py index defb8e5b..d37fb6a3 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 @@ -91,6 +93,7 @@ def test_naturaldelta_nomonths(test_input, expected): [ (0, "a moment"), (1, "a second"), + (23.5, "23 seconds"), (30, "30 seconds"), (dt.timedelta(minutes=1, seconds=30), "a minute"), (dt.timedelta(minutes=2), "2 minutes"), @@ -124,7 +127,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 @@ -154,17 +157,20 @@ def test_naturaldelta(test_input, expected): # regression tests for bugs in post-release humanize (NOW + dt.timedelta(days=10000), "27 years from now"), (NOW - dt.timedelta(days=365 + 35), "1 year, 1 month ago"), + (dt.timedelta(days=-10000), "27 years from now"), + (dt.timedelta(days=365 + 35), "1 year, 1 month ago"), + (23.5, "23 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), ("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) @@ -196,13 +202,16 @@ def nt_nomonths(d): # regression tests for bugs in post-release humanize (NOW + dt.timedelta(days=10000), "27 years from now"), (NOW - dt.timedelta(days=365 + 35), "1 year, 35 days ago"), + (dt.timedelta(days=-10000), "27 years from now"), + (dt.timedelta(days=365 + 35), "1 year, 35 days ago"), + (23.5, "23 seconds ago"), (30, "30 seconds ago"), (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), ("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 +225,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 +244,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 +275,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 +293,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 +336,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 +346,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 +361,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 +405,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 +436,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 +492,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 +543,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,14 +620,16 @@ 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: + assert humanize.precisedelta(None) == "None" with pytest.raises(ValueError): humanize.precisedelta(1, minimum_unit="years", suppress=["years"]) @@ -615,11 +638,10 @@ 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"