diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index ef63aed..face743 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -12,12 +12,12 @@ jobs: benchmarks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3.14 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" allow-prereleases: true @@ -34,7 +34,7 @@ jobs: run: pip install -e ".[tests]" - name: Run benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 + uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 with: mode: simulation run: pytest tests/test_benchmarks.py --codspeed diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4291693..60b86e1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,17 +12,17 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Docs run: | diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 85b60da..f81156d 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -12,9 +12,9 @@ jobs: sync: permissions: pull-requests: write - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: micnncim/action-label-syncer@3abd5ab72fda571e69fffd97bd4e0033dd5f495c # v1.3.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1041c72..36aed10 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Mypy run: uvx --with tox-uv tox -e mypy diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 7e539aa..4929270 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-slim steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1 + - uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0 autolabeler: if: | @@ -36,4 +36,4 @@ jobs: pull-requests: write runs-on: ubuntu-slim steps: - - uses: release-drafter/release-drafter/autolabeler@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1 + - uses: release-drafter/release-drafter/autolabeler@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4595ff9..4d7a4e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index bd53a2a..47bf92b 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -6,7 +6,7 @@ on: jobs: label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: issues: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef093e7..6cb6e61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,6 @@ jobs: - "3.15" - "3.14t" - "3.14" - - "3.13t" - "3.13" - "3.12" - "3.11" @@ -28,12 +27,12 @@ jobs: os: [windows-latest, macos-latest, ubuntu-latest] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -54,7 +53,7 @@ jobs: brew install gettext - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Generate translation binaries run: | @@ -65,7 +64,7 @@ jobs: uvx --python ${{ matrix.python-version }} --with tox-uv tox -e py - name: Upload coverage - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6c446d..77b243c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.20 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.3.1 + rev: 26.5.1 hooks: - id: black @@ -27,7 +27,7 @@ repos: exclude: \.github/ISSUE_TEMPLATE\.md|\.github/PULL_REQUEST_TEMPLATE\.md - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.1 + rev: 0.37.4 hooks: - id: check-github-workflows - id: check-renovate @@ -38,12 +38,12 @@ repos: - id: actionlint - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.23.1 + rev: v1.26.1 hooks: - id: zizmor - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.21.0 + rev: v2.25.1 hooks: - id: pyproject-fmt @@ -63,7 +63,7 @@ repos: - id: yamlfmt - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.8.1 + rev: v3.9.4 hooks: - id: prettier args: [--prose-wrap=always, --print-width=88] diff --git a/docs/requirements.txt b/docs/requirements.txt index 5c66848..c8e211c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,4 @@ mkdocs-include-markdown-plugin mkdocs-material mkdocstrings[python]==1.0.4 pygments -pymdown-extensions==10.21.3 +pymdown-extensions==11.0 diff --git a/pyproject.toml b/pyproject.toml index b277739..1843da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,15 +100,15 @@ lint.future-annotations = true max_supported_python = "3.15" [tool.mypy] -pretty = true strict = true +pretty = true show_error_codes = true [tool.pytest] -minversion = "9.0" addopts = [ "--color=yes" ] -testpaths = [ "tests" ] filterwarnings = [ "error" ] +minversion = "9.0" +testpaths = [ "tests" ] [tool.coverage] # Regexes for lines to exclude from consideration diff --git a/src/humanize/__init__.py b/src/humanize/__init__.py index 6fb9959..4f54bc4 100644 --- a/src/humanize/__init__.py +++ b/src/humanize/__init__.py @@ -2,6 +2,15 @@ from __future__ import annotations +__lazy_modules__ = { + "humanize._version", + "humanize.filesize", + "humanize.i18n", + "humanize.lists", + "humanize.number", + "humanize.time", +} + from humanize.filesize import naturalsize from humanize.i18n import activate, deactivate, decimal_separator, thousands_separator from humanize.lists import natural_list diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index c495fed..fb675fd 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -2,6 +2,8 @@ from __future__ import annotations +__lazy_modules__ = {"humanize.i18n", "math"} + from math import log from humanize.i18n import _gettext as _ @@ -97,6 +99,12 @@ def naturalsize( return f"{int(bytes_)}B" if gnu else _("%d Bytes") % int(bytes_) exp = int(min(log(abs_bytes, base), len(suffix))) + # The suffix is chosen from the unrounded byte count, but `format` rounds the + # mantissa afterward; rounding can push it up to `base` (e.g. 999999 is + # 999.999 kB, which formats to "1000.0 kB"). When that happens and a larger + # suffix is available, step up one suffix so the result reads "1.0 MB". + if exp < len(suffix) and abs(float(format % (abs_bytes / (base**exp)))) >= base: + exp += 1 space = "" if gnu else " " ret: str = format % (bytes_ / (base**exp)) + space + _(suffix[exp - 1]) return ret diff --git a/src/humanize/number.py b/src/humanize/number.py index f4dcb05..2fb22c6 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +__lazy_modules__ = {"bisect"} + import bisect from .i18n import _gettext as _ @@ -367,7 +369,12 @@ def fractional(value: NumberOrString) -> str: if not whole_number: return f"{numerator:.0f}/{denominator:.0f}" - return f"{whole_number:.0f} {numerator:.0f}/{denominator:.0f}" + # int() truncates toward zero, so for a negative number both + # whole_number and numerator carry the minus sign, which prints as + # "-1 -3/10". The sign already rides on the whole part; absorb it + # from the fractional part so the result reads as a normal mixed + # fraction. + return f"{whole_number:.0f} {abs(numerator):.0f}/{denominator:.0f}" def scientific(value: NumberOrString, precision: int = 2) -> str: @@ -538,14 +545,22 @@ def metric(value: float, unit: str = "", precision: int = 3) -> str: if exponent >= 33 or exponent < -30: return scientific(value, precision - 1) + unit - value /= 10 ** (exponent // 3 * 3) + old_bucket = exponent // 3 * 3 + value /= 10**old_bucket + digits = int(max(0, precision - exponent % 3 - 1)) + if exponent < 30 and round(abs(value), digits) >= 1000: + exponent += 3 - exponent % 3 + new_bucket = exponent // 3 * 3 + value /= 10 ** (new_bucket - old_bucket) + digits = int(max(0, precision - exponent % 3 - 1)) + if exponent >= 3: ordinal_ = "kMGTPEZYRQ"[exponent // 3 - 1] elif exponent < 0: ordinal_ = "mμnpfazyrq"[(-exponent - 1) // 3] else: ordinal_ = "" - value_ = format(value, f".{int(max(0, precision - exponent % 3 - 1))}f") + value_ = format(value, f".{digits}f") if not (unit or ordinal_) or unit in ("°", "′", "″"): space = "" else: diff --git a/src/humanize/time.py b/src/humanize/time.py index 8651a98..4a07d52 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -5,6 +5,8 @@ from __future__ import annotations +__lazy_modules__ = {"humanize.i18n", "humanize.number"} + from enum import Enum from functools import total_ordering diff --git a/tests/test_filesize.py b/tests/test_filesize.py index 04774d9..e695639 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -82,6 +82,15 @@ ([1.123456789, False, True], "1B"), ([1.123456789 * 10**3, False, True], "1.1K"), ([1.123456789 * 10**6, False, True], "1.1M"), + # Rounding must not leave the mantissa at the base while a larger suffix + # is available: 999999 is 999.999 kB, which the "%.1f" format rounds to + # 1000.0 and must carry into 1.0 MB rather than render as "1000.0 kB". + ([999999], "1.0 MB"), + ([999999999], "1.0 GB"), + ([999999999999], "1.0 TB"), + ([1024**2 - 1, True], "1.0 MiB"), + ([1024**3 - 1, True], "1.0 GiB"), + ([1024**2 - 1, False, True], "1.0M"), ], ) def test_naturalsize(test_args: list[int] | list[int | bool], expected: str) -> None: diff --git a/tests/test_number.py b/tests/test_number.py index cb74fcf..78639c3 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -122,6 +122,7 @@ def test_intword_powers() -> None: ([2e100], "2.0 googol"), ([None], "None"), (["1230000", "%0.2f"], "1.23 million"), + ([10**100], "1.0 googol"), ([10**101], "10.0 googol"), ([math.nan], "NaN"), ([math.inf], "+Inf"), @@ -183,6 +184,9 @@ def test_apnumber(test_input: int | str, expected: str) -> None: (-math.inf, "-Inf"), ("nan", "NaN"), ("-inf", "-Inf"), + (-1.3, "-1 3/10"), + (-2.5, "-2 1/2"), + (-0.5, "-1/2"), ], ) def test_fractional(test_input: float | str, expected: str) -> None: @@ -254,6 +258,10 @@ def test_clamp(test_args: list[typing.Any], expected: str) -> None: ([1234.56], "1.23 k"), ([12345, "", 6], "12.3450 k"), ([200_000], "200 k"), + ([999.9, "V"], "1.00 kV"), + ([999.99, "V"], "1.00 kV"), + ([999_999, "V"], "1.00 MV"), + ([0.0009999, "V"], "1.00 mV"), ([1e25, "m"], "10.0 Ym"), ([1e26, "m"], "100 Ym"), ([1e27, "A"], "1.00 RA"), diff --git a/tox.ini b/tox.ini index 58cbf67..1e50273 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,13 @@ [tox] requires = - tox>=4.2 + tox>=4.32 env_list = docs lint mypy - py{py3, 315, 314, 313, 312, 311, 310} + py{py3} + py{310-315} + py{314-315}t [testenv] extras =