argcomplete_config.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """Helper utilities for integrating argcomplete with traitlets"""
  2. # Copyright (c) IPython Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. from __future__ import annotations
  5. import argparse
  6. import os
  7. import typing as t
  8. try:
  9. import argcomplete
  10. from argcomplete import CompletionFinder # type:ignore[attr-defined]
  11. except ImportError:
  12. # This module and its utility methods are written to not crash even
  13. # if argcomplete is not installed.
  14. class StubModule:
  15. def __getattr__(self, attr: str) -> t.Any:
  16. if not attr.startswith("__"):
  17. raise ModuleNotFoundError("No module named 'argcomplete'")
  18. raise AttributeError(f"argcomplete stub module has no attribute '{attr}'")
  19. argcomplete = StubModule() # type:ignore[assignment]
  20. CompletionFinder = object # type:ignore[assignment, misc]
  21. def get_argcomplete_cwords() -> t.Optional[t.List[str]]:
  22. """Get current words prior to completion point
  23. This is normally done in the `argcomplete.CompletionFinder` constructor,
  24. but is exposed here to allow `traitlets` to follow dynamic code-paths such
  25. as determining whether to evaluate a subcommand.
  26. """
  27. if "_ARGCOMPLETE" not in os.environ:
  28. return None
  29. comp_line = os.environ["COMP_LINE"]
  30. comp_point = int(os.environ["COMP_POINT"])
  31. # argcomplete.debug("splitting COMP_LINE for:", comp_line, comp_point)
  32. comp_words: t.List[str]
  33. try:
  34. (
  35. cword_prequote,
  36. cword_prefix,
  37. cword_suffix,
  38. comp_words,
  39. last_wordbreak_pos,
  40. ) = argcomplete.split_line(comp_line, comp_point) # type:ignore[attr-defined,no-untyped-call]
  41. except ModuleNotFoundError:
  42. return None
  43. # _ARGCOMPLETE is set by the shell script to tell us where comp_words
  44. # should start, based on what we're completing.
  45. # 1: <script> [args]
  46. # 2: python <script> [args]
  47. # 3: python -m <module> [args]
  48. start = int(os.environ["_ARGCOMPLETE"]) - 1
  49. comp_words = comp_words[start:]
  50. # argcomplete.debug("prequote=", cword_prequote, "prefix=", cword_prefix, "suffix=", cword_suffix, "words=", comp_words, "last=", last_wordbreak_pos)
  51. return comp_words # noqa: RET504
  52. def increment_argcomplete_index() -> None:
  53. """Assumes ``$_ARGCOMPLETE`` is set and `argcomplete` is importable
  54. Increment the index pointed to by ``$_ARGCOMPLETE``, which is used to
  55. determine which word `argcomplete` should start evaluating the command-line.
  56. This may be useful to "inform" `argcomplete` that we have already evaluated
  57. the first word as a subcommand.
  58. """
  59. try:
  60. os.environ["_ARGCOMPLETE"] = str(int(os.environ["_ARGCOMPLETE"]) + 1)
  61. except Exception:
  62. try:
  63. argcomplete.debug("Unable to increment $_ARGCOMPLETE", os.environ["_ARGCOMPLETE"]) # type:ignore[attr-defined,no-untyped-call]
  64. except (KeyError, ModuleNotFoundError):
  65. pass
  66. class ExtendedCompletionFinder(CompletionFinder):
  67. """An extension of CompletionFinder which dynamically completes class-trait based options
  68. This finder adds a few functionalities:
  69. 1. When completing options, it will add ``--Class.`` to the list of completions, for each
  70. class in `Application.classes` that could complete the current option.
  71. 2. If it detects that we are currently trying to complete an option related to ``--Class.``,
  72. it will add the corresponding config traits of Class to the `ArgumentParser` instance,
  73. so that the traits' completers can be used.
  74. 3. If there are any subcommands, they are added as completions for the first word
  75. Note that we are avoiding adding all config traits of all classes to the `ArgumentParser`,
  76. which would be easier but would add more runtime overhead and would also make completions
  77. appear more spammy.
  78. These changes do require using the internals of `argcomplete.CompletionFinder`.
  79. """
  80. _parser: argparse.ArgumentParser
  81. config_classes: t.List[t.Any] = [] # Configurables
  82. subcommands: t.List[str] = []
  83. def match_class_completions(self, cword_prefix: str) -> t.List[t.Tuple[t.Any, str]]:
  84. """Match the word to be completed against our Configurable classes
  85. Check if cword_prefix could potentially match against --{class}. for any class
  86. in Application.classes.
  87. """
  88. class_completions = [(cls, f"--{cls.__name__}.") for cls in self.config_classes]
  89. matched_completions = class_completions
  90. if "." in cword_prefix:
  91. cword_prefix = cword_prefix[: cword_prefix.index(".") + 1]
  92. matched_completions = [(cls, c) for (cls, c) in class_completions if c == cword_prefix]
  93. elif len(cword_prefix) > 0:
  94. matched_completions = [
  95. (cls, c) for (cls, c) in class_completions if c.startswith(cword_prefix)
  96. ]
  97. return matched_completions
  98. def inject_class_to_parser(self, cls: t.Any) -> None:
  99. """Add dummy arguments to our ArgumentParser for the traits of this class
  100. The argparse-based loader currently does not actually add any class traits to
  101. the constructed ArgumentParser, only the flags & aliaes. In order to work nicely
  102. with argcomplete's completers functionality, this method adds dummy arguments
  103. of the form --Class.trait to the ArgumentParser instance.
  104. This method should be called selectively to reduce runtime overhead and to avoid
  105. spamming options across all of Application.classes.
  106. """
  107. try:
  108. for traitname, trait in cls.class_traits(config=True).items():
  109. completer = trait.metadata.get("argcompleter") or getattr(
  110. trait, "argcompleter", None
  111. )
  112. multiplicity = trait.metadata.get("multiplicity")
  113. self._parser.add_argument( # type: ignore[attr-defined]
  114. f"--{cls.__name__}.{traitname}",
  115. type=str,
  116. help=trait.help,
  117. nargs=multiplicity,
  118. # metavar=traitname,
  119. ).completer = completer
  120. # argcomplete.debug(f"added --{cls.__name__}.{traitname}")
  121. except AttributeError:
  122. pass
  123. def _get_completions(
  124. self, comp_words: t.List[str], cword_prefix: str, *args: t.Any
  125. ) -> t.List[str]:
  126. """Overridden to dynamically append --Class.trait arguments if appropriate
  127. Warning:
  128. This does not (currently) support completions of the form
  129. --Class1.Class2.<...>.trait, although this is valid for traitlets.
  130. Part of the reason is that we don't currently have a way to identify
  131. which classes may be used with Class1 as a parent.
  132. Warning:
  133. This is an internal method in CompletionFinder and so the API might
  134. be subject to drift.
  135. """
  136. # Try to identify if we are completing something related to --Class. for
  137. # a known Class, if we are then add the Class config traits to our ArgumentParser.
  138. prefix_chars = self._parser.prefix_chars
  139. is_option = len(cword_prefix) > 0 and cword_prefix[0] in prefix_chars
  140. if is_option:
  141. # If we are currently completing an option, check if it could
  142. # match with any of the --Class. completions. If there's exactly
  143. # one matched class, then expand out the --Class.trait options.
  144. matched_completions = self.match_class_completions(cword_prefix)
  145. if len(matched_completions) == 1:
  146. matched_cls = matched_completions[0][0]
  147. self.inject_class_to_parser(matched_cls)
  148. elif len(comp_words) > 0 and "." in comp_words[-1] and not is_option:
  149. # If not an option, perform a hacky check to see if we are completing
  150. # an argument for an already present --Class.trait option. Search backwards
  151. # for last option (based on last word starting with prefix_chars), and see
  152. # if it is of the form --Class.trait. Note that if multiplicity="+", these
  153. # arguments might conflict with positional arguments.
  154. for prev_word in comp_words[::-1]:
  155. if len(prev_word) > 0 and prev_word[0] in prefix_chars:
  156. matched_completions = self.match_class_completions(prev_word)
  157. if matched_completions:
  158. matched_cls = matched_completions[0][0]
  159. self.inject_class_to_parser(matched_cls)
  160. break
  161. completions: t.List[str]
  162. completions = super()._get_completions(comp_words, cword_prefix, *args) # type:ignore[no-untyped-call]
  163. # For subcommand-handling: it is difficult to get this to work
  164. # using argparse subparsers, because the ArgumentParser accepts
  165. # arbitrary extra_args, which ends up masking subparsers.
  166. # Instead, check if comp_words only consists of the script,
  167. # if so check if any subcommands start with cword_prefix.
  168. if self.subcommands and len(comp_words) == 1:
  169. argcomplete.debug("Adding subcommands for", cword_prefix) # type:ignore[attr-defined,no-untyped-call]
  170. completions.extend(subc for subc in self.subcommands if subc.startswith(cword_prefix))
  171. return completions
  172. def _get_option_completions(
  173. self, parser: argparse.ArgumentParser, cword_prefix: str
  174. ) -> t.List[str]:
  175. """Overridden to add --Class. completions when appropriate"""
  176. completions: t.List[str]
  177. completions = super()._get_option_completions(parser, cword_prefix) # type:ignore[no-untyped-call]
  178. if cword_prefix.endswith("."):
  179. return completions
  180. matched_completions = self.match_class_completions(cword_prefix)
  181. if len(matched_completions) > 1:
  182. completions.extend(opt for cls, opt in matched_completions)
  183. # If there is exactly one match, we would expect it to have already
  184. # been handled by the options dynamically added in _get_completions().
  185. # However, maybe there's an edge cases missed here, for example if the
  186. # matched class has no configurable traits.
  187. return completions