Skip to content

Commit 7c23779

Browse files
committed
First commit
0 parents  commit 7c23779

File tree

9 files changed

+327
-0
lines changed

9 files changed

+327
-0
lines changed

.coveragerc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[run]
2+
source=argparse_subdec
3+
command_line = -m pytest -v

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.egg-info
2+
.coverage
3+
__pycache__

README.rst

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
python-argparse-subdec
2+
######################
3+
4+
This is a very simple Python package that allows one to create argparse_'s
5+
subcommands via function decorators.
6+
7+
Usage
8+
=====
9+
10+
Create a ``SubDec`` object:
11+
12+
.. code:: python
13+
14+
import argparse_subdec
15+
16+
# Create the object to collect subcommands via decorators
17+
sd = argparse_subdec.SubDec()
18+
19+
Now, we can define our subcommands. We define a subcommand by decorating a
20+
function with method calls to ``sd``, like below:
21+
22+
.. code:: python
23+
24+
@sd.add_argument('--option', default='123')
25+
@sd.add_argument('--another')
26+
def foo(args):
27+
print(f'foo subcommand: --option={args.option!r} and --another={args.another!r}')
28+
29+
You can use any method name that you would use in a subparser. In our example
30+
above, we used ``add_argument``, probably most commonly used. When creating
31+
the subparsers, ``sd`` will replay those calls to the created subparser for
32+
the ``foo`` subcommand.
33+
34+
35+
In the example below we define a second subcommand, which will be named
36+
``foo-bar``:
37+
38+
.. code:: python
39+
40+
@sd.cmd(prefix_chars=':')
41+
@sd.add_argument('positional_arg', type=int)
42+
def foo_bar(args):
43+
print(f'foo-bar subcommand: {args.positional_arg!r}')
44+
45+
Note that we use a special decorator, ``sd.cmd``, which makes ``sd`` pass all
46+
of its arguments down to ``add_parser()`` when creating the subparser for
47+
``foo-bar``.
48+
49+
Once our subcommands are defined, we must call ``sd.create_parsers()`` in
50+
order to effectively create the subparsers:
51+
52+
.. code:: python
53+
54+
parser = argparse.ArgumentParser()
55+
subparsers = parser.add_subparsers()
56+
sd.create_parsers(subparsers)
57+
58+
59+
The following is example of how we would call the subcommands:
60+
61+
.. code:: python
62+
63+
args = parser.parse_args(['foo', '--another', 'hello'])
64+
args.fn(args) # The subcommand function is assigned to ``args.fn`` by default
65+
# Outputs: foo subcommand: --option='123' and --another='hello'
66+
67+
68+
For more details about the API, check out the subdec_ module.
69+
70+
71+
Install
72+
=======
73+
74+
Via ``pip``:
75+
76+
.. code:: bash
77+
78+
pip install argparse-subdec
79+
80+
81+
.. _argparse: https://docs.python.org/3/library/argparse.html
82+
.. _subdec: argparse_subdec/subdec.py

argparse_subdec/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .subdec import SubDec

argparse_subdec/subdec.py

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import collections.abc as C
5+
import typing as T
6+
7+
8+
class SubDec:
9+
"""
10+
This class provides a way to decorate functions as subcommands for
11+
argparse.
12+
13+
It provides an implementation of `__getattr__` that allows one to annotate
14+
a function (which represents a subcommand) multiple times as if calling
15+
methods on a sub-parser for that command.
16+
17+
The example below creates two subcommands::
18+
19+
>>> sd = SubDec()
20+
21+
>>> @sd.add_argument('--option', default='123')
22+
... @sd.add_argument('--another')
23+
... def foo(args):
24+
... print(f'foo subcommand: --option={args.option!r} and --another={args.another!r}')
25+
26+
>>> @sd.cmd(prefix_chars=':')
27+
... @sd.add_argument('positional_arg', type=int)
28+
... def foo_bar(args):
29+
... print(f'foo-bar subcommand: {args.positional_arg!r}')
30+
31+
Note that the second command uses a special decorator ``cmd()``, which
32+
passes all of its arguments down to ``add_parser()`` when creating the
33+
sub-parser.
34+
35+
In order to create the sub-parsers, you must call ``sd.create_parsers()``
36+
passing a ``subparsers`` object as argument::
37+
38+
>>> parser = argparse.ArgumentParser()
39+
>>> subparsers = parser.add_subparsers()
40+
>>> sd.create_parsers(subparsers)
41+
42+
>>> args = parser.parse_args(['foo', '--another', 'hello'])
43+
44+
By default, the subcommand function is assigned to ``args.fn``::
45+
46+
>>> args.fn(args)
47+
foo subcommand: --option='123' and --another='hello'
48+
49+
Calling the second command::
50+
51+
>>> args = parser.parse_args(['foo-bar', '42'])
52+
>>> args.fn(args)
53+
foo-bar subcommand: 42
54+
"""
55+
56+
def __init__(self,
57+
name_prefix: str = '',
58+
fn_dest: str = 'fn',
59+
sep: str = '-',
60+
):
61+
"""
62+
Initialize this object.
63+
64+
If ``name_prefix`` is not empty, that prefix is removed from the
65+
function name when defining the name of that function's subparser.
66+
67+
The parameter ``fn_dest`` (default: "fn") defines the name of the
68+
attribute in the object returned by ``parse_args()`` the decorated
69+
function will be assigned to.
70+
71+
The parameter ``sep`` (default: "-") defines the string that will
72+
replace the underscore character ("_") when converting the name of the
73+
decorated function to a subcommand name.
74+
"""
75+
self.__decorators_cache = {}
76+
self.__commands = {}
77+
self.__name_prefix = name_prefix
78+
self.__fn_dest = fn_dest
79+
self.__sep = sep
80+
81+
def create_parsers(self, subparsers):
82+
"""
83+
Create subparsers by calling ``subparsers.add_parser()`` for each
84+
decorated function.
85+
"""
86+
for cmd in self.__commands.values():
87+
self.__create_parser(cmd, subparsers)
88+
89+
def cmd(self, *k, **kw):
90+
"""
91+
Special decorator to register arguments to be passed do
92+
``add_parser()``.
93+
"""
94+
def decorator(fn):
95+
cmd = self.__get_command(fn)
96+
cmd['add_parser_args'] = (k, kw)
97+
return fn
98+
return decorator
99+
100+
def __getattr__(self, name: str):
101+
if name in self.__decorators_cache:
102+
return self.__decorators_cache[name]
103+
104+
def decorator_wrapper(*k, **kw):
105+
def decorator(fn):
106+
cmd = self.__get_command(fn)
107+
cmd['subparser_call_stack'].append({
108+
'method_name': name,
109+
'args': k,
110+
'kwargs': kw,
111+
})
112+
return fn
113+
return decorator
114+
115+
self.__decorators_cache[name] = decorator_wrapper
116+
return decorator_wrapper
117+
118+
def __get_command(self, fn: T.Callable):
119+
if fn not in self.__commands:
120+
self.__commands[fn] = {
121+
'name': None,
122+
'fn': fn,
123+
'subparser_call_stack': [],
124+
'add_parser_args': None,
125+
}
126+
return self.__commands[fn]
127+
128+
def __create_parser(self, cmd: dict, subparsers: T.Any):
129+
name = cmd['name']
130+
if not name:
131+
name = cmd['fn'].__name__
132+
if name.startswith(self.__name_prefix):
133+
name = name[len(self.__name_prefix):]
134+
if self.__sep is not None:
135+
name = name.replace('_', self.__sep)
136+
137+
if cmd['add_parser_args']:
138+
add_parser_args, add_parser_kwargs = cmd['add_parser_args']
139+
if not add_parser_args:
140+
add_parser_args = (name,)
141+
else:
142+
add_parser_args = (name,)
143+
add_parser_kwargs = {}
144+
145+
parser = subparsers.add_parser(*add_parser_args, **add_parser_kwargs)
146+
parser.set_defaults(**{self.__fn_dest: cmd['fn']})
147+
148+
for call_data in reversed(cmd['subparser_call_stack']):
149+
method = getattr(parser, call_data['method_name'])
150+
method(*call_data['args'], **call_data['kwargs'])

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["setuptools", "wheel"]
3+
build-backend = "setuptools.build_meta"

pytest.ini

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
addopts =
3+
--doctest-modules --doctest-ignore-import-errors

setup.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import setuptools
2+
3+
setuptools.setup(
4+
name='argparse-subdec',
5+
version='0.0.0',
6+
packages=['argparse_subdec'],
7+
extras_require={
8+
'tests': [
9+
'pytest~=6.2',
10+
'coverage~=5.5',
11+
],
12+
},
13+
)

tests/test_subdec.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import argparse
2+
3+
import argparse_subdec
4+
5+
6+
def test_command_decorator():
7+
sd = argparse_subdec.SubDec()
8+
9+
@sd.cmd()
10+
def foo():
11+
pass
12+
13+
@sd.cmd()
14+
def two_words():
15+
pass
16+
17+
@sd.add_argument('--option-for-bar')
18+
@sd.add_argument('--another-option-for-bar')
19+
def bar():
20+
pass
21+
22+
@sd.cmd('changed-name')
23+
@sd.add_argument('--option')
24+
def original_name():
25+
pass
26+
27+
# Test changing order of decorators
28+
@sd.add_argument('--option')
29+
@sd.cmd('changed-name-2')
30+
def another_original_name():
31+
pass
32+
33+
parser = argparse.ArgumentParser()
34+
subparsers = parser.add_subparsers()
35+
sd.create_parsers(subparsers)
36+
37+
args = parser.parse_args(['foo'])
38+
expected_args = argparse.Namespace(
39+
fn=foo,
40+
)
41+
assert args == expected_args
42+
43+
args = parser.parse_args(['two-words'])
44+
expected_args = argparse.Namespace(
45+
fn=two_words,
46+
)
47+
assert args == expected_args
48+
49+
args = parser.parse_args(['bar', '--option-for-bar', 'hello'])
50+
expected_args = argparse.Namespace(
51+
fn=bar,
52+
option_for_bar='hello',
53+
another_option_for_bar=None,
54+
)
55+
assert args == expected_args
56+
57+
args = parser.parse_args(['changed-name', '--option', 'hello'])
58+
expected_args = argparse.Namespace(
59+
fn=original_name,
60+
option='hello',
61+
)
62+
assert args == expected_args
63+
64+
args = parser.parse_args(['changed-name-2'])
65+
expected_args = argparse.Namespace(
66+
fn=another_original_name,
67+
option=None,
68+
)
69+
assert args == expected_args

0 commit comments

Comments
 (0)