Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'humanize' command-line interface #185

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Homepage = "https://github.com/python-humanize/humanize"
"Issue tracker" = "https://github.com/python-humanize/humanize/issues"
"Release notes" = "https://github.com/python-humanize/humanize/releases"
Source = "https://github.com/python-humanize/humanize"
[project.scripts]
humanize = "humanize.cli.main:main"

[tool.hatch]
version.source = "vcs"
Expand Down Expand Up @@ -97,6 +99,7 @@ convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D"]
"src/humanize/cli/argutils.py" = ["UP006"]

[tool.pytest.ini_options]
addopts = "--color=yes"
Expand Down
8 changes: 8 additions & 0 deletions src/humanize/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Enables to run CLI through `python -m humanize`."""

from __future__ import annotations

from humanize.cli.main import main

if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions src/humanize/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""CLI package for humanize."""
66 changes: 66 additions & 0 deletions src/humanize/cli/anntype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Argument type to be used in ArgumentParser."""

from __future__ import annotations

import builtins
import sys
from argparse import ArgumentTypeError
from collections import UserList
from typing import Any

if sys.version_info >= (3, 9):
from collections.abc import Callable

TypeList = UserList[Callable[[str], Any]]
else:
# generics syntax is not supported in standard collections before PEP 585
TypeList = UserList


class AnnotationType(TypeList):
"""Argument type based on function's annotation."""

def __init__(self, initlist: TypeList) -> None:
"""Use the `of` class method to get an instance from annotation."""
super().__init__(initlist)
self.annotation = ""

def __hash__(self) -> int:
"""ArgumentParser requires each type to be hashable."""
return hash(self.annotation)

def __call__(self, string: str) -> Any:
"""Attempt conversion."""
if self:
try:
return self[0](string)
except (TypeError, ValueError):
return AnnotationType.__call__(self[1:], string)
else:
raise ArgumentTypeError("Can't convert '%s'" % string)

@classmethod
def of(cls, annotation: str) -> AnnotationType:
"""Construction using type hints in annotation."""
argtype = cls(TypeList())
argtype.annotation = annotation

for hint in map(str.strip, annotation.split("|")):
try:
obj = getattr(builtins, hint)
except AttributeError:
if hint == "NumberOrString":
argtype.append(str)
else:
raise ValueError("Unknown hint '%s' was found" % hint)
else:
if callable(obj):
argtype.append(obj) # such as int, str, etc.
elif obj is None:
# ignoring the 'None' hint here because ArgumentParser
# doesn't convert None arguments
pass
else:
raise ValueError("Unsupported hint '%s' was found" % hint)

return argtype
117 changes: 117 additions & 0 deletions src/humanize/cli/argutils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""ArgumentParser utilities."""

from __future__ import annotations

import sys
from abc import ABC
from argparse import ArgumentParser
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from inspect import Parameter, signature
from typing import Any, Literal, NamedTuple, Union

if sys.version_info >= (3, 9):
from collections.abc import Callable
from typing import Tuple # UP006 is suppressed in this file
else:
# generics syntax is not supported in standard collections before PEP 585
from typing import Callable, Tuple

from humanize.cli.anntype import AnnotationType

FilesizeFlag = Literal[
"--binary",
"--format",
"--gnu",
]
NumberFlag = Literal[
"--ceil",
"--ceil-token",
"--floor",
"--floor-token",
"--format",
"--gender",
"--ndigits",
"--precision",
"--unit",
]

ArgFlag = Union[FilesizeFlag, NumberFlag]
ArgParam = Union[
Tuple[Literal["action"], Literal["store_true", "store_false"]],
Tuple[Literal["default"], Any],
Tuple[Literal["type"], AnnotationType],
]

ArgFlagParams = Tuple[ArgFlag, Tuple[ArgParam, ...]]
ArgError = Callable[[str], None]
ArgHelp = str

LOCALE_HELP = "Language name, e.g. `en_GB`."
VALUES_HELP = "Values to humanize. If not found, humanizes inputs from stdin."
HELP_SUFFIX = " (default: `%(default)s`)"


class ArgContext(NamedTuple):
"""Execution context to be set in ArgumentParser."""

func: Callable[..., Any]
type_: AnnotationType
error: ArgError


class PositionalArgumentFoundError(ValueError):
"""Raised when an unexpected positional argument is found."""

def __init__(self, name: str):
"""Init with an error message that includes the argument name."""
super().__init__(f"Positional argument '{name}' was found.")


def _extract_flags(parameters: Iterable[Parameter]) -> Iterator[ArgFlagParams]:
"""Yield flag and its params from function's parameters."""
for parameter in parameters:
if parameter.default is Parameter.empty:
# expect only optional keyword arguments for flags
raise PositionalArgumentFoundError(parameter.name)

kebabed_name = parameter.name.replace("_", "-")
flag: ArgFlag = f"--{kebabed_name}" # type: ignore[assignment]
params: Tuple[ArgParam, ...] = (("default", parameter.default),)

if parameter.default is False:
params += (("action", "store_true"),)
elif parameter.default is True:
params += (("action", "store_false"),)
# flip the flag with `--no-` prefix
flag = f"--no-{kebabed_name}" # type: ignore[assignment]
else:
params += (("type", AnnotationType.of(parameter.annotation)),)

yield (flag, params)


@dataclass(frozen=True)
class AddArguments(ABC):
"""Base class to organize functions in sub-classes."""

func: Callable[..., Any]
helps: Tuple[Tuple[ArgFlag, ArgHelp], ...] = ()

def __call__(self, parser: ArgumentParser) -> None:
"""Populate parser with function."""
parameters = dict(signature(self.func).parameters)

type_ = AnnotationType.of(parameters.pop("value").annotation)
# add value*s* argument using value's argument type
parser.add_argument("values", nargs="*", help=VALUES_HELP, type=type_)

# add flags
parser.add_argument("--locale", help=LOCALE_HELP)
for flag, params in _extract_flags(parameters.values()):
help_ = dict(self.helps)[flag] + HELP_SUFFIX
parser.add_argument(flag, help=help_, **dict(params))

# set execution context
context = ArgContext(func=self.func, type_=type_, error=parser.error)
parser.set_defaults(context=context)
30 changes: 30 additions & 0 deletions src/humanize/cli/filesize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""AddArguments for filesize module."""

from __future__ import annotations

from dataclasses import dataclass

from humanize.cli.argutils import AddArguments, FilesizeFlag


@dataclass(frozen=True)
class AddNaturalsizeArguments(AddArguments):
"""naturalsize function from filesize module."""

helps: tuple[tuple[FilesizeFlag, str], ...] = (
(
"--binary",
"""
If `True`, uses binary suffixes (KiB, MiB) with base 2**10 instead
of 10**3.
""",
),
(
"--gnu",
"""
If `True`, the binary argument is ignored and GNU-style (`ls -sh`
style) prefixes are used (K, M) with the 2**10 definition.
""",
),
("--format", "Custom formatter."),
)
98 changes: 98 additions & 0 deletions src/humanize/cli/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Main for CLI."""

from __future__ import annotations

import importlib.metadata
import sys
from argparse import ArgumentParser, ArgumentTypeError

from humanize import filesize, i18n, number
from humanize.cli.argutils import ArgContext
from humanize.cli.filesize import AddNaturalsizeArguments
from humanize.cli.number import (
AddApnumberArguments,
AddClampArguments,
AddFractionalArguments,
AddIntcommaArguments,
AddIntwordArguments,
AddMetricArguments,
AddOrdinalArguments,
AddScientificArguments,
)


def create_parser() -> ArgumentParser:
"""Create ArgumentParser."""
name = __name__.split(".")[0]
version = importlib.metadata.version(name)
summary = importlib.metadata.metadata(name).get("Summary")

parser = ArgumentParser(prog=name, description=summary)
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {version}"
)

subparsers = parser.add_subparsers(metavar="FUNCTION", required=True)
funcs = (
filesize.naturalsize,
number.apnumber,
number.clamp,
number.fractional,
number.intcomma,
number.intword,
number.metric,
number.ordinal,
number.scientific,
)

for func in sorted(funcs, key=lambda func: func.__name__):
func_doc = getattr(func, "__doc__", "").splitlines()[0]
subparser = subparsers.add_parser(func.__name__, help=func_doc)

cameled_name = "".join(map(str.capitalize, func.__name__.split("_")))
add_arguments = {
cls.__name__: cls
for cls in (
AddApnumberArguments,
AddClampArguments,
AddFractionalArguments,
AddIntcommaArguments,
AddIntwordArguments,
AddMetricArguments,
AddNaturalsizeArguments,
AddOrdinalArguments,
AddScientificArguments,
)
}[f"Add{cameled_name}Arguments"](func)
add_arguments(subparser)

return parser


def main() -> None:
"""Run humanize command."""
kwargs = vars(create_parser().parse_args())
context: ArgContext = kwargs.pop("context")

locale = kwargs.pop("locale")
if locale:
try:
i18n.activate(locale)
except FileNotFoundError as e:
context.error(f"{e} locale: {locale}")

values = kwargs.pop("values")
if not values:
# replacing with iterator wrapping stdin
values = map(context.type_, map(str.strip, sys.stdin))

try:
for value in values:
try:
print(context.func(value, **kwargs))
except ValueError as e:
context.error(str(e))
except ArgumentTypeError as e:
context.error(str(e))
finally:
i18n.deactivate()