Skip to content

Commit d2fbecc

Browse files
authored
Polishing argcomplete support (#829)
* Polishing argcomplete support * Forgot to add license notice on new files * Add support for completing subcommands. Its not easy to do this with argparse's subparser with the way traitlets uses argparse, so instead directly add subcommands as completions for the first cword in argcomplete. * A few other small refactor/cleanup/testing * Add missed subapp.clear_instance() in unit test Otherwise, this test prevents other tests from creating subcommand applications.
1 parent a6c926e commit d2fbecc

5 files changed

Lines changed: 94 additions & 32 deletions

File tree

traitlets/config/application.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ def flatten_flags(self):
731731
This prevents issues such as an alias pointing to InteractiveShell,
732732
but a config file setting the same trait in TerminalInteraciveShell
733733
getting inappropriate priority over the command-line arg.
734-
Also, loaders expect ``(key: longname)`` and not ````key: (longname, help)`` items.
734+
Also, loaders expect ``(key: longname)`` and not ``key: (longname, help)`` items.
735735
736736
Only aliases with exactly one descendent in the class list
737737
will be promoted.
@@ -785,7 +785,9 @@ def flatten_flags(self):
785785
return flags, aliases
786786

787787
def _create_loader(self, argv, aliases, flags, classes):
788-
return KVArgParseConfigLoader(argv, aliases, flags, classes=classes, log=self.log)
788+
return KVArgParseConfigLoader(
789+
argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands
790+
)
789791

790792
@classmethod
791793
def _get_sys_argv(cls, check_argcomplete: bool = False) -> t.List[str]:

traitlets/config/argcomplete_config.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
"""Helper utilities for integrating argcomplete with traitlets"""
2+
3+
# Copyright (c) IPython Development Team.
4+
# Distributed under the terms of the Modified BSD License.
5+
6+
27
import argparse
38
import os
49
import typing as t
@@ -76,23 +81,25 @@ def increment_argcomplete_index():
7681
class ExtendedCompletionFinder(CompletionFinder):
7782
"""An extension of CompletionFinder which dynamically completes class-trait based options
7883
79-
This finder mainly adds 2 functionalities:
84+
This finder adds a few functionalities:
8085
81-
1. When completing options, it will add --Class. to the list of completions, for each
82-
class in Application.classes that could complete the current option.
83-
2. If it detects that we are currently trying to complete an option related to --Class.,
84-
it will add the corresponding config traits of Class to the ArgumentParser instance,
86+
1. When completing options, it will add ``--Class.`` to the list of completions, for each
87+
class in `Application.classes` that could complete the current option.
88+
2. If it detects that we are currently trying to complete an option related to ``--Class.``,
89+
it will add the corresponding config traits of Class to the `ArgumentParser` instance,
8590
so that the traits' completers can be used.
91+
3. If there are any subcommands, they are added as completions for the first word
8692
87-
Note that we are avoiding adding all config traits of all classes to the ArgumentParser,
93+
Note that we are avoiding adding all config traits of all classes to the `ArgumentParser`,
8894
which would be easier but would add more runtime overhead and would also make completions
8995
appear more spammy.
9096
91-
These changes do require using the internals of argcomplete.CompletionFinder.
97+
These changes do require using the internals of `argcomplete.CompletionFinder`.
9298
"""
9399

94100
_parser: argparse.ArgumentParser
95-
config_classes: t.List[t.Any] # Configurables
101+
config_classes: t.List[t.Any] = [] # Configurables
102+
subcommands: t.List[str] = []
96103

97104
def match_class_completions(self, cword_prefix: str) -> t.List[t.Tuple[t.Any, str]]:
98105
"""Match the word to be completed against our Configurable classes
@@ -182,6 +189,16 @@ def _get_completions(
182189

183190
completions: t.List[str]
184191
completions = super()._get_completions(comp_words, cword_prefix, *args)
192+
193+
# For subcommand-handling: it is difficult to get this to work
194+
# using argparse subparsers, because the ArgumentParser accepts
195+
# arbitrary extra_args, which ends up masking subparsers.
196+
# Instead, check if comp_words only consists of the script,
197+
# if so check if any subcommands start with cword_prefix.
198+
if self.subcommands and len(comp_words) == 1:
199+
argcomplete.debug("Adding subcommands for", cword_prefix)
200+
completions.extend(subc for subc in self.subcommands if subc.startswith(cword_prefix))
201+
185202
return completions
186203

187204
def _get_option_completions(

traitlets/config/loader.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -784,11 +784,15 @@ def parse_known_args(self, args=None, namespace=None):
784784
return super().parse_known_args(args, namespace)
785785

786786

787+
# type aliases
788+
Flags = t.Union[str, t.Tuple[str, ...]]
789+
SubcommandsDict = t.Dict[str, t.Any]
790+
791+
787792
class ArgParseConfigLoader(CommandLineConfigLoader):
788793
"""A loader that uses the argparse module to load from the command line."""
789794

790795
parser_class = ArgumentParser
791-
Flags = t.Union[str, t.Tuple[str, ...]]
792796

793797
def __init__(
794798
self,
@@ -797,6 +801,7 @@ def __init__(
797801
flags: t.Optional[t.Dict[Flags, str]] = None,
798802
log: t.Any = None,
799803
classes: t.Optional[t.List[t.Type[t.Any]]] = None,
804+
subcommands: t.Optional[SubcommandsDict] = None,
800805
*parser_args: t.Any,
801806
**parser_kw: t.Any,
802807
) -> None:
@@ -837,6 +842,7 @@ def __init__(
837842
self.aliases = aliases or {}
838843
self.flags = flags or {}
839844
self.classes = classes
845+
self.subcommands = subcommands # only used for argcomplete currently
840846

841847
self.parser_args = parser_args
842848
self.version = parser_kw.pop("version", None)
@@ -874,6 +880,7 @@ def load_config(self, argv=None, aliases=None, flags=_deprecated, classes=None):
874880
if classes is not None:
875881
self.classes = classes
876882
self._create_parser()
883+
self._argcomplete(self.classes, self.subcommands)
877884
self._parse_args(argv)
878885
self._convert_to_config()
879886
return self.config
@@ -893,6 +900,12 @@ def _create_parser(self):
893900
def _add_arguments(self, aliases, flags, classes):
894901
raise NotImplementedError("subclasses must implement _add_arguments")
895902

903+
def _argcomplete(
904+
self, classes: t.List[t.Any], subcommands: t.Optional[SubcommandsDict]
905+
) -> None:
906+
"""If argcomplete is enabled, allow triggering command-line autocompletion"""
907+
pass
908+
896909
def _parse_args(self, args):
897910
"""self.parser->self.parsed_data"""
898911
uargs = [cast_unicode(a) for a in args]
@@ -1047,7 +1060,6 @@ def _add_arguments(self, aliases, flags, classes):
10471060
if argcompleter is not None:
10481061
# argcomplete's completers are callables returning list of completion strings
10491062
action.completer = functools.partial(argcompleter, key=key) # type: ignore
1050-
self.argcomplete(classes)
10511063

10521064
def _convert_to_config(self):
10531065
"""self.parsed_data->self.config, parse unrecognized extra args via KVLoader."""
@@ -1097,7 +1109,10 @@ def _handle_unrecognized_alias(self, arg: str) -> None:
10971109
"""
10981110
self.log.warning("Unrecognized alias: '%s', it will have no effect.", arg)
10991111

1100-
def argcomplete(self, classes: t.List[t.Any]) -> None:
1112+
def _argcomplete(
1113+
self, classes: t.List[t.Any], subcommands: t.Optional[SubcommandsDict]
1114+
) -> None:
1115+
"""If argcomplete is enabled, allow triggering command-line autocompletion"""
11011116
try:
11021117
import argcomplete # type: ignore[import] # noqa
11031118
except ImportError:
@@ -1107,6 +1122,7 @@ def argcomplete(self, classes: t.List[t.Any]) -> None:
11071122

11081123
finder = argcomplete_config.ExtendedCompletionFinder()
11091124
finder.config_classes = classes
1125+
finder.subcommands = list(subcommands or [])
11101126
# for ease of testing, pass through self._argcomplete_kwargs if set
11111127
finder(self.parser, **getattr(self, "_argcomplete_kwargs", {}))
11121128

traitlets/config/tests/test_application.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ def test_subcommands_instanciation(self):
633633
self.assertIs(app.subapp.parent, app)
634634
self.assertIs(app.subapp.subapp.parent, app.subapp) # Set by factory.
635635

636+
Root.clear_instance()
637+
Sub1.clear_instance()
638+
636639
def test_loaded_config_files(self):
637640
app = MyApp()
638641
app.log = logging.getLogger()

traitlets/config/tests/test_argcomplete.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Tests for argcomplete handling by traitlets.config.application.Application
33
"""
44

5+
# Copyright (c) IPython Development Team.
6+
# Distributed under the terms of the Modified BSD License.
7+
58
import io
69
import os
710
import typing as t
@@ -21,8 +24,19 @@ class ArgcompleteApp(Application):
2124

2225
argcomplete_kwargs: t.Dict[str, t.Any]
2326

27+
def __init__(self, *args, **kwargs):
28+
# For subcommands, inherit argcomplete_kwargs from parent app
29+
parent = kwargs.get("parent")
30+
super().__init__(*args, **kwargs)
31+
if parent:
32+
argcomplete_kwargs = getattr(parent, "argcomplete_kwargs", None)
33+
if argcomplete_kwargs:
34+
self.argcomplete_kwargs = argcomplete_kwargs
35+
2436
def _create_loader(self, argv, aliases, flags, classes):
25-
loader = KVArgParseConfigLoader(argv, aliases, flags, classes=classes, log=self.log)
37+
loader = KVArgParseConfigLoader(
38+
argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands
39+
)
2640
loader._argcomplete_kwargs = self.argcomplete_kwargs # type: ignore[attr-defined]
2741
return loader
2842

@@ -169,24 +183,34 @@ class CustomApp(ArgcompleteApp):
169183
assert completions == ["--val=foo", "--val=bar"] or completions == ["foo", "bar"]
170184
assert self.run_completer(app, "app --val --log-level=", point=10) == ["foo", "bar"]
171185

172-
# TODO: don't have easy way of testing subcommands yet, since we want
173-
# to inject _argcomplete_kwargs to subapp. Could use mocking for this
174-
# def test_complete_subcommands_subapp1(self, argcomplete_on):
175-
# # subcommand handling modifies _ARGCOMPLETE env var global state, so
176-
# # only can test one completion per unit test
177-
# app = MainApp()
178-
# assert set(self.run_completer(app, "app subapp1 --Sub")) > {
179-
# '--SubApp1.show_config',
180-
# '--SubApp1.log_level',
181-
# '--SubApp1.log_format',
182-
# }
183-
#
184-
# def test_complete_subcommands_subapp2(self, argcomplete_on):
185-
# app = MainApp()
186-
# assert set(self.run_completer(app, "app subapp2 --")) > {
187-
# '--Application.',
188-
# '--SubApp2.',
189-
# }
186+
def test_complete_subcommands(self, argcomplete_on):
187+
app = MainApp()
188+
assert set(self.run_completer(app, "app ")) >= {"subapp1", "subapp2"}
189+
assert set(self.run_completer(app, "app sub")) == {"subapp1", "subapp2"}
190+
assert set(self.run_completer(app, "app subapp1")) == {"subapp1"}
191+
192+
def test_complete_subcommands_subapp1(self, argcomplete_on):
193+
# subcommand handling modifies _ARGCOMPLETE env var global state, so
194+
# only can test one completion per unit test
195+
app = MainApp()
196+
try:
197+
assert set(self.run_completer(app, "app subapp1 --Sub")) > {
198+
'--SubApp1.show_config',
199+
'--SubApp1.log_level',
200+
'--SubApp1.log_format',
201+
}
202+
finally:
203+
SubApp1.clear_instance()
204+
205+
def test_complete_subcommands_subapp2(self, argcomplete_on):
206+
app = MainApp()
207+
try:
208+
assert set(self.run_completer(app, "app subapp2 --")) > {
209+
'--Application.',
210+
'--SubApp2.',
211+
}
212+
finally:
213+
SubApp2.clear_instance()
190214

191215
def test_complete_subcommands_main(self, argcomplete_on):
192216
app = MainApp()

0 commit comments

Comments
 (0)