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..551c0f38 100644 --- a/docs/filesize.md +++ b/docs/filesize.md @@ -1,3 +1,26 @@ # 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..bb99921e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +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" +scripts.humanize = "humanize.cli:main" [tool.hatch] version.source = "vcs" 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 == ""