Skip to content

Commit

Permalink
feat: create subcommands as soon as Arger initiated
Browse files Browse the repository at this point in the history
  • Loading branch information
jnoortheen committed Nov 3, 2020
1 parent 7e2f20a commit 9848e4e
Show file tree
Hide file tree
Showing 10 changed files with 100 additions and 117 deletions.
109 changes: 58 additions & 51 deletions arger/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,78 +5,73 @@
import sys
import typing as tp

from .structs import Command
from .typing_utils import F
from .parser.funcs import ParsedFunc, parse_function
from .types import VarArg

CMD_TITLE = "commands"
LEVEL = '__level__'
FUNC_PREFIX = '_func_'
FUNC_PREFIX = '__func_'
NS_PREFIX = '_namespace_'


def _add_args(parser, cmd: Command, level: int):
parser.set_defaults(**{f'{FUNC_PREFIX}{level}': cmd.callback, LEVEL: level})
for arg_name, arg in cmd.docs.args.items():
if arg_name.startswith('_'):
continue
arg.add(parser)


def _cmd_prepare(
parser: ap._SubParsersAction, cmd: Command, level: int
) -> ap.ArgumentParser:
cmd_parser = parser.add_parser(
name=cmd.name, help=cmd.docs.description if cmd.docs else ''
)
_add_args(cmd_parser, cmd, level)
return cmd_parser


def _add_parsers(parser: tp.Union["Arger", ap.ArgumentParser], cmd: Command):
commands = list(cmd)
if commands:
subparser = parser.add_subparsers(
# action=CommandAction,
title=CMD_TITLE,
)
for _, sub in commands:
level = (parser.get_default(LEVEL) or 0) + 1
cmd_parser = _cmd_prepare(subparser, sub, level=level)
_add_parsers(cmd_parser, sub) # recursively add any nested commands


class Arger(ap.ArgumentParser):
"""Contains one (parser) or more commands (subparsers)."""

def __init__(self, fn: tp.Optional[F] = None, **kwargs):
def __init__(
self,
func: tp.Optional[tp.Callable] = None,
_parsed_fn: tp.Optional[ParsedFunc] = None, # passed from subparser action
_level=0, # passed from subparser action
**kwargs,
):
"""
Args:
fn: A callable to parse root parser's arguments.
func: A callable to parse root parser's arguments.
**kwargs: all the arguments that are supported by `ArgumentParser`
"""
kwargs.setdefault('formatter_class', ap.ArgumentDefaultsHelpFormatter)
self._command = Command(fn)
if self._command.docs.description:
kwargs.setdefault("description", self._command.docs.description)

self.sub_parser_action: tp.Optional[ap._SubParsersAction] = None
self.func = parse_function(func) if _parsed_fn is None else _parsed_fn

if self.func.description:
kwargs.setdefault("description", self.func.description)

super().__init__(**kwargs)

if fn: # lazily add arguments
_add_args(self, self._command, level=0)
self._add_args(_level)

def _add_args(self, level: int):
self.set_defaults(**{f'{FUNC_PREFIX}{level}': self.dispatch, LEVEL: level})

for arg_name, arg in self.func.args.items():
if arg_name.startswith(
'_'
): # useful only when `_namespace_` is requested or it is a kwarg
continue
arg.add(self)

def dispatch(self, ns: ap.Namespace) -> tp.Any:
if self.func.fn:
kwargs = {}
args = []
for arg_name, arg_type in self.func.args.items():
val = getattr(ns, arg_name)
if isinstance(arg_type.kwargs.get('type'), VarArg):
args = val
else:
kwargs[arg_name] = val
# todo: use inspect.signature.bind to bind kwargs and args to respective names
return self.func.fn(*args, **kwargs)
return None

def run(self, *args: str, capture_sys=True) -> ap.Namespace:
"""Parse cli and dispatch functions.
Args:
*args: The arguments will be passed onto self.parse_args
"""
if not self._command.is_valid():
raise NotImplementedError("No function to dispatch.")

# populate sub-parsers
_add_parsers(self, self._command)

if not args and capture_sys:
args = tuple(sys.argv[1:])
namespace = self.parse_args(args)
Expand All @@ -98,15 +93,27 @@ def init(cls, **kwargs) -> tp.Callable[[tp.Callable], "Arger"]:
func: main function that has description and has sub-command level arguments
"""

def _wrapper(fn):
return cls(fn, **kwargs)
def _wrapper(fn: tp.Callable):
return cls(func=fn, **kwargs)

return _wrapper

def add_cmd(self, func: F) -> Command:
def add_cmd(self, func: tp.Callable) -> ap.ArgumentParser:
"""Decorate the function as a sub-command.
Args:
func: function
"""
return self._command.add(func)
if not self.sub_parser_action:
self.sub_parser_action = self.add_subparsers(
# action=CommandAction,
title=CMD_TITLE,
)

parsed_fn = parse_function(func)
return self.sub_parser_action.add_parser(
name=func.__name__,
help=parsed_fn.description,
_parsed_fn=parsed_fn,
_level=self.get_default(LEVEL) + 1,
)
5 changes: 2 additions & 3 deletions arger/parser/classes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# pylint: disable = W0221
from argparse import Action, ArgumentParser, FileType
from typing import Any, Callable, Optional, Text, Tuple, Union
from typing import Any, Callable, Optional, Tuple, Union

from arger.parser.utils import FlagsGenerator

from ..typing_utils import UNDEFINED, T
from .actions import TypeAction

ARG_TYPE = Union[Callable[[Text], T], Callable[[str], T], FileType]
ARG_TYPE = Union[Callable[[str], T], FileType]


class Argument:
Expand All @@ -29,7 +29,6 @@ def __init__(
nargs (Union[int, str]): The number of command-line arguments that should be consumed.
to be generated from the type-hint.
dest (str): The name of the attribute to be added to the object returned by parse_args().
const (Any): covered by type-hint and default value given
choices (Iterable[str]): covered by enum type
action (Union[str, Type[Action]]): The basic type of action to be taken
Expand Down
15 changes: 9 additions & 6 deletions arger/parser/funcs.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import inspect
from collections import OrderedDict
from itertools import filterfalse, tee
from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple
from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Tuple

from arger.parser.docstring import parse_docstring

from ..types import VarArg, VarKw
from ..typing_utils import UNDEFINED, F, T
from ..typing_utils import UNDEFINED, T
from .classes import Argument, Option
from .utils import FlagsGenerator

Expand All @@ -19,9 +19,10 @@ class Param(NamedTuple):


class ParsedFunc(NamedTuple):
description: str
epilog: str
args: Dict[str, Argument]
fn: Optional[Callable] = None
description: str = ''
epilog: str = ''


def partition(pred, iterable: Iterable[T]) -> Tuple[Iterable[T], Iterable[T]]:
Expand Down Expand Up @@ -90,8 +91,10 @@ def create_argument(param: Param) -> Argument:
return arg


def parse_function(func: F) -> ParsedFunc:
def parse_function(func: Optional[Callable]) -> ParsedFunc:
"""Parse 'func' and adds parser arguments from function signature."""
if func is None:
return ParsedFunc({})

docstr, positional_params, kw_params = prepare_params(func)
option_generator = FlagsGenerator()
Expand All @@ -103,4 +106,4 @@ def parse_function(func: F) -> ParsedFunc:
for param, default in kw_params:
arguments[param.name] = create_option(param, default, option_generator)

return ParsedFunc(docstr.description, docstr.epilog, arguments)
return ParsedFunc(arguments, func, docstr.description, docstr.epilog)
48 changes: 0 additions & 48 deletions arger/structs.py

This file was deleted.

3 changes: 1 addition & 2 deletions arger/typing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import sys
from enum import Enum
from inspect import isclass
from typing import Any, Callable, FrozenSet, List, Set, Tuple, TypeVar
from typing import Any, FrozenSet, List, Set, Tuple, TypeVar

NEW_TYPING = sys.version_info[:3] >= (3, 7, 0) # PEP 560

Expand Down Expand Up @@ -109,7 +109,6 @@ def cast(tp, val) -> Any:
return origin(val)


F = TypeVar("F", bound=Callable[..., Any]) # decorator
T = TypeVar('T')


Expand Down
4 changes: 2 additions & 2 deletions docs/examples/1-single-function.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@
},
{
"cell_type": "code",
"execution_count": 18,
"execution_count": 2,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"_namespace_ (<class 'argparse.Namespace'>): [('__level__', 0), ('_func_0', <bound method Command.callback of <Command: main>), ('kw1', None), ('kw2', False), ('param1', 10), ('param2', 'p2')]\r\n",
"_namespace_ (<class 'argparse.Namespace'>): [('__func_0', 'dispatch'), ('__level__', 0), ('kw1', None), ('kw2', False), ('param1', 10), ('param2', 'p2')]\r\n",
"kw1 (<class 'NoneType'>): None\r\n",
"kw2 (<class 'bool'>): False\r\n",
"param1 (<class 'int'>): 10\r\n",
Expand Down
8 changes: 7 additions & 1 deletion docs/examples/2-multi-functions.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 1,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"usage: pytest create [-h] name\r\n",
"\r\n",
"Create new test.\r\n",
"\r\n",
"positional arguments:\r\n",
" name Name of the test\r\n",
"\r\n",
Expand Down Expand Up @@ -85,6 +87,8 @@
"text": [
"usage: pytest remove [-h] [name [name ...]]\r\n",
"\r\n",
"Remove a test with variadic argument.\r\n",
"\r\n",
"positional arguments:\r\n",
" name tests to remove (default: None)\r\n",
"\r\n",
Expand Down Expand Up @@ -114,6 +118,8 @@
"text": [
"usage: pytest list [-h]\r\n",
"\r\n",
"List all tests.\r\n",
"\r\n",
"optional arguments:\r\n",
" -h, --help show this help message and exit\r\n"
]
Expand Down
12 changes: 9 additions & 3 deletions docs/examples/3-sub-commands.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": 1,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"usage: pytest create [-h] name\r\n",
"\r\n",
"Create new test.\r\n",
"\r\n",
"positional arguments:\r\n",
" name Name of the test\r\n",
"\r\n",
Expand All @@ -81,14 +83,16 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 2,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"usage: pytest remove [-h] name\r\n",
"\r\n",
"Remove a test.\r\n",
"\r\n",
"positional arguments:\r\n",
" name Name of the test\r\n",
"\r\n",
Expand All @@ -110,14 +114,16 @@
},
{
"cell_type": "code",
"execution_count": 11,
"execution_count": 3,
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"usage: pytest list [-h]\r\n",
"\r\n",
"List all tests.\r\n",
"\r\n",
"optional arguments:\r\n",
" -h, --help show this help message and exit\r\n"
]
Expand Down

0 comments on commit 9848e4e

Please sign in to comment.