"""Utilities for constructing command-line interfaces."""
from typing import Callable, Iterable, Mapping, Optional
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
from dataclasses import asdict, dataclass, field
from datetime import timedelta
from functools import wraps
import textwrap
from time import time
from .decorators import decorator_with_params
from .utils import print_err, pprint_dict
TMainFunction = Callable[[Namespace], None]
[docs]
@decorator_with_params
def clicommand(main: TMainFunction,
message: Optional[str] = None,
name: Optional[str] = None) -> TMainFunction:
"""Convert a function to act informatively as the main function in a CLI app.
Parameters
----------
message : str, optional
Message to prepend to reporting of parameters.
name: str, optional
Name of the app.
Returns
-------
Callable
Decorator for function to be used as a main function in a CLI app.
Examples
--------
>>> from argparse import Namespace
>>> def main_func(args): print("Hello world!")
>>> args = Namespace(a=1, b="Testing")
>>> clicommand(main_func, name="Main program")(args) # doctest: +SKIP
🚀 Processing with the following parameters:
a: 1
b: Testing
Hello world!
⏰ Completed Main program in 0:00:00.000132
"""
message = message or "Processing with the following parameters"
name = name or "process"
@wraps(main)
def _clicommand(args: Namespace) -> None:
start_time = time()
pprint_dict(args,
message="🚀 " + message)
main(args)
process_time = time() - start_time
print_err(f'⏰ Completed {name} in {timedelta(seconds=process_time)}')
return None
return _clicommand
[docs]
@dataclass
class BaseCLIOption:
"""Store command-line option parameters.
Takes the same parameters as `argparse.ArgumentParser().add_arguments()`, but
automatically appends defaults or "Required" to help string if not already included.
"""
args: Optional[Iterable] = field(default_factory=list)
kwargs: Optional[Mapping] = field(default_factory=dict)
def __post_init__(self):
if 'required' in self.kwargs and 'default' in self.kwargs:
if self.kwargs['default'] is not None and self.kwargs['required']:
raise AttributeError(f"Cannot set both {self.kwargs['required']=} and "
f"{self.kwargs['default']=} for CLIOption.")
if 'help' in self.kwargs:
if ('default' in self.kwargs and
' Default: ' not in self.kwargs['help'] and
self.kwargs['default'] is not None):
self.kwargs['help'] += ' Default: "{}".'.format(self.kwargs['default'])
elif 'required' in self.kwargs and self.kwargs['required']:
self.kwargs['help'] += ' Required.'
[docs]
class CLIOption(BaseCLIOption):
"""Store command-line option parameters.
Takes the same parameters as `argparse.ArgumentParser().add_arguments()`, but
automatically appends defaults or "Required" to help string if not already included.
Provides a `replace` method so that options can be re-used but with a few
parameters altered.
"""
def __init__(self, *args, **kwargs):
super().__init__(args=args,
kwargs=kwargs)
[docs]
def replace(self, **kwargs):
"""Substitute named parameters and return a new `CLIOPtion`
object
"""
_copy = BaseCLIOption(**asdict(self))
for key, value in kwargs.items():
_copy.kwargs[key] = value
return _copy
[docs]
@dataclass
class CLICommand:
"""Store parameters for a command-line app's subcommands.
Parameters
----------
name : str
Subcommand name.
description : str, optional
Help string.
options : Iterable[CLIOption]
Iterable of option objects.
main: Callable
Function to run when this command is called.
"""
name: str
description: Optional[str] = None
options: Iterable[CLIOption] = field(default_factory=list)
main: Optional[Callable] = None
[docs]
@dataclass
class CLIApp:
"""A command-line app.
Container for subcommands, which in turn contain options. Once assembled,
the app can be run with the `run()` method.
Parameters
----------
name : str
Name of the app.
version : str
Version of the app.
description : str
Help string.
commands : Iterable[CLICommand]
Set of subcommands.
"""
name: str
version: str
description: Optional[str] = None
commands: Iterable[CLICommand] = field(default_factory=list)
_parser: ArgumentParser = field(init=False)
_subcommands: Iterable = field(init=False, default_factory=list)
def __post_init__(self):
self._parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter,
description=textwrap.dedent(self.description))
self._parser.add_argument("--version", "-v",
action="version",
version='%(prog)s {version}'.format(version=self.version))
if len(self.commands) > 0:
subcommands = self._parser.add_subparsers(title='Sub-commands',
dest='subcommand',
help='Use these commands to specify the tool you want to use.')
for command in self.commands:
this_command = subcommands.add_parser(command.name,
help=command.description)
this_command.set_defaults(func=command.main)
for option in command.options:
this_command.add_argument(*option.args, **option.kwargs)
self._subcommands.append(this_command)
self._args = self._parser.parse_args()
[docs]
def run(self) -> None:
"""Run the app.
"""
return self._args.func(self._args)