Skip to content

Commit f6aecc7

Browse files
committed
Import subc library
Subc is a small library I wrote a while back to facilitate simple argparse based CLI applications which had sub-commands. It is used in multiple of my personal projects, but Yo is clearly the largest user. Due to its small size, it's somewhat silly to include as a dependency, especially if RPM packaging is to be considered. Since I'm the only author (it has never received a contribution), I fully own the copyright and can include it here with my developer sign-off. In this first commit, I include it unmodified from the v0.8.0 release. Subsequent commits will address type annotations, file headers, static checks, etc. Signed-off-by: Stephen Brennan <[email protected]>
1 parent 284742e commit f6aecc7

File tree

1 file changed

+396
-0
lines changed

1 file changed

+396
-0
lines changed

yo/subc.py

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
#!/usr/bin/env python3
2+
"""
3+
A simple sub-command library for writing rich CLIs
4+
"""
5+
import argparse
6+
import collections
7+
import typing as t
8+
from abc import ABC
9+
from abc import abstractproperty
10+
from abc import abstractmethod
11+
12+
13+
def _first_different(s1: str, s2: str) -> int:
14+
"""
15+
Return index of the first different character in s1 or s2. If the strings
16+
are the same, raises a ValueError.
17+
"""
18+
for i, (c1, c2) in enumerate(zip(s1, s2)):
19+
if c1 != c2:
20+
return i
21+
if len(s1) == len(s2):
22+
raise ValueError(f"Duplicate string {s1!r} is not allowed")
23+
return i + 1
24+
25+
26+
def _unique_prefixes(strings: t.Iterable[str]) -> t.Dict[str, t.List[str]]:
27+
"""
28+
Helper to find a list of unique prefixes for each string in strings.
29+
30+
Return a dict mapping each string to a list of prefixes which are unique
31+
among all other strings within the list. Here is an example:
32+
33+
>>> _unique_prefixes(["commit", "count", "apply", "app", "shape"])
34+
{'app': [],
35+
'apply': ['appl'],
36+
'commit': ['com', 'comm', 'commi'],
37+
'count': ['cou', 'coun'],
38+
'launch': ['la', 'lau', 'laun', 'launc'],
39+
'list': ['li', 'lis'],
40+
'shape': ['s', 'sh', 'sha', 'shap']}
41+
"""
42+
strings = sorted(strings)
43+
diffs = [0] * len(strings)
44+
for i, (s1, s2) in enumerate(zip(strings, strings[1:])):
45+
common = _first_different(s1, s2)
46+
diffs[i] = max(diffs[i], common)
47+
diffs[i + 1] = max(diffs[i + 1], common)
48+
return {
49+
s: [s[:i] for i in range(x + 1, len(s))]
50+
for (s, x) in zip(strings, diffs)
51+
}
52+
53+
54+
class _SneakyDict(collections.UserDict):
55+
"""
56+
A dictionary which can have "hidden" keys that only show up if you know
57+
about them. The keys are just aliases to other keys. They show up with
58+
"getitem" and "contains" operations, but not in list / len operations.
59+
"""
60+
61+
def __init__(self, *args, **kwargs):
62+
super().__init__(*args, **kwargs)
63+
self._aliases = {}
64+
65+
def __getitem__(self, key):
66+
key = self._aliases.get(key, key)
67+
return super().__getitem__(key)
68+
69+
def __contains__(self, key):
70+
key = self._aliases.get(key, key)
71+
return super().__contains__(key)
72+
73+
def add_aliases(self, alias_map: t.Dict[str, t.List[str]]):
74+
alias_to_name = {a: n for n, l in alias_map.items() for a in l}
75+
self._aliases.update(alias_to_name)
76+
77+
78+
def _wrap_subparser_aliases(
79+
option: argparse._SubParsersAction,
80+
alias_map: t.Dict[str, t.List[str]]
81+
) -> None:
82+
"""
83+
Unfortunately, this mucks around with an internal implementation of
84+
argparse. However, the API seems pretty stable, and I hope to catch any
85+
compatibility issues with testing on each new version.
86+
87+
The "choices" and "_name_parser_map" fields are used to determine which
88+
subcommands are allowed, and also to list out all of the subcommands for the
89+
help output (or even to generate completions with something like
90+
argcomplete).
91+
92+
For the purposes of lookup (or membership testing), we want the aliases to
93+
be reflected in these variables. But for the purposes of listing, the
94+
aliases should be hidden. Thus, use a the _SneakyDict from above to hide the
95+
aliases.
96+
"""
97+
new_choices = _SneakyDict(option.choices)
98+
new_choices.add_aliases(alias_map)
99+
option.choices = new_choices # type: ignore
100+
option._name_parser_map = option.choices
101+
102+
103+
T = t.TypeVar("T", bound="Command")
104+
F = t.TypeVar("F", bound=argparse.HelpFormatter)
105+
106+
107+
class Command(ABC):
108+
"""
109+
A simple class for implementing sub-commands in your command line
110+
application. Create a subclass for your app as follows:
111+
112+
class MyCmd(subc.Command):
113+
rootname = "mycmd"
114+
115+
Then, each command in your app can subclass this, implementing the three
116+
required fields:
117+
118+
class HelloWorld(MyCmd):
119+
name = 'hello-world'
120+
description = 'say hello'
121+
def run(self):
122+
print('hello world')
123+
124+
Finally, use your app-level subclass for creating an argument parser:
125+
126+
def main():
127+
parser = argparse.ArgumentParser(description='a cool tool')
128+
MyCmd.add_commands(parser)
129+
args = parser.parse_args()
130+
args.func(args)
131+
132+
Optional properties of the command:
133+
134+
- help_formatter_class: used to specify how argparse formats help
135+
- group: used to categorize commands into groups
136+
- help: used as a short description (fallback to description)
137+
- alias: used as an optional alias for this command (in case you rename it)
138+
"""
139+
140+
@property
141+
def help_formatter_class(self) -> t.Type[F]:
142+
return argparse.HelpFormatter
143+
144+
@abstractproperty
145+
def name(self) -> str:
146+
"""A field or property which is used for the command name argument"""
147+
148+
@abstractproperty
149+
def description(self) -> str:
150+
"""A field or property which is used as the help/description"""
151+
152+
def add_args(self, parser: argparse.ArgumentParser):
153+
pass # default is no arguments
154+
155+
@abstractmethod
156+
def run(self) -> t.Any:
157+
"""Function which is called for this command."""
158+
159+
def base_run(self, args: argparse.Namespace):
160+
self.args = args
161+
return self.run()
162+
163+
@classmethod
164+
def iter_commands(cls: t.Type[T]) -> t.Iterator[T]:
165+
"""
166+
Iterate over all sub-commands of the root parser
167+
168+
This function yields an instance subclass which subc will consider a
169+
"command" that is, only leaf classes in the hierarchy. You can use this
170+
if you want to do some sort of operation on each command, e.g.
171+
generating documentation.
172+
"""
173+
subclasses = collections.deque(cls.__subclasses__())
174+
while subclasses:
175+
subcls = subclasses.popleft()
176+
this_node_subclasses = subcls.__subclasses__()
177+
if this_node_subclasses:
178+
# Assume that any class with children is not executable. Add
179+
# its children to the queue (BFS) but do not instantiate it.
180+
subclasses.extend(this_node_subclasses)
181+
else:
182+
yield subcls()
183+
184+
def simple_sub_parser(self) -> argparse.ArgumentParser:
185+
"""
186+
Return a simple argument parser for this sub-command
187+
188+
This function returns an argument parser which could be used to parse
189+
arguments for this sub-command. It's not the same as the parser you get
190+
if you were to use the root command with add_commands() - but it's good
191+
if you'd like to only execute this one command, or if you'd like to
192+
create a parser for use by documentation generators like
193+
sphinx-argparse.
194+
"""
195+
if hasattr(self, "rootname"):
196+
prog = f"{self.rootname}"
197+
else:
198+
prog = self.name
199+
parser = argparse.ArgumentParser(
200+
prog=prog,
201+
description=self.description,
202+
formatter_class=self.help_formatter_class,
203+
)
204+
self.add_args(parser)
205+
return parser
206+
207+
@classmethod
208+
def add_commands(
209+
cls,
210+
parser: argparse.ArgumentParser,
211+
default: t.Optional[str] = None,
212+
shortest_prefix: bool = False,
213+
cmd_aliases: t.Optional[t.Mapping[str, str]] = None,
214+
group_order: t.Optional[t.List[str]] = None,
215+
) -> argparse.ArgumentParser:
216+
"""
217+
Add all subcommands which are descendents of this class to parser.
218+
219+
This call is required in order to setup an argument parser before
220+
parsing args and executing sub-command. Each sub-command must be a
221+
sub-class (or a further descendent) of this class. Only leaf subclasses
222+
are considered commands -- internal "nodes" in the hierarchy are skipped
223+
as they are assumed to be helpers.
224+
225+
A default command to run may be set with 'default'. When the argument
226+
parser is called without a sub-command, this command will automatically
227+
execute (rather than simply raising an Exception).
228+
229+
Shortest prefix sub-command matching allows the user to select a
230+
sub-command by using any string which is a prefix of exactly one
231+
command, e.g. "git cl" rather than "git clone". This is useful whenever
232+
there is a small, unchanging set of sub-commands, as a user can develop
233+
muscle memory for prefixes. However, if the set of sub-commands changes
234+
over time, then users may develop muscle-memory for a prefix which
235+
becomes ambiguous with a new command. Thus, it may be preferable to
236+
allow users to specify their own alias list. You can setup shortest
237+
prefix aliases and also user-specified aliases with this function, even
238+
simultaneously if you'd like.
239+
240+
:param parser: Argument parser which is already created for this app
241+
:param default: Name of the command which should be executed if none is
242+
selected
243+
:param shortest_prefix: Enable shortest prefix command matching
244+
:param cmd_aliases: User-provided alias list in the form
245+
{"alias": "true name"}.
246+
:param group_order: Ordering of the groups in display
247+
:returns: the modified parser (this can be ignored)
248+
"""
249+
default_set = False
250+
subparsers = parser.add_subparsers(
251+
help=argparse.SUPPRESS, metavar="SUB-COMMAND",
252+
)
253+
parser.formatter_class = argparse.RawTextHelpFormatter
254+
to_add = list(cls.iter_commands())
255+
256+
# Groups are for the help display, we will group the subcommands with
257+
# this and then output each one in a section.
258+
groups = collections.defaultdict(list)
259+
260+
# Subcmds are for an added level of sub-command. For example, if subc is
261+
# used for "prog subcommand", then this would allow "prog level1 level2"
262+
# commands. We don't (yet) go further than this.
263+
subcmds = collections.defaultdict(list)
264+
265+
# These are the names which actually would get considered for the unique
266+
# prefix operation. It will exclude the sub-sub-command names.
267+
names = []
268+
269+
# These are the aliases defined by each command, e.g. in case they have
270+
# some other name for compatibility with previous versions of a tool.
271+
# This will be extended with the users cmd_aliases if provided.
272+
# ALIAS -> TRUE NAME
273+
aliases = {}
274+
275+
max_len = 0
276+
277+
for cmd in to_add:
278+
base_name = cmd.name
279+
max_len = max(max_len, len(cmd.name))
280+
if " " in cmd.name:
281+
base_name = cmd.name.replace(" ", "-")
282+
sub, rem = cmd.name.split(" ", 1)
283+
subcmds[sub].append((rem, cmd))
284+
else:
285+
# Only include in shortest prefix mappings if it's not
286+
# a sub-sub-command.
287+
names.append(cmd.name)
288+
289+
cmd_parser = subparsers.add_parser(
290+
base_name,
291+
description=cmd.description,
292+
formatter_class=cmd.help_formatter_class,
293+
)
294+
cmd.add_args(cmd_parser)
295+
cmd_parser.set_defaults(func=cmd.base_run)
296+
if hasattr(cmd, "alias"):
297+
names.append(cmd.alias)
298+
aliases[cmd.alias] = base_name
299+
300+
groups[getattr(cmd, "group", "")].append(cmd)
301+
302+
if cmd.name == default:
303+
parser.set_defaults(func=cmd.base_run)
304+
default_set = True
305+
306+
for subcmd, cmdlist in subcmds.items():
307+
subcmd_parser = subparsers.add_parser(subcmd)
308+
subcmd_subp = subcmd_parser.add_subparsers(
309+
title="sub-command", metavar="SUB-COMMAND",
310+
)
311+
sub_names = []
312+
names.append(subcmd)
313+
subcmd_parser.set_defaults(_sub=subcmd_parser)
314+
subcmd_parser.set_defaults(func=lambda ns: ns._sub.print_help())
315+
for name, cmd in cmdlist:
316+
sub_names.append(name)
317+
cmd_parser = subcmd_subp.add_parser(
318+
name,
319+
help=getattr(cmd, "help", cmd.description),
320+
description=cmd.description,
321+
formatter_class=cmd.help_formatter_class,
322+
)
323+
cmd.add_args(cmd_parser)
324+
cmd_parser.set_defaults(func=cmd.base_run)
325+
if shortest_prefix:
326+
sub_inv_aliases = _unique_prefixes(sub_names)
327+
_wrap_subparser_aliases(subcmd_subp, sub_inv_aliases)
328+
329+
if cmd_aliases:
330+
names.extend(cmd_aliases)
331+
aliases.update(cmd_aliases)
332+
333+
inv_aliases = collections.defaultdict(list)
334+
if shortest_prefix:
335+
inv_aliases.update(_unique_prefixes(names))
336+
for name, target in aliases.items():
337+
if " " in target:
338+
# allow alias to a subcommand
339+
target = target.replace(" ", "-")
340+
inv_aliases[target].append(name)
341+
inv_aliases[target].extend(inv_aliases.pop(name, []))
342+
_wrap_subparser_aliases(subparsers, inv_aliases)
343+
344+
if not group_order:
345+
group_order = sorted(groups)
346+
lines = []
347+
for group in group_order:
348+
cmds = groups[group]
349+
if group:
350+
lines.append(group)
351+
for cmd in cmds:
352+
help = getattr(cmd, "help", cmd.description.strip())
353+
lines.append(f"{cmd.name.ljust(max_len)} {help}")
354+
lines.append("")
355+
356+
parser.epilog = "\n".join(lines[:-1])
357+
358+
if not default_set:
359+
def default_func(*args, **kwargs):
360+
raise Exception('you must select a sub-command')
361+
parser.set_defaults(func=default_func)
362+
return parser
363+
364+
@classmethod
365+
def main(
366+
cls,
367+
description: str,
368+
default: t.Optional[str] = None,
369+
args: t.Optional[t.List[str]] = None,
370+
shortest_prefix: bool = False,
371+
) -> t.Any:
372+
"""
373+
Parse arguments and run the selected sub-command.
374+
375+
This helper function is expected to be the main, most useful API for
376+
subc, although you could directly call the add_commands() method.
377+
Creates an argument parser, adds every discovered sub-command, parses
378+
the arguments, and executes the selected sub-command, returning its
379+
return value.
380+
381+
Custom arguments (rather than sys.argv) can be specified using "args".
382+
Details on the arguments "default" and "shortest_prefix" can be found
383+
in the docstring for add_commands().
384+
385+
:param description: Description of the application (for help output)
386+
:param default: Default command name
387+
:param args: If specified, a list of args to use in place of sys.argv
388+
:param shortest_prefix: whether to enable prefix matching
389+
:returns: Return value of the selected command's run() method
390+
"""
391+
parser = argparse.ArgumentParser(description=description)
392+
cls.add_commands(
393+
parser, default=default, shortest_prefix=shortest_prefix,
394+
)
395+
ns = parser.parse_args(args=args)
396+
return ns.func(ns)

0 commit comments

Comments
 (0)