Skip to content

Commit

Permalink
refactor: rewrite arger using new docstring parser
Browse files Browse the repository at this point in the history
  • Loading branch information
jnoortheen committed Nov 1, 2020
1 parent dcd47b0 commit 9cd93a5
Show file tree
Hide file tree
Showing 19 changed files with 132 additions and 179 deletions.
13 changes: 0 additions & 13 deletions .isort.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,4 @@

profile = black

multi_line_output = 3

known_standard_library = dataclasses,typing_extensions
known_third_party = click,log
known_first_party = arger

combine_as_imports = true
force_grid_wrap = false
include_trailing_comma = true

lines_after_imports = 2
line_length = 88

use_parentheses=True
3 changes: 1 addition & 2 deletions arger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from pkg_resources import DistributionNotFound, get_distribution

from .arger import Arger
from .main import Arger
from .parser.classes import Argument, Option


try:
__version__ = get_distribution("arger").version
except DistributionNotFound:
Expand Down
50 changes: 23 additions & 27 deletions arger/arger.py → arger/main.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,54 @@
# pylint: disable = W0212 ; protected member

import argparse as ap
import sys
from argparse import ArgumentParser
from typing import Any, Callable, Dict, Optional
import typing as tp

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


def _add_args(parser, args: Dict[str, Option]):
def _add_args(parser, args: tp.Dict[str, Argument]):
for _, arg in args.items():
arg.add(parser)


def _cmd_prepare(parser, cmd: Command):
cmd_parser = parser.add_parser(name=cmd.name, help=cmd.desc)
_add_args(cmd_parser, cmd.args)
def _cmd_prepare(parser: ap._SubParsersAction, cmd: Command) -> ap.ArgumentParser:
cmd_parser = parser.add_parser(
name=cmd.name, help=cmd.docs.description if cmd.docs else ''
)
cmd_parser.set_defaults(func=cmd.callback)
_add_args(cmd_parser, cmd.docs.args if cmd.docs else {})
return cmd_parser


CMD = "command"
CMD_TITLE = "commands"


def _add_parsers(parser: "Arger", cmd: Command):
def _add_parsers(parser: tp.Union["Arger", ap.ArgumentParser], cmd: Command):
commands = list(cmd)
if commands:
subparser = parser.add_subparsers(
title=CMD_TITLE, # cmd.name
dest=CMD, # cmd.name,
# action=CommandAction,
# description=cmd.desc,
# type=cmd.callback,
)
for _, sub in commands:
cmd_parser = _cmd_prepare(subparser, sub)
_add_parsers(cmd_parser, sub) # recursively add any nested commands


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

def __init__(self, fn: Optional[F] = None, **kwargs):
def __init__(self, fn: tp.Optional[F] = None, **kwargs):
self._command = Command(fn)
if self._command.desc:
kwargs.setdefault("description", self._command.desc)
if self._command.docs and self._command.docs.description:
kwargs.setdefault("description", self._command.docs.description)

super().__init__(**kwargs)

if fn: # lazily add arguments
_add_args(self, self._command.args)
_add_args(self, self._command.docs.args)

def run(self, *args, capture_sys=True) -> Any:
def run(self, *args, capture_sys=True) -> tp.Dict[str, tp.Any]:
"""Parse cli and dispatch functions.
Args:
Expand All @@ -64,12 +62,10 @@ def run(self, *args, capture_sys=True) -> Any:

if not args and capture_sys:
args = tuple(sys.argv[1:])
kwargs = vars(self.parse_args(args)) # type: Dict[str, Any]

return self._command.run(**kwargs)
return vars(self.parse_args(args))

@classmethod
def init(cls, **kwargs) -> Callable[[Callable], "Arger"]:
def init(cls, **kwargs) -> tp.Callable[[tp.Callable], "Arger"]:
"""Create parser from function as a decorator.
Args:
Expand Down
2 changes: 1 addition & 1 deletion arger/parser/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .funcs import opterate
from .funcs import parse_function
2 changes: 1 addition & 1 deletion arger/parser/actions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import argparse
from typing import Any, Callable, Sequence, Text, Tuple, Type, Union
from typing import Any, Tuple, Union

from ..types import UNDEFINED, VarArg, VarKw
from ..typing_utils import (
Expand Down
24 changes: 7 additions & 17 deletions arger/parser/classes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# pylint: disable = W0221
import argparse
import inspect
from typing import Any, Tuple
from typing import Any, Optional, Tuple

from arger.parser.utils import generate_flags
from arger.parser.utils import FlagsGenerator

from ..types import UNDEFINED
from .actions import TypeAction
Expand All @@ -24,7 +23,7 @@ def __init__(
):
"""Represent optional arguments to the command.
Args:
Keyword Args:
type (Any): The type to which the command-line argument should be converted. Got from annotation.
help (str): A brief description of what the argument does. From docstring.
Expand All @@ -45,13 +44,6 @@ def __init__(
def add(self, parser: argparse.ArgumentParser):
return parser.add_argument(*self.flags, **self.kwargs)

@property
def _kwargs_repr_(self):
return {
k: f"`{val.__name__}" if inspect.isclass(val) else val
for k, val in self.kwargs.items()
}

def __repr__(self):
"""helps during tests"""
return f"<{self.__class__.__name__}: {self.flags}, {repr(self.kwargs)}>"
Expand Down Expand Up @@ -86,13 +78,12 @@ def __init__(self, *flags: str, **kwargs):
super().__init__(**kwargs)
self.flags = flags

def update_flags(self, name: str, option_generator: Any = None):
def update_flags(
self, name: str, option_generator: Optional[FlagsGenerator] = None
):
self.kwargs.setdefault('dest', name)
if not self.flags and option_generator is not None:
hlp = self.kwargs.pop("help").split()
# generate flags
self.flags = tuple(generate_flags(name, hlp, option_generator))
self.kwargs["help"] = " ".join(hlp)
self.flags = tuple(option_generator.generate(name))

def update(self, tp: Any = UNDEFINED, default: Any = UNDEFINED, **_):
"""Update type and default externally"""
Expand All @@ -105,7 +96,6 @@ def update(self, tp: Any = UNDEFINED, default: Any = UNDEFINED, **_):
"store_true" if default is False else "store_false"
)
tp = self.kwargs.pop('type', UNDEFINED)

elif default is not None and tp is UNDEFINED:
tp = type(default)
super().update(tp)
33 changes: 19 additions & 14 deletions arger/parser/docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ def get_flags_from_param_doc(doc: str, flag_symbol='-') -> Tuple[List[str], str]
Examples:
''':param arg1: -a --arg this is the document'''
"""
doc_parts = []
flags = []
doc_parts: List[str] = []
flags: List[str] = []
for part in doc.split():
if part.startswith(flag_symbol) and not doc_parts:
# strip both comma and empty space
Expand All @@ -42,6 +42,8 @@ class DocstringParser:
"""Abstract class"""

pattern: re.Pattern
section_ptrn: re.Pattern
param_ptrn: re.Pattern

def parse(self, doc: str) -> DocstringTp:
raise NotImplementedError
Expand All @@ -62,7 +64,7 @@ def __init__(self):
r'^(?P<param>\w+)[ \t]*:[ \t]*(?P<type>\w+)?'
) # matches parameter_name e.g. param1: or param2 (int):

def get_rest_of_section(self, params: str) -> (str, str):
def get_rest_of_section(self, params: str) -> Tuple[str, str]:
other_sect = self.section_ptrn.search(params)
if other_sect:
pos = other_sect.start()
Expand Down Expand Up @@ -98,7 +100,7 @@ class GoogleDocParser(NumpyDocParser):
`Example <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html>`_
"""

def __init__(self):
def __init__(self): # pylint: disable=super-init-not-called
self.pattern = re.compile(r'\s(Args|Arguments):\s')
self.section_ptrn = re.compile(r'\n(?P<section>[A-Z]\w+):\n+')
self.param_ptrn = re.compile(
Expand All @@ -116,24 +118,27 @@ def __init__(self):
self.section_ptrn = re.compile(r'\n:[\w]+') # matches any start of the section
self.param_ptrn = re.compile(r'^[ ]+(?P<tp_param>.+):[ ]*(?P<doc>[\s\S]+)')

def parse_doc(self, line: str, params: Dict[str, ParamDocTp]):
match = self.param_ptrn.match(line)
if match:
tp_param, doc = match.groups() # type: str, str
parts = tp_param.strip().rsplit(' ', maxsplit=1)
param = parts[-1].strip()
type_hint = None
if len(parts) > 1:
type_hint = parts[0].strip()
params[param] = ParamDocTp(type_hint, *get_flags_from_param_doc(doc))

def parse(self, doc: str) -> DocstringTp:
lines = self.pattern.split(doc)
long_desc = lines.pop(0)
epilog = ''
params = {}
params: Dict[str, ParamDocTp] = {}
for idx, lin in enumerate(lines):
sections = self.section_ptrn.split(lin, maxsplit=1)
if idx + 1 == len(lines) and len(sections) > 1:
epilog = sections[-1]
match = self.param_ptrn.match(sections[0])
if match:
tp_param, doc = match.groups() # type: str, str
parts = tp_param.strip().rsplit(' ', maxsplit=1)
param = parts[-1].strip()
type_hint = None
if len(parts) > 1:
type_hint = parts[0].strip()
params[param] = ParamDocTp(type_hint, *get_flags_from_param_doc(doc))
self.parse_doc(sections[0], params)

return DocstringTp(long_desc.strip(), epilog, params)

Expand Down
51 changes: 31 additions & 20 deletions arger/parser/funcs.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import inspect
from collections import OrderedDict, namedtuple
from collections import OrderedDict
from itertools import filterfalse, tee
from typing import Dict, Iterable, Tuple
from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple

from arger.parser.docstring import parse_docstring

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


Param = namedtuple("Param", ["name", "type", "help"])
class Param(NamedTuple):
name: str
type: str
help: Optional[str]
flags: List[str]


class ParsedFunc(NamedTuple):
description: str
epilog: str
args: Dict[str, Argument]


def partition(pred, iterable: Iterable[T]) -> Tuple[Iterable[T], Iterable[T]]:
'Use a predicate to partition entries into false entries and true entries'
"""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)
Expand All @@ -24,30 +34,34 @@ def get_val(val, default):
return default if val == inspect.Parameter.empty else val


def prepare_params(func, docs: Dict[str, str]):
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):
def get_param(param: inspect.Parameter) -> Param:
annot = get_val(param.annotation, UNDEFINED)
if param.kind == inspect.Parameter.VAR_POSITIONAL:
annot = VarArg(annot)
elif param.kind == inspect.Parameter.VAR_KEYWORD:
annot = VarKw(annot)

name = param.name
return Param(name, annot, docs.get(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],
)


def create_option(param: Param, default, option_generator):
def create_option(param: Param, default, option_generator: FlagsGenerator):
if isinstance(default, Option):
default.kwargs.setdefault('help', param.help)
default.update_flags(param.name, option_generator)
Expand All @@ -68,27 +82,24 @@ def create_option(param: Param, default, option_generator):
return option


def create_argument(param: Param):
def create_argument(param: Param) -> Argument:
arg = Argument(help=param.help)
arg.update_flags(param.name)
arg.update(tp=param.type)
return arg


def prepare_arguments(func, param_docs) -> Dict[str, Option]:
def parse_function(func: F) -> ParsedFunc:
"""Parse 'func' and adds parser arguments from function signature."""
positional_params, kw_params = prepare_params(func, param_docs)
option_generator = get_option_generator()

arguments: Dict[str, Option] = OrderedDict()
docstr, positional_params, kw_params = prepare_params(func)
option_generator = FlagsGenerator()

arguments: 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 arguments


def opterate(func) -> Tuple[str, Dict[str, Option]]:
description, param_docs = parse_docstring(func.__doc__)
return description, prepare_arguments(func, param_docs)
return ParsedFunc(docstr.description, docstr.epilog, arguments)

0 comments on commit 9cd93a5

Please sign in to comment.