Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/filesize.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/humanize/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
93 changes: 93 additions & 0 deletions src/humanize/cli.py
Original file line number Diff line number Diff line change
@@ -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)
134 changes: 134 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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 == ""
Loading