Skip to content

Commit

Permalink
refactor: replace namedtuple Param with inspect.Parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
jnoortheen committed Nov 3, 2020
1 parent 73e4f08 commit 545cb80
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 155 deletions.
6 changes: 3 additions & 3 deletions arger/docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

class ParamDocTp(tp.NamedTuple):
type_hint: tp.Any
flags: tp.List[str]
flags: tp.Tuple[str, ...]
doc: str

@classmethod
Expand All @@ -24,7 +24,7 @@ def init(cls, type_hint: tp.Any, doc: str, flag_symbol='-'):
else:
doc_parts.append(part)

return cls(type_hint, flags=flags, doc=" ".join(doc_parts))
return cls(type_hint, flags=tuple(flags), doc=" ".join(doc_parts))


class DocstringTp(tp.NamedTuple):
Expand Down Expand Up @@ -73,7 +73,7 @@ def parse_params(self, params: str) -> tp.Dict[str, ParamDocTp]:
if match:
result = match.groupdict()
doc = result.get('doc', '')
docs.append((result['param'], result['type'], doc))
docs.append([result['param'], result['type'], doc])
elif docs:
docs[-1][-1] += line

Expand Down
165 changes: 71 additions & 94 deletions arger/funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,12 @@
import typing as tp
from argparse import Action, ArgumentParser
from collections import OrderedDict
from itertools import filterfalse, tee
from typing import Any, Tuple, Union

from arger import typing_utils as tp_utils
from arger.docstring import parse_docstring
from arger.docstring import ParamDocTp, parse_docstring


class Param(tp.NamedTuple):
name: str
type: str
help: tp.Optional[str]
flags: tp.List[str]


def partition(
pred, iterable: tp.Iterable[tp_utils.T]
) -> tp.Tuple[tp.Iterable[tp_utils.T], tp.Iterable[tp_utils.T]]:
"""Use a predicate to partition entries into false entries and true entries"""
# partition(is_odd, range(10)) --> 0 2 4 6 8 and 1 3 5 7 9
t1, t2 = tee(iterable)
return filter(pred, t1), filterfalse(pred, t2)


def get_val(val, default):
return default if val == inspect.Parameter.empty else val


def prepare_params(func):
docstr = parse_docstring(inspect.getdoc(func))

sign = inspect.signature(func)

args, kwargs = partition(
lambda x: x.default == inspect.Parameter.empty, sign.parameters.values()
)

def get_param(param: inspect.Parameter) -> Param:
annot = get_val(param.annotation, tp_utils.UNDEFINED)
if param.kind == inspect.Parameter.VAR_POSITIONAL:
annot = tp_utils.VarArg(annot)
elif param.kind == inspect.Parameter.VAR_KEYWORD:
annot = tp_utils.VarKw(annot)

name = param.name
doc = docstr.params.get(name)
return Param(name, annot, doc.doc if doc else None, doc.flags if doc else [])

return (
docstr,
[get_param(param) for param in args],
[(get_param(param), param.default) for param in kwargs],
)
_EMPTY = inspect.Parameter.empty


class FlagsGenerator:
Expand Down Expand Up @@ -117,7 +71,7 @@ def set_dest(self, name: str, typ: tp.Any):

def update_type(self, typ: tp.Any):
"""Update type externally."""
if 'type' not in self.kwargs and typ is not tp_utils.UNDEFINED:
if 'type' not in self.kwargs and typ is not _EMPTY:
self.kwargs['type'] = typ


Expand Down Expand Up @@ -149,77 +103,93 @@ def set_flags(
self,
name: str,
typ: tp.Any,
default: tp.Any = tp_utils.UNDEFINED,
default: tp.Any = _EMPTY,
option_generator: tp.Optional[FlagsGenerator] = None,
):
self.kwargs.setdefault('dest', name)
if not self.flags and option_generator is not None:
self.flags = tuple(option_generator.generate(name))
self.update_default(typ, default)

def update_default(self, typ: tp.Any, default: tp.Any = tp_utils.UNDEFINED):
def update_default(self, typ: tp.Any, default: tp.Any = _EMPTY):
"""Update type and default externally"""
if default is not tp_utils.UNDEFINED and 'default' not in self.kwargs:
if default is not _EMPTY and 'default' not in self.kwargs:
self.kwargs["default"] = default
else:
default = self.kwargs["default"]

if isinstance(default, bool):
self.kwargs['action'] = "store_true" if default is False else "store_false"
typ = self.kwargs.pop('type', tp_utils.UNDEFINED)
elif default is not None and typ is tp_utils.UNDEFINED:
typ = self.kwargs.pop('type', _EMPTY)
elif default is not None and typ is _EMPTY:
typ = type(default)

self.update_type(typ)


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


def create_option(param: Param, default, option_generator: FlagsGenerator):
if isinstance(default, Option):
default.kwargs.setdefault('help', param.help)
default.set_flags(param.name, param.type, option_generator)
return default

if isinstance(default, Argument):
default.kwargs.setdefault('help', param.help)
default.set_dest(param.name, param.type)
return default

option = Option(help=param.help or "")
option.set_flags(param.name, param.type, default, option_generator)
def __init__(self, func: tp.Optional[tp.Callable]):
"""Parse 'func' and adds parser arguments from function signature."""
if func is None:
self.args = {}
return

docstr = parse_docstring(inspect.getdoc(func))

sign = inspect.signature(func)
self.fn = func
self.description = docstr.description
self.epilog = docstr.epilog

option_generator = FlagsGenerator()
self.args = OrderedDict()
for param in sign.parameters.values():
param_doc = docstr.params.get(param.name)
if param.default is param.empty:
# todo: handle VarArg
# if param.kind == inspect.Parameter.VAR_POSITIONAL:
# annot = tp_utils.VarArg(annot)
# elif param.kind == inspect.Parameter.VAR_KEYWORD:
# annot = tp_utils.VarKw(annot)
self.args[param.name] = create_argument(param, param_doc)
else:
self.args[param.name] = create_option(
param, param_doc, option_generator
)


def create_option(
param: inspect.Parameter,
pdoc: tp.Optional[ParamDocTp],
option_generator: FlagsGenerator,
):
hlp = pdoc.doc if pdoc else ""
if isinstance(param.default, Option):
param.default.kwargs.setdefault('help', hlp)
param.default.set_flags(param.name, param.annotation, option_generator)
return param.default

if isinstance(param.default, Argument):
param.default.kwargs.setdefault('help', hlp)
param.default.set_dest(param.name, param.annotation)
return param.default

option = Option(help=hlp)
option.set_flags(param.name, param.annotation, param.default, option_generator)
return option


def create_argument(param: Param) -> Argument:
arg = Argument(help=param.help or "")
arg.set_dest(param.name, param.type)
def create_argument(param: inspect.Parameter, doc: tp.Optional[ParamDocTp]) -> Argument:
arg = Argument(help=doc.doc if doc else "")
arg.set_dest(param.name, param.annotation)
return arg


def parse_function(func: tp.Optional[tp.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()

arguments: tp.Dict[str, Argument] = OrderedDict()
for param in positional_params:
arguments[param.name] = create_argument(param)

for param, default in kw_params:
arguments[param.name] = create_option(param, default, option_generator)

return ParsedFunc(arguments, func, docstr.description, docstr.epilog)


def get_nargs(typ: Any) -> Tuple[Any, Union[int, str]]:
inner = tp_utils.unpack_type(typ)
if (
Expand All @@ -238,9 +208,9 @@ class TypeAction(argparse.Action):
"""After the parse update the type of value"""

def __init__(self, *args, **kwargs):
typ = kwargs.pop("type", tp_utils.UNDEFINED)
typ = kwargs.pop("type", _EMPTY)
self.orig_type = typ
if typ is not tp_utils.UNDEFINED:
if typ is not _EMPTY:
origin = tp_utils.get_origin(typ)
if tp_utils.is_iterable(origin):
origin, kwargs["nargs"] = get_nargs(typ)
Expand All @@ -252,6 +222,13 @@ def __init__(self, *args, **kwargs):
kwargs["type"] = origin
super().__init__(*args, **kwargs)

def set_attr(self, namespace, vals):
if self.orig_type is _EMPTY:
val = vals
else:
val = tp_utils.cast(self.orig_type, vals)
setattr(namespace, self.dest, val)

def __call__(self, parser, namespace, values, option_string=None):
if tp_utils.is_iterable(self.orig_type):
items = getattr(namespace, self.dest, ()) or ()
Expand All @@ -260,4 +237,4 @@ def __call__(self, parser, namespace, values, option_string=None):
vals = items
else:
vals = values
setattr(namespace, self.dest, tp_utils.cast(self.orig_type, vals))
self.set_attr(namespace, vals)
6 changes: 3 additions & 3 deletions arger/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import sys
import typing as tp

from arger.funcs import ParsedFunc, parse_function
from arger.funcs import ParsedFunc

from .typing_utils import VarArg

Expand Down Expand Up @@ -41,7 +41,7 @@ def __init__(
kwargs.setdefault('formatter_class', ap.ArgumentDefaultsHelpFormatter)

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

if self.func.description:
kwargs.setdefault("description", self.func.description)
Expand Down Expand Up @@ -120,7 +120,7 @@ def add_cmd(self, func: tp.Callable) -> ap.ArgumentParser:
title=CMD_TITLE,
)

parsed_fn = parse_function(func)
parsed_fn = ParsedFunc(func)
return self.sub_parser_action.add_parser(
name=func.__name__,
help=parsed_fn.description,
Expand Down
10 changes: 0 additions & 10 deletions arger/typing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,6 @@ def cast(tp, val) -> Any:
T = TypeVar('T')


class _Undefined:
"""sometimes the value could be None. we need this to distinguish such values."""

def __repr__(self):
return 'UNDEFINED'


UNDEFINED = _Undefined() # singleton


class VarArg:
"""Represent variadic arguent."""

Expand Down
4 changes: 2 additions & 2 deletions scent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ class Options:
rerun_args = None

targets = [
(('make', 'test-all'), "Integration Tests", False),
# (('make', 'test-all'), "Integration Tests", False),
(('make', 'check'), "Static Analysis", False),
(('make', 'tox'), "Make sure that it works on all environments", False),
# (('make', 'tox'), "Make sure that it works on all environments", False),
]


Expand Down
Empty file.
47 changes: 47 additions & 0 deletions tests/test_args_opts/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import inspect

import pytest

from arger import Arger, Argument, Option
from arger.docstring import ParamDocTp
from arger.funcs import _EMPTY, FlagsGenerator, create_argument, create_option


@pytest.fixture
def param_doc(hlp=''):
return ParamDocTp.init('', hlp)


@pytest.fixture
def parameter(name, tp=_EMPTY, default=_EMPTY):
return inspect.Parameter(
name,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=tp,
default=default,
)


@pytest.fixture
def argument(parameter, param_doc) -> Argument:
return create_argument(parameter, param_doc)


@pytest.fixture
def add_arger():
def _add(argument):
par = Arger()
argument.add(par)
return par

return _add


@pytest.fixture
def gen_options():
return FlagsGenerator()


@pytest.fixture
def option(parameter, param_doc, gen_options) -> Option:
return create_option(parameter, param_doc, gen_options)

0 comments on commit 545cb80

Please sign in to comment.