aboutsummaryrefslogtreecommitdiffstats
path: root/contrib/python/traitlets/py3/tests/config/test_argcomplete.py
blob: 3f1ce482e88a93ee5ad7a4214ee741c8f1aceadb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""
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