Skip to content

Commit 00beb16

Browse files
authored
Merge pull request #96 from swansonk14/tapify
Tapify: run functions via command line arguments + Python 3.11
2 parents 333d1db + 3acf811 commit 00beb16

13 files changed

+695
-88
lines changed

.github/workflows/code-coverage.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ jobs:
66
run:
77
runs-on: ubuntu-latest
88
env:
9-
PYTHON: '3.10'
9+
PYTHON: '3.11'
1010
steps:
1111
- uses: actions/checkout@main
1212
- name: Setup Python
1313
uses: actions/setup-python@main
1414
with:
15-
python-version: '3.10'
15+
python-version: '3.11'
1616
- name: Generate coverage report
1717
run: |
1818
git config --global user.email "[email protected]"
@@ -24,7 +24,7 @@ jobs:
2424
python -m pip install pytest-cov
2525
pytest --cov=tap --cov-report=xml
2626
- name: Upload coverage to Codecov
27-
uses: codecov/codecov-action@v1
27+
uses: codecov/codecov-action@v3
2828
with:
2929
token: ${{ secrets.CODECOV_TOKEN }}
3030
files: ./coverage.xml

.github/workflows/tests.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ jobs:
1616
strategy:
1717
matrix:
1818
os: [ubuntu-latest, macos-latest, windows-latest]
19-
python-version: ['3.7', '3.8', '3.9', '3.10']
19+
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
2020

2121
steps:
22-
- uses: actions/checkout@v2
22+
- uses: actions/checkout@main
2323
- name: Set up Python ${{ matrix.python-version }}
24-
uses: actions/setup-python@v2
24+
uses: actions/setup-python@main
2525
with:
2626
python-version: ${{ matrix.python-version }}
2727
- name: Set temp directories on Windows

setup.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
package_data={'tap': ['py.typed']},
3030
install_requires=[
3131
'typing_extensions >= 3.7.4',
32-
'typing-inspect >= 0.7.1'
32+
'typing-inspect >= 0.7.1',
33+
'docstring-parser >= 0.15'
3334
],
3435
tests_require=['pytest'],
3536
classifiers=[
@@ -38,6 +39,7 @@
3839
'Programming Language :: Python :: 3.8',
3940
'Programming Language :: Python :: 3.9',
4041
'Programming Language :: Python :: 3.10',
42+
'Programming Language :: Python :: 3.11',
4143
'License :: OSI Approved :: MIT License',
4244
'Operating System :: OS Independent',
4345
"Typing :: Typed"

tap/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from argparse import ArgumentError, ArgumentTypeError
22
from tap._version import __version__
33
from tap.tap import Tap
4+
from tap.tapify import tapify
45

5-
__all__ = ['ArgumentError', 'ArgumentTypeError', 'Tap', '__version__']
6+
__all__ = ['ArgumentError', 'ArgumentTypeError', 'Tap', 'tapify', '__version__']

tap/_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
__all__ = ['__version__']
22

33
# major, minor, patch
4-
version_info = 1, 7, 3
4+
version_info = 1, 8, 0
55

66
# Nice string for the version
77
__version__ = '.'.join(map(str, version_info))

tap/tap.py

+12-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import json
2+
import sys
3+
import time
14
from argparse import ArgumentParser, ArgumentTypeError
25
from collections import OrderedDict
36
from copy import deepcopy
47
from functools import partial
5-
import json
68
from pathlib import Path
79
from pprint import pformat
810
from shlex import quote, split
9-
import sys
10-
import time
1111
from types import MethodType
1212
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, TypeVar, Union, get_type_hints
1313
from typing_inspect import is_literal_type
@@ -226,23 +226,23 @@ def _add_argument(self, *name_or_flags, **kwargs) -> None:
226226
loop = False
227227
types = get_args(var_type)
228228

229-
# Don't allow Tuple[()]
230-
if len(types) == 1 and types[0] == tuple():
231-
raise ArgumentTypeError('Empty Tuples (i.e. Tuple[()]) are not a valid Tap type '
232-
'because they have no arguments.')
233-
234229
# Handle Tuple[type, ...]
235230
if len(types) == 2 and types[1] == Ellipsis:
236231
types = types[0:1]
237232
loop = True
238233
kwargs['nargs'] = '*'
234+
# Handle Tuple[()]
235+
elif len(types) == 1 and types[0] == tuple():
236+
types = [str]
237+
loop = True
238+
kwargs['nargs'] = '*'
239239
else:
240240
kwargs['nargs'] = len(types)
241241

242242
var_type = TupleTypeEnforcer(types=types, loop=loop)
243243

244244
if get_origin(var_type) in BOXED_TYPES:
245-
# If List or Set type, set nargs
245+
# If List or Set or Tuple type, set nargs
246246
if (get_origin(var_type) in BOXED_COLLECTION_TYPES
247247
and kwargs.get('action') not in {'append', 'append_const'}):
248248
kwargs['nargs'] = kwargs.get('nargs', '*')
@@ -259,6 +259,7 @@ def _add_argument(self, *name_or_flags, **kwargs) -> None:
259259
# Handle the cases of List[bool], Set[bool], Tuple[bool]
260260
if var_type == bool:
261261
var_type = boolean_type
262+
262263
# If bool then set action, otherwise set type
263264
if var_type == bool:
264265
if explicit_bool:
@@ -421,8 +422,8 @@ def parse_args(self: TapType,
421422
422423
:param args: List of strings to parse. The default is taken from `sys.argv`.
423424
:param known_only: If true, ignores extra arguments and only parses known arguments.
424-
Unparsed arguments are saved to self.extra_args.
425-
:legacy_config_parsing: If true, config files are parsed using `str.split` instead of `shlex.split`.
425+
Unparsed arguments are saved to self.extra_args.
426+
:param legacy_config_parsing: If true, config files are parsed using `str.split` instead of `shlex.split`.
426427
:return: self, which is a Tap instance containing all of the parsed args.
427428
"""
428429
# Prevent double parsing

tap/tapify.py

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tapify module, which can initialize a class or run a function by parsing arguments from the command line."""
2+
from inspect import signature, Parameter
3+
from typing import Any, Callable, List, Optional, TypeVar, Union
4+
5+
from docstring_parser import parse
6+
7+
from tap import Tap
8+
9+
InputType = TypeVar('InputType')
10+
OutputType = TypeVar('OutputType')
11+
12+
13+
def tapify(class_or_function: Union[Callable[[InputType], OutputType], OutputType],
14+
args: Optional[List[str]] = None,
15+
known_only: bool = False,
16+
**func_kwargs) -> OutputType:
17+
"""Tapify initializes a class or runs a function by parsing arguments from the command line.
18+
19+
:param class_or_function: The class or function to run with the provided arguments.
20+
:param args: Arguments to parse. If None, arguments are parsed from the command line.
21+
:param known_only: If true, ignores extra arguments and only parses known arguments.
22+
:param func_kwargs: Additional keyword arguments for the function. These act as default values when
23+
parsing the command line arguments and overwrite the function defaults but
24+
are overwritten by the parsed command line arguments.
25+
"""
26+
# Get signature from class or function
27+
sig = signature(class_or_function)
28+
29+
# Parse class or function docstring in one line
30+
if isinstance(class_or_function, type) and class_or_function.__init__.__doc__ is not None:
31+
doc = class_or_function.__init__.__doc__
32+
else:
33+
doc = class_or_function.__doc__
34+
35+
# Parse docstring
36+
docstring = parse(doc)
37+
38+
# Get the description of each argument in the class init or function
39+
param_to_description = {param.arg_name: param.description for param in docstring.params}
40+
41+
# Create a Tap object
42+
tap = Tap(description='\n'.join(filter(None, (docstring.short_description, docstring.long_description))))
43+
44+
# Add arguments of class init or function to the Tap object
45+
for param_name, param in sig.parameters.items():
46+
tap_kwargs = {}
47+
48+
# Get type of the argument
49+
if param.annotation != Parameter.empty:
50+
# Any type defaults to str (needed for dataclasses where all non-default attributes must have a type)
51+
if param.annotation is Any:
52+
tap._annotations[param.name] = str
53+
# Otherwise, get the type of the argument
54+
else:
55+
tap._annotations[param.name] = param.annotation
56+
57+
# Get the default or required of the argument
58+
if param.name in func_kwargs:
59+
tap_kwargs['default'] = func_kwargs[param.name]
60+
del func_kwargs[param.name]
61+
elif param.default != Parameter.empty:
62+
tap_kwargs['default'] = param.default
63+
else:
64+
tap_kwargs['required'] = True
65+
66+
# Get the help string of the argument
67+
if param.name in param_to_description:
68+
tap.class_variables[param.name] = {'comment': param_to_description[param.name]}
69+
70+
# Add the argument to the Tap object
71+
tap._add_argument(f'--{param_name}', **tap_kwargs)
72+
73+
# If any func_kwargs remain, they are not used in the function, so raise an error
74+
if func_kwargs and not known_only:
75+
raise ValueError(f'Unknown keyword arguments: {func_kwargs}')
76+
77+
# Parse command line arguments
78+
args = tap.parse_args(
79+
args=args,
80+
known_only=known_only
81+
)
82+
83+
# Initialize the class or run the function with the parsed arguments
84+
return class_or_function(**args.as_dict())

tests/test_actions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def configure(self):
220220

221221
help_regex = r'.*positional arguments:\n.*arg\s*\(str, required\).*'
222222
help_text = PositionalDefault().format_help()
223-
self.assertRegexpMatches(help_text, help_regex)
223+
self.assertRegex(help_text, help_regex)
224224

225225

226226
if __name__ == '__main__':

0 commit comments

Comments
 (0)