221 lines
10 KiB
Python
221 lines
10 KiB
Python
"""Helper utilities for integrating argcomplete with traitlets"""
|
|
|
|
# Copyright (c) IPython Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import typing as t
|
|
|
|
try:
|
|
import argcomplete
|
|
from argcomplete import CompletionFinder # type:ignore[attr-defined]
|
|
except ImportError:
|
|
# This module and its utility methods are written to not crash even
|
|
# if argcomplete is not installed.
|
|
class StubModule:
|
|
def __getattr__(self, attr: str) -> t.Any:
|
|
if not attr.startswith("__"):
|
|
raise ModuleNotFoundError("No module named 'argcomplete'")
|
|
raise AttributeError(f"argcomplete stub module has no attribute '{attr}'")
|
|
|
|
argcomplete = StubModule() # type:ignore[assignment]
|
|
CompletionFinder = object # type:ignore[assignment, misc]
|
|
|
|
|
|
def get_argcomplete_cwords() -> t.Optional[t.List[str]]:
|
|
"""Get current words prior to completion point
|
|
|
|
This is normally done in the `argcomplete.CompletionFinder` constructor,
|
|
but is exposed here to allow `traitlets` to follow dynamic code-paths such
|
|
as determining whether to evaluate a subcommand.
|
|
"""
|
|
if "_ARGCOMPLETE" not in os.environ:
|
|
return None
|
|
|
|
comp_line = os.environ["COMP_LINE"]
|
|
comp_point = int(os.environ["COMP_POINT"])
|
|
# argcomplete.debug("splitting COMP_LINE for:", comp_line, comp_point)
|
|
comp_words: t.List[str]
|
|
try:
|
|
(
|
|
cword_prequote,
|
|
cword_prefix,
|
|
cword_suffix,
|
|
comp_words,
|
|
last_wordbreak_pos,
|
|
) = argcomplete.split_line(comp_line, comp_point) # type:ignore[attr-defined,no-untyped-call]
|
|
except ModuleNotFoundError:
|
|
return None
|
|
|
|
# _ARGCOMPLETE is set by the shell script to tell us where comp_words
|
|
# should start, based on what we're completing.
|
|
# 1: <script> [args]
|
|
# 2: python <script> [args]
|
|
# 3: python -m <module> [args]
|
|
start = int(os.environ["_ARGCOMPLETE"]) - 1
|
|
comp_words = comp_words[start:]
|
|
|
|
# argcomplete.debug("prequote=", cword_prequote, "prefix=", cword_prefix, "suffix=", cword_suffix, "words=", comp_words, "last=", last_wordbreak_pos)
|
|
return comp_words # noqa: RET504
|
|
|
|
|
|
def increment_argcomplete_index() -> None:
|
|
"""Assumes ``$_ARGCOMPLETE`` is set and `argcomplete` is importable
|
|
|
|
Increment the index pointed to by ``$_ARGCOMPLETE``, which is used to
|
|
determine which word `argcomplete` should start evaluating the command-line.
|
|
This may be useful to "inform" `argcomplete` that we have already evaluated
|
|
the first word as a subcommand.
|
|
"""
|
|
try:
|
|
os.environ["_ARGCOMPLETE"] = str(int(os.environ["_ARGCOMPLETE"]) + 1)
|
|
except Exception:
|
|
try:
|
|
argcomplete.debug("Unable to increment $_ARGCOMPLETE", os.environ["_ARGCOMPLETE"]) # type:ignore[attr-defined,no-untyped-call]
|
|
except (KeyError, ModuleNotFoundError):
|
|
pass
|
|
|
|
|
|
class ExtendedCompletionFinder(CompletionFinder):
|
|
"""An extension of CompletionFinder which dynamically completes class-trait based options
|
|
|
|
This finder adds a few functionalities:
|
|
|
|
1. When completing options, it will add ``--Class.`` to the list of completions, for each
|
|
class in `Application.classes` that could complete the current option.
|
|
2. If it detects that we are currently trying to complete an option related to ``--Class.``,
|
|
it will add the corresponding config traits of Class to the `ArgumentParser` instance,
|
|
so that the traits' completers can be used.
|
|
3. If there are any subcommands, they are added as completions for the first word
|
|
|
|
Note that we are avoiding adding all config traits of all classes to the `ArgumentParser`,
|
|
which would be easier but would add more runtime overhead and would also make completions
|
|
appear more spammy.
|
|
|
|
These changes do require using the internals of `argcomplete.CompletionFinder`.
|
|
"""
|
|
|
|
_parser: argparse.ArgumentParser
|
|
config_classes: t.List[t.Any] = [] # Configurables
|
|
subcommands: t.List[str] = []
|
|
|
|
def match_class_completions(self, cword_prefix: str) -> t.List[t.Tuple[t.Any, str]]:
|
|
"""Match the word to be completed against our Configurable classes
|
|
|
|
Check if cword_prefix could potentially match against --{class}. for any class
|
|
in Application.classes.
|
|
"""
|
|
class_completions = [(cls, f"--{cls.__name__}.") for cls in self.config_classes]
|
|
matched_completions = class_completions
|
|
if "." in cword_prefix:
|
|
cword_prefix = cword_prefix[: cword_prefix.index(".") + 1]
|
|
matched_completions = [(cls, c) for (cls, c) in class_completions if c == cword_prefix]
|
|
elif len(cword_prefix) > 0:
|
|
matched_completions = [
|
|
(cls, c) for (cls, c) in class_completions if c.startswith(cword_prefix)
|
|
]
|
|
return matched_completions
|
|
|
|
def inject_class_to_parser(self, cls: t.Any) -> None:
|
|
"""Add dummy arguments to our ArgumentParser for the traits of this class
|
|
|
|
The argparse-based loader currently does not actually add any class traits to
|
|
the constructed ArgumentParser, only the flags & aliaes. In order to work nicely
|
|
with argcomplete's completers functionality, this method adds dummy arguments
|
|
of the form --Class.trait to the ArgumentParser instance.
|
|
|
|
This method should be called selectively to reduce runtime overhead and to avoid
|
|
spamming options across all of Application.classes.
|
|
"""
|
|
try:
|
|
for traitname, trait in cls.class_traits(config=True).items():
|
|
completer = trait.metadata.get("argcompleter") or getattr(
|
|
trait, "argcompleter", None
|
|
)
|
|
multiplicity = trait.metadata.get("multiplicity")
|
|
self._parser.add_argument( # type: ignore[attr-defined]
|
|
f"--{cls.__name__}.{traitname}",
|
|
type=str,
|
|
help=trait.help,
|
|
nargs=multiplicity,
|
|
# metavar=traitname,
|
|
).completer = completer
|
|
# argcomplete.debug(f"added --{cls.__name__}.{traitname}")
|
|
except AttributeError:
|
|
pass
|
|
|
|
def _get_completions(
|
|
self, comp_words: t.List[str], cword_prefix: str, *args: t.Any
|
|
) -> t.List[str]:
|
|
"""Overridden to dynamically append --Class.trait arguments if appropriate
|
|
|
|
Warning:
|
|
This does not (currently) support completions of the form
|
|
--Class1.Class2.<...>.trait, although this is valid for traitlets.
|
|
Part of the reason is that we don't currently have a way to identify
|
|
which classes may be used with Class1 as a parent.
|
|
|
|
Warning:
|
|
This is an internal method in CompletionFinder and so the API might
|
|
be subject to drift.
|
|
"""
|
|
# Try to identify if we are completing something related to --Class. for
|
|
# a known Class, if we are then add the Class config traits to our ArgumentParser.
|
|
prefix_chars = self._parser.prefix_chars
|
|
is_option = len(cword_prefix) > 0 and cword_prefix[0] in prefix_chars
|
|
if is_option:
|
|
# If we are currently completing an option, check if it could
|
|
# match with any of the --Class. completions. If there's exactly
|
|
# one matched class, then expand out the --Class.trait options.
|
|
matched_completions = self.match_class_completions(cword_prefix)
|
|
if len(matched_completions) == 1:
|
|
matched_cls = matched_completions[0][0]
|
|
self.inject_class_to_parser(matched_cls)
|
|
elif len(comp_words) > 0 and "." in comp_words[-1] and not is_option:
|
|
# If not an option, perform a hacky check to see if we are completing
|
|
# an argument for an already present --Class.trait option. Search backwards
|
|
# for last option (based on last word starting with prefix_chars), and see
|
|
# if it is of the form --Class.trait. Note that if multiplicity="+", these
|
|
# arguments might conflict with positional arguments.
|
|
for prev_word in comp_words[::-1]:
|
|
if len(prev_word) > 0 and prev_word[0] in prefix_chars:
|
|
matched_completions = self.match_class_completions(prev_word)
|
|
if matched_completions:
|
|
matched_cls = matched_completions[0][0]
|
|
self.inject_class_to_parser(matched_cls)
|
|
break
|
|
|
|
completions: t.List[str]
|
|
completions = super()._get_completions(comp_words, cword_prefix, *args) # type:ignore[no-untyped-call]
|
|
|
|
# For subcommand-handling: it is difficult to get this to work
|
|
# using argparse subparsers, because the ArgumentParser accepts
|
|
# arbitrary extra_args, which ends up masking subparsers.
|
|
# Instead, check if comp_words only consists of the script,
|
|
# if so check if any subcommands start with cword_prefix.
|
|
if self.subcommands and len(comp_words) == 1:
|
|
argcomplete.debug("Adding subcommands for", cword_prefix) # type:ignore[attr-defined,no-untyped-call]
|
|
completions.extend(subc for subc in self.subcommands if subc.startswith(cword_prefix))
|
|
|
|
return completions
|
|
|
|
def _get_option_completions(
|
|
self, parser: argparse.ArgumentParser, cword_prefix: str
|
|
) -> t.List[str]:
|
|
"""Overridden to add --Class. completions when appropriate"""
|
|
completions: t.List[str]
|
|
completions = super()._get_option_completions(parser, cword_prefix) # type:ignore[no-untyped-call]
|
|
if cword_prefix.endswith("."):
|
|
return completions
|
|
|
|
matched_completions = self.match_class_completions(cword_prefix)
|
|
if len(matched_completions) > 1:
|
|
completions.extend(opt for cls, opt in matched_completions)
|
|
# If there is exactly one match, we would expect it to have already
|
|
# been handled by the options dynamically added in _get_completions().
|
|
# However, maybe there's an edge cases missed here, for example if the
|
|
# matched class has no configurable traits.
|
|
return completions
|