diff --git a/face/command.py b/face/command.py index 25683dc..b93f34c 100644 --- a/face/command.py +++ b/face/command.py @@ -1,9 +1,10 @@ import sys from collections import OrderedDict +from typing import Callable, List, Optional, Union from face.utils import unwrap_text, get_rdep_map, echo from face.errors import ArgumentParseError, CommandLineError, UsageError -from face.parser import Parser, Flag +from face.parser import Parser, Flag, PosArgSpec from face.helpers import HelpHandler from face.middleware import (inject, get_arg_names, @@ -57,56 +58,58 @@ class Command(Parser): populate it with flags and subcommands, and then call command.run() to execute your CLI. - Note that only the first three constructor arguments are - positional, the rest are keyword-only. - Args: - func (callable): The function called when this command is - run with an argv that contains no subcommands. - name (str): The name of this command, used when this - command is included as a subcommand. (Defaults to name - of function) - doc (str): A description or message that appears in various - help outputs. - flags (list): A list of Flag instances to initialize the - Command with. Flags can always be added later with the - .add() method. - posargs (bool): Pass True if the command takes positional - arguments. Defaults to False. Can also pass a PosArgSpec - instance. - post_posargs (bool): Pass True if the command takes - additional positional arguments after a conventional '--' - specifier. - help (bool): Pass False to disable the automatically added - --help flag. Defaults to True. Also accepts a HelpHandler - instance, see those docs for more details. - middlewares (list): A list of @face_middleware decorated - callables which participate in dispatch. Also addable - via the .add() method. See Middleware docs for more - details. - + func: The function called when this command is + run with an argv that contains no subcommands. + name: The name of this command, used when this + command is included as a subcommand. (Defaults to name + of function) + doc: A description or message that appears in various + help outputs. + flags: A list of Flag instances to initialize the + Command with. Flags can always be added later with the + .add() method. + posargs: Pass True if the command takes positional + arguments. Defaults to False. Can also pass a PosArgSpec + instance. + post_posargs: Pass True if the command takes + additional positional arguments after a conventional '--' + specifier. + help: Pass False to disable the automatically added + --help flag. Defaults to True. Also accepts a HelpHandler + instance. + middlewares: A list of @face_middleware decorated + callables which participate in dispatch. """ - def __init__(self, func, name=None, doc=None, **kwargs): + def __init__(self, + func: Optional[Callable], + name: Optional[str] = None, + doc: Optional[str] = None, + *, + flags: Optional[List[Flag]] = None, + posargs: Optional[Union[bool, PosArgSpec]] = None, + post_posargs: Optional[bool] = None, + flagfile: bool = True, + help: Union[bool, HelpHandler] = DEFAULT_HELP_HANDLER, + middlewares: Optional[List[Callable]] = None) -> None: name = name if name is not None else _get_default_name(func) - if doc is None: doc = _docstring_to_doc(func) # TODO: default posargs if none by inspecting func super().__init__(name, doc, - flags=kwargs.pop('flags', None), - posargs=kwargs.pop('posargs', None), - post_posargs=kwargs.pop('post_posargs', None), - flagfile=kwargs.pop('flagfile', True)) + flags=flags, + posargs=posargs, + post_posargs=post_posargs, + flagfile=flagfile) - _help = kwargs.pop('help', DEFAULT_HELP_HANDLER) - self.help_handler = _help + self.help_handler = help # TODO: if func is callable, check that "next_" isn't taken self._path_func_map = OrderedDict() self._path_func_map[()] = func - middlewares = list(kwargs.pop('middlewares', None) or []) + middlewares = list(middlewares or []) self._path_mw_map = OrderedDict() self._path_mw_map[()] = [] self._path_wrapped_map = OrderedDict() @@ -114,16 +117,15 @@ def __init__(self, func, name=None, doc=None, **kwargs): for mw in middlewares: self.add_middleware(mw) - if kwargs: - raise TypeError(f'unexpected keyword arguments: {sorted(kwargs.keys())!r}') - - if _help: - if _help.flag: - self.add(_help.flag) - if _help.subcmd: - self.add(_help.func, _help.subcmd) # for 'help' as a subcmd + if help: + if help is True: + help = DEFAULT_HELP_HANDLER + if help.flag: + self.add(help.flag) + if help.subcmd: + self.add(help.func, help.subcmd) # for 'help' as a subcmd - if not func and not _help: + if not func and not help: raise ValueError('Command requires a handler function or help handler' ' to be set, not: %r' % func) diff --git a/face/middleware.py b/face/middleware.py index c48a94e..75260b0 100644 --- a/face/middleware.py +++ b/face/middleware.py @@ -148,6 +148,7 @@ def timing_middleware(next_, echo_time): from face.parser import Flag from face.sinter import make_chain, get_arg_names, get_fb, get_callable_labels from face.sinter import inject # transitive import for external use +from typing import Callable, List, Optional, Union INNER_NAME = 'next_' @@ -171,35 +172,38 @@ def is_middleware(target): return False -def face_middleware(func=None, **kwargs): +def face_middleware(func: Optional[Callable] = None, + *, + provides: Union[List[str], str] = [], + flags: List[Flag] = [], + optional: bool = False) -> Callable: """A decorator to mark a function as face middleware, which wraps execution of a subcommand handler function. This decorator can be called with or without arguments: Args: - provides (list): An optional list of names, declaring which - values be provided by this middleware at execution time. - flags (list): An optional list of Flag instances, which will be - automatically added to any Command which adds this middleware. - optional (bool): Whether this middleware should be skipped if its - provides are not required by the command. + provides: An optional list of names, declaring which + values be provided by this middleware at execution time. + flags: An optional list of Flag instances, which will be + automatically added to any Command which adds this middleware. + optional: Whether this middleware should be skipped if its + provides are not required by the command. The first argument of the decorated function must be named "next_". This argument is a function, representing the next function in the execution chain, the last of which is the command's handler function. + + Returns: + A decorator function that marks the decorated function as middleware. """ - provides = kwargs.pop('provides', []) if isinstance(provides, str): provides = [provides] - flags = list(kwargs.pop('flags', [])) + flags = list(flags) if flags: for flag in flags: if not isinstance(flag, Flag): raise TypeError(f'expected Flag object, not: {flag!r}') - optional = kwargs.pop('optional', False) - if kwargs: - raise TypeError(f'unexpected keyword arguments: {kwargs.keys()!r}') def decorate_face_middleware(func): check_middleware(func, provides=provides) diff --git a/face/parser.py b/face/parser.py index afda7e7..3aef226 100644 --- a/face/parser.py +++ b/face/parser.py @@ -3,6 +3,7 @@ import codecs import os.path from collections import OrderedDict +from typing import Optional from boltons.iterutils import split, unique from boltons.dictutils import OrderedMultiDict as OMD @@ -287,7 +288,14 @@ class FlagDisplay: """ # value_name -> arg_name? - def __init__(self, flag, **kw): + def __init__(self, flag, *, + label: Optional[str] = None, + post_doc: Optional[str] = None, + full_doc: Optional[str] = None, + value_name: Optional[str] = None, + group: int = 0, + hidden: bool = False, + sort_key: int = 0): self.flag = flag self.doc = flag.doc @@ -297,24 +305,21 @@ def __init__(self, flag, **kw): if _prep == 'as': self.doc = desc - self.post_doc = kw.pop('post_doc', None) - self.full_doc = kw.pop('full_doc', None) + self.post_doc = post_doc + self.full_doc = full_doc self.value_name = '' if callable(flag.parse_as): # TODO: use default when it's set and it's a basic renderable type - self.value_name = kw.pop('value_name', None) or self.flag.name.upper() + self.value_name = value_name or self.flag.name.upper() - self.group = kw.pop('group', 0) # int or str - self._hide = kw.pop('hidden', False) # bool - self.label = kw.pop('label', None) # see hidden property below for more info - self.sort_key = kw.pop('sort_key', 0) # int or str + self.group = group + self._hide = hidden + self.label = label # see hidden property below for more info + self.sort_key = sort_key # TODO: sort_key is gonna need to be partitioned on type for py3 # TODO: maybe sort_key should be a counter so that flags sort # in the order they are created - - if kw: - raise TypeError(f'unexpected keyword arguments: {kw.keys()!r}') return @property @@ -342,16 +347,17 @@ class PosArgDisplay: often describes default behavior. """ - def __init__(self, **kw): - self.name = kw.pop('name', None) or 'arg' - self.doc = kw.pop('doc', '') - self.post_doc = kw.pop('post_doc', None) - self._hide = kw.pop('hidden', False) # bool - self.label = kw.pop('label', None) - - if kw: - raise TypeError(f'unexpected keyword arguments: {kw.keys()!r}') - return + def __init__(self, *, + name: Optional[str] = None, + doc: str = '', + post_doc: Optional[str] = None, + hidden: bool = False, + label: Optional[str] = None) -> None: + self.name = name or 'arg' + self.doc = doc + self.post_doc = post_doc + self._hide = hidden + self.label = label @property def hidden(self): @@ -386,13 +392,11 @@ class PosArgSpec: times around the application. """ - def __init__(self, parse_as=str, min_count=None, max_count=None, display=None, provides=None, **kwargs): + def __init__(self, parse_as=str, min_count=None, max_count=None, display=None, provides=None, + *, name: Optional[str] = None, count: Optional[int] = None): if not callable(parse_as) and parse_as is not ERROR: raise TypeError(f'expected callable or ERROR for parse_as, not {parse_as!r}') - name = kwargs.pop('name', None) - count = kwargs.pop('count', None) - if kwargs: - raise TypeError(f'unexpected keyword arguments: {list(kwargs.keys())!r}') + self.parse_as = parse_as # count convenience alias @@ -717,7 +721,7 @@ def parse(self, argv): ape.prs_res = cpr raise ape for arg in argv: - if not isinstance(arg, (str, str)): + if not isinstance(arg, str): raise TypeError(f'parse expected all args as strings, not: {arg!r} ({type(arg).__name__})') ''' for subprs_path, subprs in self.subprs_map.items(): diff --git a/face/test/test_basic.py b/face/test/test_basic.py index bb7bf0c..5df0c8a 100644 --- a/face/test/test_basic.py +++ b/face/test/test_basic.py @@ -43,9 +43,6 @@ def test_flag_name(): with pytest.raises(TypeError, match='or FlagDisplay instance'): Flag('name', display=object()) - with pytest.raises(TypeError, match='unexpected keyword arguments'): - Flag('name', display={'badkw': 'val'}) - with pytest.raises(ValueError, match='expected identifier.*'): assert identifier_to_flag('--flag') @@ -85,11 +82,6 @@ def test_flag_hidden(): assert 'dragon' not in [f.name for f in flags] -def test_command_misc_api(): - with pytest.raises(TypeError, match='unexpected keyword'): - Command(lambda: None, name='ok', bad_kwarg=True) - - def test_flag_init(): cmd = Command(lambda flag, part: None, name='cmd') @@ -143,8 +135,6 @@ def test_minimal_exe(): def test_posargspec_init(): with pytest.raises(TypeError, match='expected callable or ERROR'): PosArgSpec(parse_as=object()) - with pytest.raises(TypeError, match='unexpected keyword'): - PosArgSpec(badkw='val') with pytest.raises(ValueError, match='expected min_count >= 0'): PosArgSpec(min_count=-1) diff --git a/face/testing.py b/face/testing.py index f292cc4..a73b644 100644 --- a/face/testing.py +++ b/face/testing.py @@ -327,7 +327,7 @@ def run(self, args, input=None, env=None, chdir=None, exit_code=0): exc_info = None exit_code = 0 - if isinstance(args, (str, str)): + if isinstance(args, str): args = shlex.split(args) try: diff --git a/face/utils.py b/face/utils.py index bb34148..09cad30 100644 --- a/face/utils.py +++ b/face/utils.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import os import re import sys import getpass import keyword import textwrap +import typing from boltons.strutils import pluralize, strip_ansi from boltons.iterutils import split, unique @@ -34,7 +37,7 @@ def process_command_name(name): """ - if not name or not isinstance(name, (str, str)): + if not name or not isinstance(name, str): raise ValueError(f'expected non-zero length string for subcommand name, not: {name!r}') if name.endswith('-') or name.endswith('_'): @@ -72,7 +75,7 @@ def flag_to_identifier(flag): Input case doesn't matter, output case will always be lower. """ orig_flag = flag - if not flag or not isinstance(flag, (str, str)): + if not flag or not isinstance(flag, str): raise ValueError(f'expected non-zero length string for flag, not: {flag!r}') if flag.endswith('-') or flag.endswith('_'): @@ -249,7 +252,7 @@ def get_minimal_executable(executable=None, path=None, environ=None): executable = sys.executable if executable is None else executable environ = os.environ if environ is None else environ path = environ.get('PATH', '') if path is None else path - if isinstance(path, (str, str)): + if isinstance(path, str): path = path.split(':') executable_basename = os.path.basename(executable) @@ -275,7 +278,13 @@ def should_strip_ansi(stream): return not isatty(stream) -def echo(msg, **kw): +def echo(msg: str | bytes | object, *, + err: bool = False, + file: typing.TextIO | None = None, + nl: bool = True, + end: str | None = None, + color: bool | None = None, + indent: str | int = '') -> None: """A better-behaved :func:`print()` function for command-line applications. Writes text or bytes to a file or stream and flushes. Seamlessly @@ -286,26 +295,23 @@ def echo(msg, **kw): test Args: - - msg (str): A text or byte string to echo. - err (bool): Set the default output file to ``sys.stderr`` - file (file): Stream or other file-like object to output - to. Defaults to ``sys.stdout``, or ``sys.stderr`` if *err* is - True. - nl (bool): If ``True``, sets *end* to ``'\\n'``, the newline character. - end (str): Explicitly set the line-ending character. Setting this overrides *nl*. - color (bool): Set to ``True``/``False`` to always/never echo ANSI color - codes. Defaults to inspecting whether *file* is a TTY. - + msg: A text or byte string to echo. + err: Set the default output file to ``sys.stderr`` + file: Stream or other file-like object to output + to. Defaults to ``sys.stdout``, or ``sys.stderr`` if *err* is + True. + nl: If ``True``, sets *end* to ``'\\n'``, the newline character. + end: Explicitly set the line-ending character. Setting this overrides *nl*. + color: Set to ``True``/``False`` to always/never echo ANSI color + codes. Defaults to inspecting whether *file* is a TTY. + indent: String prefix or number of spaces to indent the output. """ msg = msg or '' if not isinstance(msg, (str, bytes)): msg = str(msg) - is_err = kw.pop('err', False) - _file = kw.pop('file', sys.stdout if not is_err else sys.stderr) - end = kw.pop('end', None) - enable_color = kw.pop('color', None) - indent = kw.pop('indent', '') + + _file = file or (sys.stderr if err else sys.stdout) + enable_color = color space: str = ' ' if isinstance(indent, int): indent = space * indent @@ -314,7 +320,7 @@ def echo(msg, **kw): enable_color = not should_strip_ansi(_file) if end is None: - if kw.pop('nl', True): + if nl: end = '\n' if isinstance(msg, str) else b'\n' if end: msg += end