Skip to content

Commit

Permalink
feat: use Option for populating arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
jnoortheen committed Apr 14, 2020
1 parent b8a45d1 commit aef46e7
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 193 deletions.
9 changes: 5 additions & 4 deletions arger/arger.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
from argparse import ArgumentParser
from typing import Any, Callable, Dict, Optional

from .parser.classes import Option
from .structs import Command
from .types import F


def _add_args(parser, args):
for flags, kw in args:
parser.add_argument(*flags, **kw)
def _add_args(parser, args: Dict[str, Option]):
for _, arg in args.items():
parser.add_argument(*arg.flags, **arg.kwargs)


def _cmd_prepare(parser, cmd: Command):
Expand Down Expand Up @@ -66,7 +67,7 @@ def run(self, *args, capture_sys=True) -> Any:

@classmethod
def init(cls, **kwargs) -> Callable[[Callable], 'Arger']:
"""Create parser from function as a decorator
"""Create parser from function as a decorator.
:param func: main function that has description and has sub-command level arguments
"""
Expand Down
101 changes: 101 additions & 0 deletions arger/parser/classes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import argparse
from enum import Enum
from inspect import isclass
from typing import Any, List, Optional, Tuple

from ..types import UNDEFINED
from ..typing_utils import match_types


def get_action(
_type, default=None,
):
if default is False:
return "store_true"
if default is True:
return "store_false"
if (_type is not UNDEFINED) and match_types(_type, (List, Tuple)): # type: ignore
return "append"
return "store"


class Option:
def __init__(
self,
flags: Tuple[str, ...] = (),
type_: Any = UNDEFINED,
default: Any = UNDEFINED,
help_: str = "",
metavar: Optional[str] = None,
required=False,
**kwargs,
):
"""Represent optional arguments to the command.
Tries to be compatible to `ArgumentParser.add_argument
https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument`_.
:param flags: Either a name or a list of option strings, e.g. -f, --foo.
:param default: The value produced if the argument is absent from the command line.
* The default value assigned to a keyword argument helps determine
the type of option and action.
* The default value is assigned directly to the parser's default for that option.
* In addition, it determines the ArgumentParser action
* a default value of False implies store_true, while True implies store_false.
* If the default value is a list, the action is append
(multiple instances of that option are permitted).
* Strings or None imply a store action.
:param type_: The type to which the command-line argument should be converted.
:param help_: A brief description of what the argument does.
:param metavar: A name for the argument in usage messages.
:param required: Whether or not the command-line option may be omitted (optionals only).
:param kwargs: will be passed onto parser.add_argument
"""

# :param nargs: The number of command-line arguments that should be consumed.
# nargs: to be generated from the type

# action: Union[str, Type[argparse.Action]]
# :param action: The basic type of action to be taken when this argument is encountered at the command line.

# :param dest: The name of the attribute to be added to the object returned by parse_args().

# :param const: A constant value required by some action and nargs selections.
# will be covered by type-hint and default value given

# :param choices: A container of the allowable values for the argument.
# will covered by enum type

self.flags = flags

if default is not UNDEFINED:
kwargs["default"] = default

if type_ is UNDEFINED and default is not None:
type_ = type(default)

kwargs.setdefault('action', get_action(type_, default))

if isclass(type_) and issubclass(type_, Enum):
kwargs.setdefault("choices", [e.value for e in type_])
elif (type_ is not UNDEFINED) and type_ != bool:
kwargs.setdefault("type", type_)

kwargs.setdefault('help', help_)
kwargs.setdefault('metavar', metavar)
kwargs.setdefault('required', required)
self.kwargs = kwargs

def add(self, parser: argparse.ArgumentParser):
return parser.add_argument(*self.flags, **self.kwargs)


class Argument(Option):
"""Represent positional argument that are required."""

def __init__(
self, **kwargs,
):
"""See Option.__init__'s doc for more info."""
super().__init__(required=True, **kwargs)
144 changes: 23 additions & 121 deletions arger/parser/parser.py
Original file line number Diff line number Diff line change
@@ -1,117 +1,15 @@
# most of this module is taken from https://github.com/dusty-phillips/opterator
from enum import Enum
from inspect import isclass
from typing import Any, List, Set, Tuple
from collections import OrderedDict
from typing import Dict, Tuple

from arger.parser.docstring import parse_docstring
from arger.typing_utils import match_types
from arger.utils import portable_argspec

from ..types import UNDEFINED
from .classes import Argument, Option
from .utils import generate_flags, generate_options

def generate_options():
"""Coroutine to identify short options that haven't been used yet.

Yields lists of short option (if available) and long option for
the given name, keeping track of which short options have been previously
used.
If you aren't familiar with coroutines, use similar to a generator:
x = generate_options()
next(x) # advance coroutine past its initialization code
params = x.send(param_name)
"""
used_short_options: Set[str] = set()
param_name = yield
while True:
names = ["--" + param_name]
for letter in param_name:
if letter not in used_short_options:
used_short_options.add(letter)
names.insert(0, "-" + letter)
break
param_name = yield names


def get_action(
_type, default=None,
):
if default is False:
return "store_true"
if default is True:
return "store_false"
if (_type is not UNDEFINED) and match_types(_type, (List, Tuple)): # type: ignore
return "append"
return "store"


UNDEFINED = object()
"""sometimes the value could be None. we need this to distinguish such values."""


def get_arg_names(
param, param_doc, option_generator,
):
names = []

while param_doc and param_doc[0].startswith("-"):
names.append(param_doc.pop(0))

return names if names else option_generator.send(param)


def add_param(
param: str,
param_docs: dict,
_type: Any = UNDEFINED,
default: Any = UNDEFINED,
option_generator=None,
):
"""Add each function's parameter to parser.
:param param:
:param param_docs:
:param _type:
:param default:
* The default value assigned to a keyword argument helps determine
the type of option and action.
* The default value is assigned directly to the parser's default for that option.
* In addition, it determines the ArgumentParser action
* a default value of False implies store_true, while True implies store_false.
* If the default value is a list, the action is append
(multiple instances of that option are permitted).
* Strings or None imply a store action.
:param option_generator: create shorthand options from the function names
:returns parser
"""

param_doc = (param_docs.get(param) or "").split()
option_kwargs = {}

names = [param]
if default is not UNDEFINED:
option_kwargs["default"] = default
option_kwargs["dest"] = param
names = get_arg_names(param, param_doc, option_generator)

if _type is UNDEFINED and default is not None:
_type = type(default)

option_kwargs.update(
{"action": get_action(_type, default), "help": " ".join(param_doc)}
)

if isclass(_type) and issubclass(_type, Enum):
option_kwargs["choices"] = [e.value for e in _type]
elif (_type is not UNDEFINED) and _type != bool:
option_kwargs["type"] = _type

return (names, option_kwargs)


def prepare_arguments(
func, param_docs,
):
def prepare_arguments(func, param_docs,) -> Dict[str, Option]:
"""Parse 'func' and adds parser arguments from function signature.
:param func: Function's signature is used to create parser
Expand All @@ -136,33 +34,37 @@ def prepare_arguments(
:param variable_name: -v the help_text no long option
:param variable_name: --verbose the help_text no short option
* Variable_name is the name of the variable in the function specification and
* variable_name is the name of the variable in the function specification and
must refer to a keyword argument. All options must have a :param: line like
this.
"""
(positional_params, kw_params, annotations) = portable_argspec(func)
option_generator = generate_options()
next(option_generator)

arguments = []
arguments: Dict[str, Option] = OrderedDict()
for param in positional_params:
arguments.append(
add_param(param, param_docs, _type=annotations.get(param, UNDEFINED))
arguments[param] = Argument(
help_=param_docs.get(param, ""),
flags=[param],
dest=param,
type_=annotations.get(param, UNDEFINED),
)

for param, default in kw_params.items():
arguments.append(
add_param(
param,
param_docs,
_type=annotations.get(param, UNDEFINED),
default=default,
option_generator=option_generator,
)
hlp = param_docs.get(param, "")
flags = generate_flags(param, hlp, option_generator)
hlp = " ".join(hlp)
arguments[param] = Option(
help_=hlp,
flags=flags,
dest=param,
type_=annotations.get(param, UNDEFINED),
default=default,
)
return arguments


def opterate(func):
def opterate(func) -> Tuple[str, Dict[str, Option]]:
description, param_docs = parse_docstring(func.__doc__)
return description, prepare_arguments(func, param_docs)
35 changes: 35 additions & 0 deletions arger/parser/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import Set


def generate_options():
"""Coroutine to identify short options that haven't been used yet.
Yields lists of short option (if available) and long option for
the given name, keeping track of which short options have been previously
used.
If you aren't familiar with coroutines, use similar to a generator:
x = generate_options()
next(x) # advance coroutine past its initialization code
params = x.send(param_name)
"""
used_short_options: Set[str] = set()
param_name = yield
while True:
names = ["--" + param_name]
for letter in param_name:
if letter not in used_short_options:
used_short_options.add(letter)
names.insert(0, "-" + letter)
break
param_name = yield names


def generate_flags(
param, param_doc, option_generator,
):
names = []

while param_doc and param_doc[0].startswith("-"):
names.append(param_doc.pop(0))

return names if names else option_generator.send(param)

0 comments on commit aef46e7

Please sign in to comment.