From 5f74683b09630a307816243cfe8180cc3a750bcd Mon Sep 17 00:00:00 2001 From: htaheri17 <174954096+htaheri17@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:19:29 +0000 Subject: [PATCH 1/2] Add naturalsize command-line interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 20 ++++++ docs/filesize.md | 24 +++++++ pyproject.toml | 3 + src/humanize/__main__.py | 10 +++ src/humanize/cli.py | 93 +++++++++++++++++++++++++++ tests/test_cli.py | 134 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 src/humanize/__main__.py create mode 100644 src/humanize/cli.py create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index 4957eec3..e7f95632 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,26 @@ If seconds are too large, set `minimum_unit` to milliseconds or microseconds: '976.6K' ``` +The same formatter is available from the command line: + +```console +$ humanize naturalsize 8589934592 +8.6 GB +$ python -m humanize naturalsize --binary 8589934592 +8.0 GiB +$ humanize naturalsize --gnu --format "%.2f" 8589934592 +8.00G +``` + +When the value is omitted, the command reads one byte count per line from standard +input. It stops at the first invalid line and reports its line number: + +```console +$ printf '1000\n2048\n' | humanize naturalsize +1.0 kB +2.0 kB +``` + ### Human-readable floating point numbers ```pycon diff --git a/docs/filesize.md b/docs/filesize.md index 9f3cf500..8ce74076 100644 --- a/docs/filesize.md +++ b/docs/filesize.md @@ -1,3 +1,27 @@ # Filesize ::: humanize.filesize + +## Command line + +Format a byte count with the `humanize` command or with `python -m humanize`: + +```console +$ humanize naturalsize 8589934592 +8.6 GB +$ humanize naturalsize --binary 8589934592 +8.0 GiB +$ humanize naturalsize --gnu --format "%.2f" 8589934592 +8.00G +``` + +With no positional value, one value is read from each input line: + +```console +$ printf '1000\n2048\n' | humanize naturalsize +1.0 kB +2.0 kB +``` + +Invalid values produce an error identifying the input line and a nonzero exit +status. diff --git a/pyproject.toml b/pyproject.toml index 63817fec..eb9180c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,9 @@ urls.Funding = "https://tidelift.com/subscription/pkg/pypi-humanize?utm_source=p urls.Homepage = "https://github.com/python-humanize/humanize" urls.Source = "https://github.com/python-humanize/humanize" +[project.scripts] +humanize = "humanize.cli:main" + [tool.hatch] version.source = "vcs" version.raw-options.local_scheme = "no-local-version" diff --git a/src/humanize/__main__.py b/src/humanize/__main__.py new file mode 100644 index 00000000..295030a7 --- /dev/null +++ b/src/humanize/__main__.py @@ -0,0 +1,10 @@ +"""Run humanize as a module.""" + +from __future__ import annotations + +import sys + +from humanize.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/humanize/cli.py b/src/humanize/cli.py new file mode 100644 index 00000000..b5aebd58 --- /dev/null +++ b/src/humanize/cli.py @@ -0,0 +1,93 @@ +"""Command-line interface for humanize.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Iterable, Sequence +from typing import TextIO + +from humanize.filesize import naturalsize + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="humanize") + commands = parser.add_subparsers(dest="command", required=True) + + filesize = commands.add_parser( + "naturalsize", + help="format byte counts as human-readable file sizes", + description="format byte counts as human-readable file sizes", + ) + filesize.add_argument( + "value", + nargs="?", + help="number of bytes; read one value per line from stdin when omitted", + ) + filesize.add_argument( + "--binary", + action="store_true", + help="use binary units such as KiB and MiB", + ) + filesize.add_argument( + "--gnu", + action="store_true", + help="use GNU-style binary units such as K and M", + ) + filesize.add_argument( + "--format", + default="%.1f", + help="format string for the numeric portion (default: %%.1f)", + ) + return parser + + +def _format_value(value: str, args: argparse.Namespace) -> str: + return naturalsize(value, binary=args.binary, gnu=args.gnu, format=args.format) + + +def _run_naturalsize( + args: argparse.Namespace, + stdin: TextIO, + stdout: TextIO, + stderr: TextIO, +) -> int: + try: + _format_value("1024", args) + except (TypeError, ValueError, OverflowError) as error: + stderr.write(f"humanize: error: invalid format {args.format!r}: {error}\n") + return 2 + + if args.value is not None: + values: Iterable[tuple[int, str]] = ((1, args.value),) + elif stdin.isatty(): + stderr.write("humanize: error: a value is required when stdin is interactive\n") + return 2 + else: + values = enumerate(stdin, start=1) + + for line_number, value in values: + value = value.strip() + try: + result = _format_value(value, args) + except (TypeError, ValueError, OverflowError) as error: + if args.value is not None: + stderr.write(f"humanize: error: invalid value {value!r}: {error}\n") + else: + stderr.write( + f"humanize: error: invalid value on line {line_number} " + f"{value!r}: {error}\n" + ) + return 2 + stdout.write(result + "\n") + + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the humanize command-line interface.""" + args = _parser().parse_args(argv) + if args.command == "naturalsize": + return _run_naturalsize(args, sys.stdin, sys.stdout, sys.stderr) + command = f"unknown command: {args.command}" + raise AssertionError(command) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..1f0c1271 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,134 @@ +"""Tests for the humanize command-line interface.""" + +from __future__ import annotations + +import io +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from humanize.cli import main + +ROOT = Path(__file__).parents[1] + + +def run_cli( + args: list[str], + input_text: str = "", + *, + interactive: bool = False, +) -> tuple[int, str, str]: + stdin = io.StringIO(input_text) + stdin.isatty = lambda: interactive # type: ignore[method-assign] + stdout = io.StringIO() + stderr = io.StringIO() + + old_stdin, old_stdout, old_stderr = sys.stdin, sys.stdout, sys.stderr + try: + sys.stdin, sys.stdout, sys.stderr = stdin, stdout, stderr + try: + status = main(args) + except SystemExit as error: + status = error.code if isinstance(error.code, int) else 1 + finally: + sys.stdin, sys.stdout, sys.stderr = old_stdin, old_stdout, old_stderr + return status, stdout.getvalue(), stderr.getvalue() + + +def test_naturalsize_defaults_to_decimal() -> None: + assert run_cli(["naturalsize", "8589934592"]) == (0, "8.6 GB\n", "") + + +@pytest.mark.parametrize( + "option, expected", + [ + ("--binary", "8.0 GiB\n"), + ("--gnu", "8.0G\n"), + ], +) +def test_naturalsize_supports_unit_options(option: str, expected: str) -> None: + assert run_cli(["naturalsize", option, "8589934592"]) == (0, expected, "") + + +def test_naturalsize_supports_custom_format() -> None: + assert run_cli(["naturalsize", "--format", "%.2f", "3000"]) == ( + 0, + "3.00 kB\n", + "", + ) + + +def test_naturalsize_reads_one_value_per_piped_line() -> None: + assert run_cli(["naturalsize"], "1000\n2048\n") == ( + 0, + "1.0 kB\n2.0 kB\n", + "", + ) + + +def test_naturalsize_reports_invalid_piped_line() -> None: + assert run_cli(["naturalsize"], "1000\nnot-a-number\n3000\n") == ( + 2, + "1.0 kB\n", + ( + "humanize: error: invalid value on line 2 'not-a-number': " + "could not convert string to float: 'not-a-number'\n" + ), + ) + + +def test_naturalsize_reports_invalid_value() -> None: + status, stdout, stderr = run_cli(["naturalsize", "invalid"]) + + assert status == 2 + assert stdout == "" + assert "invalid value 'invalid'" in stderr + assert "could not convert string to float" in stderr + + +def test_naturalsize_reports_malformed_format() -> None: + status, stdout, stderr = run_cli(["naturalsize", "--format", "%q", "3000"]) + + assert status == 2 + assert stdout == "" + assert "invalid format '%q'" in stderr + assert "unsupported format character" in stderr + + +def test_naturalsize_requires_value_for_interactive_stdin() -> None: + assert run_cli(["naturalsize"], interactive=True) == ( + 2, + "", + "humanize: error: a value is required when stdin is interactive\n", + ) + + +def test_help() -> None: + status, stdout, stderr = run_cli(["naturalsize", "--help"]) + + assert status == 0 + assert "format byte counts as human-readable file sizes" in stdout + assert "--binary" in stdout + assert "--gnu" in stdout + assert "--format" in stdout + assert stderr == "" + + +def test_python_module_invocation() -> None: + env = os.environ.copy() + env["PYTHONPATH"] = str(ROOT / "src") + result = subprocess.run( + [sys.executable, "-m", "humanize", "naturalsize", "8589934592"], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == "8.6 GB\n" + assert result.stderr == "" From f7d06a1c38e35d4e90a0292ef91a868d29c68b96 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:25:32 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/filesize.md | 3 +-- pyproject.toml | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/filesize.md b/docs/filesize.md index 8ce74076..551c0f38 100644 --- a/docs/filesize.md +++ b/docs/filesize.md @@ -23,5 +23,4 @@ $ printf '1000\n2048\n' | humanize naturalsize 2.0 kB ``` -Invalid values produce an error identifying the input line and a nonzero exit -status. +Invalid values produce an error identifying the input line and a nonzero exit status. diff --git a/pyproject.toml b/pyproject.toml index eb9180c1..bb99921e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,7 @@ urls.Documentation = "https://humanize.readthedocs.io/" urls.Funding = "https://tidelift.com/subscription/pkg/pypi-humanize?utm_source=pypi-humanize&utm_medium=pypi" urls.Homepage = "https://github.com/python-humanize/humanize" urls.Source = "https://github.com/python-humanize/humanize" - -[project.scripts] -humanize = "humanize.cli:main" +scripts.humanize = "humanize.cli:main" [tool.hatch] version.source = "vcs"