123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- """
- Tests for argcomplete handling by traitlets.config.application.Application
- """
- # Copyright (c) IPython Development Team.
- # Distributed under the terms of the Modified BSD License.
- from __future__ import annotations
- import io
- import os
- import typing as t
- import pytest
- argcomplete = pytest.importorskip("argcomplete")
- from traitlets import Unicode
- from traitlets.config.application import Application
- from traitlets.config.configurable import Configurable
- from traitlets.config.loader import KVArgParseConfigLoader
- class ArgcompleteApp(Application):
- """Override loader to pass through kwargs for argcomplete testing"""
- argcomplete_kwargs: t.Dict[str, t.Any]
- def __init__(self, *args, **kwargs):
- # For subcommands, inherit argcomplete_kwargs from parent app
- parent = kwargs.get("parent")
- super().__init__(*args, **kwargs)
- if parent:
- argcomplete_kwargs = getattr(parent, "argcomplete_kwargs", None)
- if argcomplete_kwargs:
- self.argcomplete_kwargs = argcomplete_kwargs
- def _create_loader(self, argv, aliases, flags, classes):
- loader = KVArgParseConfigLoader(
- argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands
- )
- loader._argcomplete_kwargs = self.argcomplete_kwargs # type: ignore[attr-defined]
- return loader
- class SubApp1(ArgcompleteApp):
- pass
- class SubApp2(ArgcompleteApp):
- @classmethod
- def get_subapp_instance(cls, app: Application) -> Application:
- app.clear_instance() # since Application is singleton, need to clear main app
- return cls.instance(parent=app) # type: ignore[no-any-return]
- class MainApp(ArgcompleteApp):
- subcommands = {
- "subapp1": (SubApp1, "First subapp"),
- "subapp2": (SubApp2.get_subapp_instance, "Second subapp"),
- }
- class CustomError(Exception):
- """Helper for exit hook for testing argcomplete"""
- @classmethod
- def exit(cls, code):
- raise cls(str(code))
- class TestArgcomplete:
- IFS = "\013"
- COMP_WORDBREAKS = " \t\n\"'><=;|&(:"
- @pytest.fixture()
- def argcomplete_on(self, mocker):
- """Mostly borrowed from argcomplete's unit test fixtures
- Set up environment variables to mimic those passed by argcomplete
- """
- _old_environ = os.environ
- os.environ = os.environ.copy() # type: ignore[assignment]
- os.environ["_ARGCOMPLETE"] = "1"
- os.environ["_ARC_DEBUG"] = "yes"
- os.environ["IFS"] = self.IFS
- os.environ["_ARGCOMPLETE_COMP_WORDBREAKS"] = self.COMP_WORDBREAKS
- # argcomplete==2.0.0 always calls fdopen(9, "w") to open a debug stream,
- # however this could conflict with file descriptors used by pytest
- # and lead to obscure errors. Since we are not looking at debug stream
- # in these tests, just mock this fdopen call out.
- mocker.patch("os.fdopen")
- try:
- yield
- finally:
- os.environ = _old_environ
- def run_completer(
- self,
- app: ArgcompleteApp,
- command: str,
- point: t.Union[str, int, None] = None,
- **kwargs: t.Any,
- ) -> t.List[str]:
- """Mostly borrowed from argcomplete's unit tests
- Modified to take an application instead of an ArgumentParser
- Command is the current command being completed and point is the index
- into the command where the completion is triggered.
- """
- if point is None:
- point = str(len(command))
- # Flushing tempfile was leading to CI failures with Bad file descriptor, not sure why.
- # Fortunately we can just write to a StringIO instead.
- # print("Writing completions to temp file with mode=", write_mode)
- # from tempfile import TemporaryFile
- # with TemporaryFile(mode=write_mode) as t:
- strio = io.StringIO()
- os.environ["COMP_LINE"] = command
- os.environ["COMP_POINT"] = str(point)
- with pytest.raises(CustomError) as cm: # noqa: PT012
- app.argcomplete_kwargs = dict(
- output_stream=strio, exit_method=CustomError.exit, **kwargs
- )
- app.initialize()
- if str(cm.value) != "0":
- raise RuntimeError(f"Unexpected exit code {cm.value}")
- out = strio.getvalue()
- return out.split(self.IFS)
- def test_complete_simple_app(self, argcomplete_on):
- app = ArgcompleteApp()
- expected = [
- "--help",
- "--debug",
- "--show-config",
- "--show-config-json",
- "--log-level",
- "--Application.",
- "--ArgcompleteApp.",
- ]
- assert set(self.run_completer(app, "app --")) == set(expected)
- # completing class traits
- assert set(self.run_completer(app, "app --App")) > {
- "--Application.show_config",
- "--Application.log_level",
- "--Application.log_format",
- }
- def test_complete_custom_completers(self, argcomplete_on):
- app = ArgcompleteApp()
- # test pre-defined completers for Bool/Enum
- assert set(self.run_completer(app, "app --Application.log_level=")) > {"DEBUG", "INFO"}
- assert set(self.run_completer(app, "app --ArgcompleteApp.show_config ")) == {
- "0",
- "1",
- "true",
- "false",
- }
- # test custom completer and mid-command completions
- class CustomCls(Configurable):
- val = Unicode().tag(
- config=True, argcompleter=argcomplete.completers.ChoicesCompleter(["foo", "bar"])
- )
- class CustomApp(ArgcompleteApp):
- classes = [CustomCls]
- aliases = {("v", "val"): "CustomCls.val"}
- app = CustomApp()
- assert self.run_completer(app, "app --val ") == ["foo", "bar"]
- assert self.run_completer(app, "app --val=") == ["foo", "bar"]
- assert self.run_completer(app, "app -v ") == ["foo", "bar"]
- assert self.run_completer(app, "app -v=") == ["foo", "bar"]
- assert self.run_completer(app, "app --CustomCls.val ") == ["foo", "bar"]
- assert self.run_completer(app, "app --CustomCls.val=") == ["foo", "bar"]
- completions = self.run_completer(app, "app --val= abc xyz", point=10)
- # fixed in argcomplete >= 2.0 to return latter below
- assert completions == ["--val=foo", "--val=bar"] or completions == ["foo", "bar"]
- assert self.run_completer(app, "app --val --log-level=", point=10) == ["foo", "bar"]
- def test_complete_subcommands(self, argcomplete_on):
- app = MainApp()
- assert set(self.run_completer(app, "app ")) >= {"subapp1", "subapp2"}
- assert set(self.run_completer(app, "app sub")) == {"subapp1", "subapp2"}
- assert set(self.run_completer(app, "app subapp1")) == {"subapp1"}
- def test_complete_subcommands_subapp1(self, argcomplete_on):
- # subcommand handling modifies _ARGCOMPLETE env var global state, so
- # only can test one completion per unit test
- app = MainApp()
- try:
- assert set(self.run_completer(app, "app subapp1 --Sub")) > {
- "--SubApp1.show_config",
- "--SubApp1.log_level",
- "--SubApp1.log_format",
- }
- finally:
- SubApp1.clear_instance()
- def test_complete_subcommands_subapp2(self, argcomplete_on):
- app = MainApp()
- try:
- assert set(self.run_completer(app, "app subapp2 --")) > {
- "--Application.",
- "--SubApp2.",
- }
- finally:
- SubApp2.clear_instance()
- def test_complete_subcommands_main(self, argcomplete_on):
- app = MainApp()
- completions = set(self.run_completer(app, "app --"))
- assert completions > {"--Application.", "--MainApp."}
- assert "--SubApp1." not in completions
- assert "--SubApp2." not in completions
|