test_argcomplete.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """
  2. Tests for argcomplete handling by traitlets.config.application.Application
  3. """
  4. # Copyright (c) IPython Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. from __future__ import annotations
  7. import io
  8. import os
  9. import typing as t
  10. import pytest
  11. argcomplete = pytest.importorskip("argcomplete")
  12. from traitlets import Unicode
  13. from traitlets.config.application import Application
  14. from traitlets.config.configurable import Configurable
  15. from traitlets.config.loader import KVArgParseConfigLoader
  16. class ArgcompleteApp(Application):
  17. """Override loader to pass through kwargs for argcomplete testing"""
  18. argcomplete_kwargs: t.Dict[str, t.Any]
  19. def __init__(self, *args, **kwargs):
  20. # For subcommands, inherit argcomplete_kwargs from parent app
  21. parent = kwargs.get("parent")
  22. super().__init__(*args, **kwargs)
  23. if parent:
  24. argcomplete_kwargs = getattr(parent, "argcomplete_kwargs", None)
  25. if argcomplete_kwargs:
  26. self.argcomplete_kwargs = argcomplete_kwargs
  27. def _create_loader(self, argv, aliases, flags, classes):
  28. loader = KVArgParseConfigLoader(
  29. argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands
  30. )
  31. loader._argcomplete_kwargs = self.argcomplete_kwargs # type: ignore[attr-defined]
  32. return loader
  33. class SubApp1(ArgcompleteApp):
  34. pass
  35. class SubApp2(ArgcompleteApp):
  36. @classmethod
  37. def get_subapp_instance(cls, app: Application) -> Application:
  38. app.clear_instance() # since Application is singleton, need to clear main app
  39. return cls.instance(parent=app) # type: ignore[no-any-return]
  40. class MainApp(ArgcompleteApp):
  41. subcommands = {
  42. "subapp1": (SubApp1, "First subapp"),
  43. "subapp2": (SubApp2.get_subapp_instance, "Second subapp"),
  44. }
  45. class CustomError(Exception):
  46. """Helper for exit hook for testing argcomplete"""
  47. @classmethod
  48. def exit(cls, code):
  49. raise cls(str(code))
  50. class TestArgcomplete:
  51. IFS = "\013"
  52. COMP_WORDBREAKS = " \t\n\"'><=;|&(:"
  53. @pytest.fixture()
  54. def argcomplete_on(self, mocker):
  55. """Mostly borrowed from argcomplete's unit test fixtures
  56. Set up environment variables to mimic those passed by argcomplete
  57. """
  58. _old_environ = os.environ
  59. os.environ = os.environ.copy() # type: ignore[assignment]
  60. os.environ["_ARGCOMPLETE"] = "1"
  61. os.environ["_ARC_DEBUG"] = "yes"
  62. os.environ["IFS"] = self.IFS
  63. os.environ["_ARGCOMPLETE_COMP_WORDBREAKS"] = self.COMP_WORDBREAKS
  64. # argcomplete==2.0.0 always calls fdopen(9, "w") to open a debug stream,
  65. # however this could conflict with file descriptors used by pytest
  66. # and lead to obscure errors. Since we are not looking at debug stream
  67. # in these tests, just mock this fdopen call out.
  68. mocker.patch("os.fdopen")
  69. try:
  70. yield
  71. finally:
  72. os.environ = _old_environ
  73. def run_completer(
  74. self,
  75. app: ArgcompleteApp,
  76. command: str,
  77. point: t.Union[str, int, None] = None,
  78. **kwargs: t.Any,
  79. ) -> t.List[str]:
  80. """Mostly borrowed from argcomplete's unit tests
  81. Modified to take an application instead of an ArgumentParser
  82. Command is the current command being completed and point is the index
  83. into the command where the completion is triggered.
  84. """
  85. if point is None:
  86. point = str(len(command))
  87. # Flushing tempfile was leading to CI failures with Bad file descriptor, not sure why.
  88. # Fortunately we can just write to a StringIO instead.
  89. # print("Writing completions to temp file with mode=", write_mode)
  90. # from tempfile import TemporaryFile
  91. # with TemporaryFile(mode=write_mode) as t:
  92. strio = io.StringIO()
  93. os.environ["COMP_LINE"] = command
  94. os.environ["COMP_POINT"] = str(point)
  95. with pytest.raises(CustomError) as cm: # noqa: PT012
  96. app.argcomplete_kwargs = dict(
  97. output_stream=strio, exit_method=CustomError.exit, **kwargs
  98. )
  99. app.initialize()
  100. if str(cm.value) != "0":
  101. raise RuntimeError(f"Unexpected exit code {cm.value}")
  102. out = strio.getvalue()
  103. return out.split(self.IFS)
  104. def test_complete_simple_app(self, argcomplete_on):
  105. app = ArgcompleteApp()
  106. expected = [
  107. "--help",
  108. "--debug",
  109. "--show-config",
  110. "--show-config-json",
  111. "--log-level",
  112. "--Application.",
  113. "--ArgcompleteApp.",
  114. ]
  115. assert set(self.run_completer(app, "app --")) == set(expected)
  116. # completing class traits
  117. assert set(self.run_completer(app, "app --App")) > {
  118. "--Application.show_config",
  119. "--Application.log_level",
  120. "--Application.log_format",
  121. }
  122. def test_complete_custom_completers(self, argcomplete_on):
  123. app = ArgcompleteApp()
  124. # test pre-defined completers for Bool/Enum
  125. assert set(self.run_completer(app, "app --Application.log_level=")) > {"DEBUG", "INFO"}
  126. assert set(self.run_completer(app, "app --ArgcompleteApp.show_config ")) == {
  127. "0",
  128. "1",
  129. "true",
  130. "false",
  131. }
  132. # test custom completer and mid-command completions
  133. class CustomCls(Configurable):
  134. val = Unicode().tag(
  135. config=True, argcompleter=argcomplete.completers.ChoicesCompleter(["foo", "bar"])
  136. )
  137. class CustomApp(ArgcompleteApp):
  138. classes = [CustomCls]
  139. aliases = {("v", "val"): "CustomCls.val"}
  140. app = CustomApp()
  141. assert self.run_completer(app, "app --val ") == ["foo", "bar"]
  142. assert self.run_completer(app, "app --val=") == ["foo", "bar"]
  143. assert self.run_completer(app, "app -v ") == ["foo", "bar"]
  144. assert self.run_completer(app, "app -v=") == ["foo", "bar"]
  145. assert self.run_completer(app, "app --CustomCls.val ") == ["foo", "bar"]
  146. assert self.run_completer(app, "app --CustomCls.val=") == ["foo", "bar"]
  147. completions = self.run_completer(app, "app --val= abc xyz", point=10)
  148. # fixed in argcomplete >= 2.0 to return latter below
  149. assert completions == ["--val=foo", "--val=bar"] or completions == ["foo", "bar"]
  150. assert self.run_completer(app, "app --val --log-level=", point=10) == ["foo", "bar"]
  151. def test_complete_subcommands(self, argcomplete_on):
  152. app = MainApp()
  153. assert set(self.run_completer(app, "app ")) >= {"subapp1", "subapp2"}
  154. assert set(self.run_completer(app, "app sub")) == {"subapp1", "subapp2"}
  155. assert set(self.run_completer(app, "app subapp1")) == {"subapp1"}
  156. def test_complete_subcommands_subapp1(self, argcomplete_on):
  157. # subcommand handling modifies _ARGCOMPLETE env var global state, so
  158. # only can test one completion per unit test
  159. app = MainApp()
  160. try:
  161. assert set(self.run_completer(app, "app subapp1 --Sub")) > {
  162. "--SubApp1.show_config",
  163. "--SubApp1.log_level",
  164. "--SubApp1.log_format",
  165. }
  166. finally:
  167. SubApp1.clear_instance()
  168. def test_complete_subcommands_subapp2(self, argcomplete_on):
  169. app = MainApp()
  170. try:
  171. assert set(self.run_completer(app, "app subapp2 --")) > {
  172. "--Application.",
  173. "--SubApp2.",
  174. }
  175. finally:
  176. SubApp2.clear_instance()
  177. def test_complete_subcommands_main(self, argcomplete_on):
  178. app = MainApp()
  179. completions = set(self.run_completer(app, "app --"))
  180. assert completions > {"--Application.", "--MainApp."}
  181. assert "--SubApp1." not in completions
  182. assert "--SubApp2." not in completions