Skip to content

Commit 460c2ed

Browse files
acetylenEmil Tylen
andauthored
feat: get type information from function annotations (#47)
* Run tox for 3.7 and 3.8 * Replace getfullargspec with inspect.signature Using `inspect.Signature` objects instead of the named tuple returned by `getfullargspec()` allows more powerful manipulation of the function signature, like checking for annotations and the like. Since the `inspect.signature` function is missing from versions lower than 3.3, we also require `funcsigs` for those versions. * Read type information from function annotations When type information is not available in the docstring, check function annotations (python3 only) for types. Added tests for providing correct and incorrect types to function annotations. * fixed some variable misspellings * added mypy caches to gitignore * CI and dependency changes * Removed deprecated python3.4 from CI runs * Moved actions from install-26-deps.sh to setup.py * Added checks for different setuptools versions to setup.py * Type error in setup... * Fixed version number checks in setup * removed deprecated python 2.6 * Mixed up what packages are required where * Removed deprecated python versions from tox and CI * added venv to gitignore * Changed argument name everywhere * don't do duplicate signature calls * removed unnecessary argument * Update documentations * Add changelog * Fixed typo * fix warning about ABCs in newer python versions * updated readme badges and examples, and added more classifiers * Update CHANGELOG remove old version Co-authored-by: Emil Tylen <[email protected]>
1 parent 9867459 commit 460c2ed

File tree

12 files changed

+262
-115
lines changed

12 files changed

+262
-115
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ develop-eggs
2020
lib
2121
lib64
2222
__pycache__
23+
.mypy_cache
2324

2425
# Installer logs
2526
pip-log.txt
@@ -30,3 +31,4 @@ pip-log.txt
3031
nosetests.xml
3132
htmlcov
3233
.cache
34+
venv

.travis.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
language: python
22
python:
33
- "2.7"
4-
- "3.4"
54
- "3.5"
65
- "3.6"
6+
- "3.7"
7+
- "3.8"
78
- "pypy"
9+
- "pypy3"
810
install:
911
- pip install -U pip
1012
- pip install -e .
1113
- pip install -r test_requirements.pip
12-
- ./install-26-deps.sh
1314
script:
1415
- make tests
1516
- make cov

CHANGELOG

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
0.7.0 (Mar 11, 2020)
2+
--------------------
3+
4+
- Switch from inspect.getargspec to inspect.signature (@acetylen): #47
5+
- Add support for type annotations (@acetylen): #47
6+
- Add support for Python 3.7 and 3.8 (@acetylen): #47
7+
- Remove support for Python 2.6 and 3.4 (@acetylen): #47

README.rst

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,41 @@
11
mando: CLI interfaces for Humans!
22
=================================
33

4-
.. image:: https://img.shields.io/travis/rubik/mando/master.svg
4+
.. image:: https://img.shields.io/travis/rubik/mando
55
:alt: Travis-CI badge
66
:target: https://travis-ci.org/rubik/mando
77

8-
.. image:: https://img.shields.io/coveralls/rubik/mando/master.svg
8+
.. image:: https://img.shields.io/coveralls/rubik/mando
99
:alt: Coveralls badge
1010
:target: https://coveralls.io/r/rubik/mando
1111

12-
.. image:: https://img.shields.io/pypi/v/mando.svg
12+
.. image:: https://img.shields.io/pypi/implementation/mando?label=%20&logo=python&logoColor=white
13+
:alt: PyPI - Implementation
14+
15+
.. image:: https://img.shields.io/pypi/v/mando
1316
:alt: Latest release
1417
:target: https://pypi.python.org/pypi/mando
1518

16-
.. image:: https://img.shields.io/pypi/format/mando.svg
19+
.. image:: https://img.shields.io/pypi/l/mando
20+
:alt: PyPI - License
21+
:target: https://pypi.org/project/mando/
22+
23+
.. image:: https://img.shields.io/pypi/pyversions/mando
24+
:alt: PyPI - Python Version
25+
:target: https://pypi.org/project/mando/
26+
27+
.. image:: https://img.shields.io/pypi/format/mando
1728
:alt: Download format
1829
:target: http://pythonwheels.com/
1930

20-
.. image:: https://img.shields.io/pypi/l/mando.svg
21-
:alt: Mando license
22-
:target: https://pypi.python.org/pypi/mando/
2331

2432
mando is a wrapper around ``argparse``, and allows you to write complete CLI
2533
applications in seconds while maintaining all the flexibility.
2634

2735
Installation
2836
------------
2937

30-
Mando is tested across all Python versions from **Python 2.6** to **Python
31-
3.6** and also on **Pypy**. You can install it with Pip::
32-
38+
.. code-block:: console
3339
$ pip install mando
3440
3541
The problem
@@ -48,6 +54,7 @@ Quickstart
4854
4955
@command
5056
def echo(text, capitalize=False):
57+
'''Echo the given text.'''
5158
if capitalize:
5259
text = text.upper()
5360
print(text)
@@ -158,7 +165,40 @@ Amazed uh? Yes, mando got the short options and the help from the docstring!
158165
You can put much more in the docstring, and if that isn't enough, there's an
159166
``@arg`` decorator to customize the arguments that get passed to argparse.
160167

168+
169+
Type annotations
170+
----------------
171+
172+
mando understands Python 3-style type annotations and will warn the user if the
173+
arguments given to a command are of the wrong type.
174+
175+
.. code-block:: python
176+
from mando import command, main
177+
178+
179+
@command
180+
def duplicate(string, times: int):
181+
'''Duplicate text.
182+
183+
:param string: The text to duplicate.
184+
:param times: How many times to duplicate.'''
185+
186+
print(string * times)
187+
188+
189+
if __name__ == '__main__':
190+
main()
191+
192+
.. code-block:: console
193+
194+
$ python3 test.py duplicate "test " 5
195+
test test test test test
196+
$ python3 test.py duplicate "test " foo
197+
usage: test.py duplicate [-h] string times
198+
test.py duplicate: error: argument times: invalid int value: 'foo'
199+
200+
161201
Mando has lots of other options. For example, it supports different docstring
162-
styes (Sphinx, Google and NumPy), supports shell autocompletion via the
202+
styles (Sphinx, Google and NumPy), supports shell autocompletion via the
163203
``argcomplete`` package and supports custom format classes. For a complete
164204
documentation, visit https://mando.readthedocs.org/.

docs/index.rst

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ This example should showcase most of mando's features::
3030

3131

3232
@arg('maxdepth', metavar='<levels>')
33-
def find(path, pattern, maxdepth=None, P=False, D=None):
33+
def find(path, pattern, maxdepth: int = None, P=False, D=None):
3434
'''Mock some features of the GNU find command.
3535

3636
This is not at all a complete program, but a simple representation to
3737
showcase mando's coolest features.
3838

3939
:param path: The starting path.
4040
:param pattern: The pattern to look for.
41-
:param -d, --maxdepth <int>: Descend at most <levels>.
41+
:param -d, --maxdepth: Descend at most <levels>.
4242
:param -P: Do not follow symlinks.
4343
:param -D <debug-opt>: Debug option, print diagnostic information.'''
4444

@@ -54,10 +54,10 @@ This example should showcase most of mando's features::
5454
if __name__ == '__main__':
5555
main()
5656

57-
mando extracts information from your command's docstring. So you can document
58-
your code and create the CLI application at once! In the above example the
59-
Sphinx format is used, but mando does not force you to write ReST docstrings.
60-
Currently, it supports the following styles:
57+
mando extracts information from your command's signature and docstring, so you
58+
can document your code and create the CLI application at once! In the above
59+
example the Sphinx format is used, but mando does not force you to write
60+
ReST docstrings. Currently, it supports the following styles:
6161

6262
- Sphinx (the default one)
6363
- Google
@@ -143,6 +143,11 @@ let's check the program itself:
143143
$ python gnu.py find --maxdepth 4 .
144144
usage: gnu.py find [-h] [-d <levels>] [-P] [-D <debug-opt>] path pattern
145145
gnu.py find: error: too few arguments
146+
$ python gnu.py find -d "four" . filename
147+
usage: gnu.py find [-h] [-d <levels>] [-P] [-D <debug-opt>] path pattern
148+
gnu.py find: error: argument maxlevels: invalid int value: 'four'
149+
150+
146151
147152
Contents
148153
--------

docs/usage.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,40 @@ Actual usage::
233233
$ python types.py pow 5 8 -m 8
234234
1.0
235235
236+
Adding *type* in the signature
237+
------------------------------
238+
239+
If running Python 3, mando can use type annotations to convert argument types.
240+
Since type annotations can be any callable, this allows more flexibility than
241+
the hard-coded list of types permitted by the docstring method::
242+
from mando import command, main
243+
244+
# Note: don't actually do this.
245+
def double_int(n):
246+
return int(n) * 2
247+
248+
249+
@command
250+
def dup(string, times: double_int):
251+
"""
252+
Duplicate text.
253+
254+
:param string: The text to duplicate.
255+
:param times: How many times to duplicate.
256+
"""
257+
print(string * times)
258+
259+
260+
if __name__ == "__main__":
261+
main()
262+
263+
.. code-block:: console
264+
$ python3 test.py dup "test " 2
265+
test test test test
266+
$ python3 test.py dup "test " foo
267+
usage: test.py dup [-h] string times
268+
test.py dup: error: argument times: invalid double_int value: 'foo'
269+
236270
237271
Overriding arguments with ``@arg``
238272
----------------------------------

install-26-deps.sh

Lines changed: 0 additions & 6 deletions
This file was deleted.

mando/core.py

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,29 @@
22
ordinary Python functions into commands for the command line. It uses
33
:py:module:``argparse`` behind the scenes.'''
44

5-
import sys
6-
import inspect
75
import argparse
8-
try:
9-
getfullargspec = inspect.getfullargspec
10-
except AttributeError:
11-
getfullargspec = inspect.getargspec
12-
try:
13-
from itertools import izip_longest
14-
except ImportError: # pragma: no cover
15-
from itertools import zip_longest as izip_longest
6+
import inspect
7+
import sys
168

179
from mando.napoleon import Config, GoogleDocstring, NumpyDocstring
1810

1911
from mando.utils import (purify_doc, action_by_type, find_param_docs,
2012
split_doc, ensure_dashes, purify_kwargs)
13+
try:
14+
from inspect import signature
15+
except ImportError:
16+
from funcsigs import signature
2117

2218

2319
_POSITIONAL = type('_positional', (object,), {})
2420
_DISPATCH_TO = '_dispatch_to'
2521

2622

2723
class SubProgram(object):
28-
29-
def __init__(self, parser, argspecs):
24+
def __init__(self, parser, signatures):
3025
self.parser = parser
3126
self._subparsers = self.parser.add_subparsers()
32-
self._argspecs = argspecs
27+
self._signatures = signatures
3328

3429
@property
3530
def name(self):
@@ -51,7 +46,7 @@ def add_subprog(self, name, **kwd):
5146
# also always provide help= to fix missing entry in command list
5247
help = kwd.pop('help', "{} subcommand".format(name))
5348
prog = SubProgram(self._subparsers.add_parser(name, help=help, **kwd),
54-
self._argspecs)
49+
self._signatures)
5550
# do not attempt to overwrite existing attributes
5651
assert not hasattr(self, name), "Invalid sub-prog name: " + name
5752
setattr(self, name, prog)
@@ -88,15 +83,10 @@ def _generate_command(self, func, name=None, doctype='rest',
8883
:param func: The function to analyze.
8984
:param name: If given, a different name for the command. The default
9085
one is ``func.__name__``.'''
91-
func_name = func.__name__
92-
name = func_name if name is None else name
93-
argspec = getfullargspec(func)
94-
self._argspecs[func_name] = argspec
95-
argz = izip_longest(reversed(argspec.args),
96-
reversed(argspec.defaults or []),
97-
fillvalue=_POSITIONAL())
98-
argz = reversed(list(argz))
86+
87+
name = name or func.__name__
9988
doc = (inspect.getdoc(func) or '').strip() + '\n'
89+
10090
if doctype == 'numpy':
10191
config = Config(napoleon_google_docstring=False,
10292
napoleon_use_rtype=False)
@@ -115,8 +105,11 @@ def _generate_command(self, func, name=None, doctype='rest',
115105
help=cmd_help or None,
116106
description=cmd_desc or None,
117107
**kwargs)
118-
params = find_param_docs(doc)
119-
for a, kw in self._analyze_func(func, params, argz, argspec.varargs):
108+
109+
doc_params = find_param_docs(doc)
110+
self._signatures[func.__name__] = signature(func)
111+
112+
for a, kw in self._analyze_func(func, doc_params):
120113
completer = kw.pop('completer', None)
121114
arg = subparser.add_argument(*a, **purify_kwargs(kw))
122115
if completer is not None:
@@ -125,27 +118,39 @@ def _generate_command(self, func, name=None, doctype='rest',
125118
subparser.set_defaults(**{_DISPATCH_TO: func})
126119
return func
127120

128-
def _analyze_func(self, func, params, argz, varargs_name):
121+
def _analyze_func(self, func, doc_params):
129122
'''Analyze the given function, merging default arguments, overridden
130123
arguments (with @arg) and parameters extracted from the docstring.
131124
132125
:param func: The function to analyze.
133-
:param params: Parameters extracted from docstring.
134-
:param argz: A list of the form (arg, default), containing arguments
135-
and their default value.
136-
:param varargs_name: The name of the variable arguments, if present,
137-
otherwise ``None``.'''
138-
for arg, default in argz:
139-
override = getattr(func, '_argopts', {}).get(arg, ((), {}))
140-
yield merge(arg, default, override, *params.get(arg, ([], {})))
141-
if varargs_name is not None:
142-
kwargs = {'nargs': '*'}
143-
kwargs.update(params.get(varargs_name, (None, {}))[1])
144-
yield ([varargs_name], kwargs)
126+
:param doc_params: Parameters extracted from docstring.
127+
'''
145128

129+
# prevent unnecessary inspect calls
130+
sig = self._signatures.get(func.__name__) or signature(func)
131+
overrides = getattr(func, '_argopts', {})
132+
for name, param in sig.parameters.items():
146133

147-
class Program(SubProgram):
134+
if param.kind is param.VAR_POSITIONAL:
135+
kwargs = {'nargs': '*'}
136+
kwargs.update(doc_params.get(name, (None, {}))[1])
137+
yield ([name], kwargs)
138+
continue
139+
140+
default = param.default
141+
if default is sig.empty:
142+
default = _POSITIONAL()
148143

144+
opts, meta = doc_params.get(name, ([], {}))
145+
# check docstring for type first, then type annotation
146+
if meta.get('type') is None and param.annotation is not sig.empty:
147+
meta['type'] = param.annotation
148+
149+
override = overrides.get(name, ((), {}))
150+
yield merge(name, default, override, opts, meta)
151+
152+
153+
class Program(SubProgram):
149154
def __init__(self, prog=None, version=None, **kwargs):
150155
parser = argparse.ArgumentParser(prog, **kwargs)
151156
if version is not None:
@@ -180,12 +185,14 @@ def parse(self, args):
180185
self.parser.error("too few arguments")
181186

182187
command = arg_map.pop(_DISPATCH_TO)
183-
argspec = self._argspecs[command.__name__]
188+
sig = self._signatures[command.__name__]
184189
real_args = []
185-
for arg in argspec.args:
186-
real_args.append(arg_map.pop(arg))
187-
if arg_map and arg_map.get(argspec.varargs):
188-
real_args.extend(arg_map.pop(argspec.varargs))
190+
for name, arg in sig.parameters.items():
191+
if arg.kind is arg.VAR_POSITIONAL:
192+
if arg_map.get(name):
193+
real_args.extend(arg_map.pop(name))
194+
else:
195+
real_args.append(arg_map.pop(name))
189196
return command, real_args
190197

191198
def execute(self, args):

0 commit comments

Comments
 (0)