Skip to content

Commit

Permalink
Autoparse now accepts any callable as an annotation
Browse files Browse the repository at this point in the history
- Added support for any callable object as a type annotation, not just `type` objects
- Added tests for that functionality
- Updated docs to reflect functionality
  • Loading branch information
Lucretiel committed Sep 7, 2016
1 parent bd21fdb commit 59397b3
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 21 deletions.
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,9 @@ If the function is called with no arguments, ``sys.argv[1:]`` is used. This is t
Exceptions and limitations
--------------------------

- There are a few possible exceptions that ``autocommand`` can raise. All of them derive from ``autocommand.AutocommandError``, which is a ``TypeError``.
- There are a few possible exceptions that ``autocommand`` can raise. All of them derive from ``autocommand.AutocommandError``

- If an invalid annotation is given (that is, it isn't a ``type``, ``str``, ``(type, str)``, or ``(str, type)``, an ``AnnotationError`` is raised
- If an invalid annotation is given (that is, it isn't a ``type``, ``str``, ``(type, str)``, or ``(str, type)``, an ``AnnotationError`` is raised. The ``type`` may be any callable, as described in the `Types`_ section.
- If the function has a ``**kwargs`` parameter, a ``KWargError`` is raised.
- If, somehow, the function has a positional-only parameter, a ``PositionalArgError`` is raised. This means that the argument doesn't have a name, which is currently not possible with a plain ``def`` or ``lambda``, though many built-in functions have this kind of parameter.

Expand Down
16 changes: 9 additions & 7 deletions src/autocommand/autoparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,29 @@
_empty = Parameter.empty


class AnnotationError(AutocommandError, TypeError):
class AnnotationError(AutocommandError):
'''Annotation error: annotation must be a string, type, or tuple of both'''


class PositionalArgError(AutocommandError, TypeError):
class PositionalArgError(AutocommandError):
'''
Postional Arg Error: autocommand can't handle postional-only parameters
'''


class KWArgError(AutocommandError, TypeError):
class KWArgError(AutocommandError):
'''kwarg Error: autocommand can't handle a **kwargs parameter'''


def _get_type_description(annotation):
'''
Given an annotation, return the (type, description) for the parameter
Given an annotation, return the (type, description) for the parameter.
If you provide an annotation that is somehow both a string and a callable,
the behavior is undefined.
'''
if annotation is _empty:
return None, None
elif isinstance(annotation, type):
elif callable(annotation):
return annotation, None
elif isinstance(annotation, str):
return None, annotation
Expand All @@ -57,9 +59,9 @@ def _get_type_description(annotation):
except ValueError as e:
raise AnnotationError(annotation) from e
else:
if isinstance(arg1, type) and isinstance(arg2, str):
if callable(arg1) and isinstance(arg2, str):
return arg1, arg2
elif isinstance(arg1, str) and isinstance(arg2, type):
elif isinstance(arg1, str) and callable(arg2):
return arg2, arg1

raise AnnotationError(annotation)
Expand Down
43 changes: 38 additions & 5 deletions test/test_autoparse/conftest.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,60 @@
import pytest
from inspect import signature
import pytest
from autocommand.autoparse import make_parser


# TODO: This doesn't really need to be a fixture; replace it with a normal
# helper
@pytest.fixture(scope='session')
def check_parse():
def check_parse_impl(function, *args, add_nos=False, **kwargs):
'''
Helper for generalized parser testing. This function takes a function,
a set of positional arguments, and a set of keyword argumnets. It
creates an argparse parser using `autocommand.autoparse:make_parser` on
the signature of the provided function. It then parses the positional
arguments using this parser, and asserts that the returned set of
parsed arguments matches the given keyword arguments exactly.
Arguments:
- function: The function to generate a parser for
- *args: The set of positional arguments to pass to the generated
parser
- add_nos: If True, "-no-" versions of the option flags will be
created,
as per the `autoparse` docs.
- **kwargs: The set of parsed argument values to check for.
'''
parser = make_parser(
func_sig=signature(function),
description=None,
epilog=None,
add_nos=add_nos)

parsed_args = parser.parse_args(args)
for key, value in kwargs.items():
assert getattr(parsed_args, key) == value
parsed_args = vars(parser.parse_args(args))
assert parsed_args == kwargs

return check_parse_impl


# This is ostensibly session scope, but I don't know how capsys works
@pytest.fixture
def check_help_text(capsys):
def check_help_text_impl(func, *texts):
'''
This helper checks that some set of text is written to stdout or stderr
after the called function raises a SystemExit. It is used to test that
the underlying ArgumentParser was correctly configured to output a
given set of help text(s).
func: This should be a wrapped autoparse function that causes a
SystemExit exception to be raised (most commonly a function with the
-h flag, or with an invalid set of positional arguments). This
Exception should be accompanied by some kind of printout from
argparse to stderr or stdout.
*texts: A set of strings to test for. All of the provided strings will
be checked for in the captured stdout/stderr using a standard
substring search.
'''
with pytest.raises(SystemExit):
func()

Expand All @@ -31,4 +63,5 @@ def check_help_text_impl(func, *texts):
# TODO: be wary of argparse's text formatting
for text in texts:
assert text in out or text in err

return check_help_text_impl
24 changes: 17 additions & 7 deletions test/test_autoparse/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,32 @@
from autocommand.autoparse import AnnotationError


def test_all_annotation_types(check_parse, check_help_text):
@pytest.mark.parametrize("type_object", [
int,
lambda value: "FACTORY({})".format(value)
])
def test_all_annotation_types(check_parse, check_help_text, type_object):
# type_object is either `int` or a factory function that converts "str" to
# "FACTORY(str)"
def func(
int_arg: int,
typed_arg: type_object,
note_arg: "note_arg description",
note_int: ("note_int description", int),
int_note: (int, "int_note description")): pass
note_type: ("note_type description", type_object),
type_note: (type_object, "type_note description")): pass

check_help_text(
lambda: check_parse(func, '-h'),
"note_arg description",
"note_int description",
"int_note description")
"note_type description",
"type_note description")

check_parse(
func,
"1", "2", "3", "4",
int_arg=1, note_arg="2", note_int=3, int_note=4)
typed_arg=type_object("1"),
note_arg="2",
note_type=type_object("3"),
type_note=type_object("4"))


@pytest.mark.parametrize('bad_annotation', [
Expand All @@ -30,6 +39,7 @@ def func(
(int, 'hello', 'world'), # TOO MANY THINGS
(int, int), # The wrong kinds of things
("hello", "world"), # Nope this is bad too
(lambda value: value, lambda value: value), # Too many lambdas
])
def test_bad_annotation(bad_annotation, check_parse):
def func(arg: bad_annotation): pass
Expand Down

0 comments on commit 59397b3

Please sign in to comment.