1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2015-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
4#
5# This file is part of qutebrowser.
6#
7# qutebrowser is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# qutebrowser is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.
19
20# pylint: disable=unused-variable
21
22"""Tests for qutebrowser.api.cmdutils."""
23
24import sys
25import logging
26import types
27import enum
28import textwrap
29
30import pytest
31
32from qutebrowser.misc import objects
33from qutebrowser.commands import cmdexc, argparser, command
34from qutebrowser.api import cmdutils
35from qutebrowser.utils import usertypes
36
37
38@pytest.fixture(autouse=True)
39def clear_globals(monkeypatch):
40    monkeypatch.setattr(objects, 'commands', {})
41
42
43def _get_cmd(*args, **kwargs):
44    """Get a command object created via @cmdutils.register.
45
46    Args:
47        Passed to @cmdutils.register decorator
48    """
49    @cmdutils.register(*args, **kwargs)
50    def fun():
51        """Blah."""
52    return objects.commands['fun']
53
54
55class TestCheckOverflow:
56
57    def test_good(self):
58        cmdutils.check_overflow(1, 'int')
59
60    def test_bad(self):
61        int32_max = 2 ** 31 - 1
62
63        with pytest.raises(cmdutils.CommandError, match="Numeric argument is "
64                           "too large for internal int representation."):
65            cmdutils.check_overflow(int32_max + 1, 'int')
66
67
68class TestCheckExclusive:
69
70    @pytest.mark.parametrize('flags', [[], [False, True], [False, False]])
71    def test_good(self, flags):
72        cmdutils.check_exclusive(flags, [])
73
74    def test_bad(self):
75        with pytest.raises(cmdutils.CommandError,
76                           match="Only one of -x/-y/-z can be given!"):
77            cmdutils.check_exclusive([True, True], 'xyz')
78
79
80class TestRegister:
81
82    def test_simple(self):
83        @cmdutils.register()
84        def fun():
85            """Blah."""
86
87        cmd = objects.commands['fun']
88        assert cmd.handler is fun
89        assert cmd.name == 'fun'
90        assert len(objects.commands) == 1
91
92    def test_underlines(self):
93        """Make sure the function name is normalized correctly (_ -> -)."""
94        @cmdutils.register()
95        def eggs_bacon():
96            """Blah."""
97
98        assert objects.commands['eggs-bacon'].name == 'eggs-bacon'
99        assert 'eggs_bacon' not in objects.commands
100
101    def test_lowercasing(self):
102        """Make sure the function name is normalized correctly (uppercase)."""
103        @cmdutils.register()
104        def Test():  # noqa: N801,N806 pylint: disable=invalid-name
105            """Blah."""
106
107        assert objects.commands['test'].name == 'test'
108        assert 'Test' not in objects.commands
109
110    def test_explicit_name(self):
111        """Test register with explicit name."""
112        @cmdutils.register(name='foobar')
113        def fun():
114            """Blah."""
115
116        assert objects.commands['foobar'].name == 'foobar'
117        assert 'fun' not in objects.commands
118        assert len(objects.commands) == 1
119
120    def test_multiple_registrations(self):
121        """Make sure registering the same name twice raises ValueError."""
122        @cmdutils.register(name='foobar')
123        def fun():
124            """Blah."""
125
126        with pytest.raises(ValueError):
127            @cmdutils.register(name='foobar')
128            def fun2():
129                """Blah."""
130
131    def test_instance(self):
132        """Make sure the instance gets passed to Command."""
133        @cmdutils.register(instance='foobar')
134        def fun(self):
135            """Blah."""
136        assert objects.commands['fun']._instance == 'foobar'
137
138    def test_star_args(self):
139        """Check handling of *args."""
140        @cmdutils.register()
141        def fun(*args):
142            """Blah."""
143            assert args == ['one', 'two']
144
145        objects.commands['fun'].parser.parse_args(['one', 'two'])
146
147    def test_star_args_empty(self):
148        """Check handling of *args without any value."""
149        @cmdutils.register()
150        def fun(*args):
151            """Blah."""
152            assert not args
153
154        with pytest.raises(argparser.ArgumentParserError):
155            objects.commands['fun'].parser.parse_args([])
156
157    def test_star_args_type(self):
158        """Check handling of *args with a type.
159
160        This isn't implemented, so be sure we catch it.
161        """
162        with pytest.raises(TypeError):
163            @cmdutils.register()
164            def fun(*args: int):
165                """Blah."""
166
167    def test_star_args_optional(self):
168        """Check handling of *args withstar_args_optional."""
169        @cmdutils.register(star_args_optional=True)
170        def fun(*args):
171            """Blah."""
172            assert not args
173        cmd = objects.commands['fun']
174        cmd.namespace = cmd.parser.parse_args([])
175        args, kwargs = cmd._get_call_args(win_id=0)
176        fun(*args, **kwargs)
177
178    def test_star_args_optional_annotated(self):
179        @cmdutils.register(star_args_optional=True)
180        def fun(*args: str):
181            """Blah."""
182
183        cmd = objects.commands['fun']
184        cmd.namespace = cmd.parser.parse_args([])
185        cmd._get_call_args(win_id=0)
186
187    @pytest.mark.parametrize('inp, expected', [
188        (['--arg'], True), (['-a'], True), ([], False)])
189    def test_flag(self, inp, expected):
190        @cmdutils.register()
191        def fun(arg=False):
192            """Blah."""
193            assert arg == expected
194        cmd = objects.commands['fun']
195        cmd.namespace = cmd.parser.parse_args(inp)
196        assert cmd.namespace.arg == expected
197
198    def test_flag_argument(self):
199        @cmdutils.register()
200        @cmdutils.argument('arg', flag='b')
201        def fun(arg=False):
202            """Blah."""
203            assert arg
204        cmd = objects.commands['fun']
205
206        with pytest.raises(argparser.ArgumentParserError):
207            cmd.parser.parse_args(['-a'])
208
209        cmd.namespace = cmd.parser.parse_args(['-b'])
210        assert cmd.namespace.arg
211        args, kwargs = cmd._get_call_args(win_id=0)
212        fun(*args, **kwargs)
213
214    def test_self_without_instance(self):
215        with pytest.raises(TypeError, match="fun is a class method, but "
216                           "instance was not given!"):
217            @cmdutils.register()
218            def fun(self):
219                """Blah."""
220
221    def test_instance_without_self(self):
222        with pytest.raises(TypeError, match="fun is not a class method, but "
223                           "instance was given!"):
224            @cmdutils.register(instance='inst')
225            def fun():
226                """Blah."""
227
228    def test_var_kw(self):
229        with pytest.raises(TypeError, match="fun: functions with varkw "
230                           "arguments are not supported!"):
231            @cmdutils.register()
232            def fun(**kwargs):
233                """Blah."""
234
235    def test_partial_arg(self):
236        """Test with only some arguments decorated with @cmdutils.argument."""
237        @cmdutils.register()
238        @cmdutils.argument('arg1', flag='b')
239        def fun(arg1=False, arg2=False):
240            """Blah."""
241
242    def test_win_id(self):
243        @cmdutils.register()
244        @cmdutils.argument('win_id', value=cmdutils.Value.win_id)
245        def fun(win_id):
246            """Blah."""
247        assert objects.commands['fun']._get_call_args(42) == ([42], {})
248
249    def test_count(self):
250        @cmdutils.register()
251        @cmdutils.argument('count', value=cmdutils.Value.count)
252        def fun(count=0):
253            """Blah."""
254        assert objects.commands['fun']._get_call_args(42) == ([0], {})
255
256    def test_fill_self(self):
257        with pytest.raises(TypeError, match="fun: Can't fill 'self' with "
258                           "value!"):
259            @cmdutils.register(instance='foobar')
260            @cmdutils.argument('self', value=cmdutils.Value.count)
261            def fun(self):
262                """Blah."""
263
264    def test_fill_invalid(self):
265        with pytest.raises(TypeError, match="fun: Invalid value='foo' for "
266                           "argument 'arg'!"):
267            @cmdutils.register()
268            @cmdutils.argument('arg', value='foo')
269            def fun(arg):
270                """Blah."""
271
272    def test_count_without_default(self):
273        with pytest.raises(TypeError, match="fun: handler has count parameter "
274                           "without default!"):
275            @cmdutils.register()
276            @cmdutils.argument('count', value=cmdutils.Value.count)
277            def fun(count):
278                """Blah."""
279
280    @pytest.mark.parametrize('hide', [True, False])
281    def test_pos_args(self, hide):
282        @cmdutils.register()
283        @cmdutils.argument('arg', hide=hide)
284        def fun(arg):
285            """Blah."""
286
287        pos_args = objects.commands['fun'].pos_args
288        if hide:
289            assert pos_args == []
290        else:
291            assert pos_args == [('arg', 'arg')]
292
293    class Enum(enum.Enum):
294
295        # pylint: disable=invalid-name
296        x = enum.auto()
297        y = enum.auto()
298
299    @pytest.mark.parametrize('annotation, inp, choices, expected', [
300        ('int', '42', None, 42),
301        ('int', 'x', None, cmdexc.ArgumentTypeError),
302        ('str', 'foo', None, 'foo'),
303
304        ('Union[str, int]', 'foo', None, 'foo'),
305        ('Union[str, int]', '42', None, 42),
306
307        # Choices
308        ('str', 'foo', ['foo'], 'foo'),
309        ('str', 'bar', ['foo'], cmdexc.ArgumentTypeError),
310
311        # Choices with Union: only checked when it's a str
312        ('Union[str, int]', 'foo', ['foo'], 'foo'),
313        ('Union[str, int]', 'bar', ['foo'], cmdexc.ArgumentTypeError),
314        ('Union[str, int]', '42', ['foo'], 42),
315
316        ('Enum', 'x', None, Enum.x),
317        ('Enum', 'z', None, cmdexc.ArgumentTypeError),
318    ])
319    def test_typed_args(self, annotation, inp, choices, expected):
320        src = textwrap.dedent("""
321        from typing import Union
322        from qutebrowser.api import cmdutils
323
324        @cmdutils.register()
325        @cmdutils.argument('arg', choices=choices)
326        def fun(arg: {annotation}):
327            '''Blah.'''
328            return arg
329        """.format(annotation=annotation))
330        code = compile(src, '<string>', 'exec')
331        print(src)
332        ns = {'choices': choices, 'Enum': self.Enum}
333        exec(code, ns, ns)
334        fun = ns['fun']
335
336        cmd = objects.commands['fun']
337        cmd.namespace = cmd.parser.parse_args([inp])
338
339        if expected is cmdexc.ArgumentTypeError:
340            with pytest.raises(cmdexc.ArgumentTypeError):
341                cmd._get_call_args(win_id=0)
342        else:
343            args, kwargs = cmd._get_call_args(win_id=0)
344            assert args == [expected]
345            assert kwargs == {}
346            ret = fun(*args, **kwargs)
347            assert ret == expected
348
349    def test_choices_no_annotation(self):
350        # https://github.com/qutebrowser/qutebrowser/issues/1871
351        @cmdutils.register()
352        @cmdutils.argument('arg', choices=['foo', 'bar'])
353        def fun(arg):
354            """Blah."""
355
356        cmd = objects.commands['fun']
357        cmd.namespace = cmd.parser.parse_args(['fish'])
358
359        with pytest.raises(cmdexc.ArgumentTypeError):
360            cmd._get_call_args(win_id=0)
361
362    def test_choices_no_annotation_kwonly(self):
363        # https://github.com/qutebrowser/qutebrowser/issues/1871
364        @cmdutils.register()
365        @cmdutils.argument('arg', choices=['foo', 'bar'])
366        def fun(*, arg='foo'):
367            """Blah."""
368
369        cmd = objects.commands['fun']
370        cmd.namespace = cmd.parser.parse_args(['--arg=fish'])
371
372        with pytest.raises(cmdexc.ArgumentTypeError):
373            cmd._get_call_args(win_id=0)
374
375    def test_pos_arg_info(self):
376        @cmdutils.register()
377        @cmdutils.argument('foo', choices=('a', 'b'))
378        @cmdutils.argument('bar', choices=('x', 'y'))
379        @cmdutils.argument('opt')
380        def fun(foo, bar, opt=False):
381            """Blah."""
382
383        cmd = objects.commands['fun']
384        assert cmd.get_pos_arg_info(0) == command.ArgInfo(choices=('a', 'b'))
385        assert cmd.get_pos_arg_info(1) == command.ArgInfo(choices=('x', 'y'))
386        with pytest.raises(IndexError):
387            cmd.get_pos_arg_info(2)
388
389    def test_keyword_only_without_default(self):
390        # https://github.com/qutebrowser/qutebrowser/issues/1872
391        def fun(*, target):
392            """Blah."""
393
394        with pytest.raises(TypeError, match="fun: handler has keyword only "
395                           "argument 'target' without default!"):
396            fun = cmdutils.register()(fun)
397
398    def test_typed_keyword_only_without_default(self):
399        # https://github.com/qutebrowser/qutebrowser/issues/1872
400        def fun(*, target: int):
401            """Blah."""
402
403        with pytest.raises(TypeError, match="fun: handler has keyword only "
404                           "argument 'target' without default!"):
405            fun = cmdutils.register()(fun)
406
407
408class TestArgument:
409
410    """Test the @cmdutils.argument decorator."""
411
412    def test_invalid_argument(self):
413        with pytest.raises(ValueError, match="fun has no argument foo!"):
414            @cmdutils.argument('foo')
415            def fun(bar):
416                """Blah."""
417
418    def test_storage(self):
419        @cmdutils.argument('foo', flag='x')
420        @cmdutils.argument('bar', flag='y')
421        def fun(foo, bar):
422            """Blah."""
423        expected = {
424            'foo': command.ArgInfo(flag='x'),
425            'bar': command.ArgInfo(flag='y')
426        }
427        assert fun.qute_args == expected
428
429    def test_arginfo_boolean(self):
430        @cmdutils.argument('special1', value=cmdutils.Value.count)
431        @cmdutils.argument('special2', value=cmdutils.Value.win_id)
432        @cmdutils.argument('normal')
433        def fun(special1, special2, normal):
434            """Blah."""
435
436        assert fun.qute_args['special1'].value
437        assert fun.qute_args['special2'].value
438        assert not fun.qute_args['normal'].value
439
440    def test_wrong_order(self):
441        """When @cmdutils.argument is used above (after) @register, fail."""
442        with pytest.raises(ValueError, match=r"@cmdutils.argument got called "
443                           r"above \(after\) @cmdutils.register for fun!"):
444            @cmdutils.argument('bar', flag='y')
445            @cmdutils.register()
446            def fun(bar):
447                """Blah."""
448
449    def test_no_docstring(self, caplog):
450        with caplog.at_level(logging.WARNING):
451            @cmdutils.register()
452            def fun():
453                # no docstring
454                pass
455
456        assert len(caplog.records) == 1
457        assert caplog.messages[0].endswith('test_cmdutils.py has no docstring')
458
459    def test_no_docstring_with_optimize(self, monkeypatch):
460        """With -OO we'd get a warning on start, but no warning afterwards."""
461        monkeypatch.setattr(sys, 'flags', types.SimpleNamespace(optimize=2))
462
463        @cmdutils.register()
464        def fun():
465            # no docstring
466            pass
467
468
469class TestRun:
470
471    @pytest.fixture(autouse=True)
472    def patch_backend(self, mode_manager, monkeypatch):
473        monkeypatch.setattr(command.objects, 'backend',
474                            usertypes.Backend.QtWebKit)
475
476    @pytest.mark.parametrize('backend, used, ok', [
477        (usertypes.Backend.QtWebEngine, usertypes.Backend.QtWebEngine, True),
478        (usertypes.Backend.QtWebEngine, usertypes.Backend.QtWebKit, False),
479        (usertypes.Backend.QtWebKit, usertypes.Backend.QtWebEngine, False),
480        (usertypes.Backend.QtWebKit, usertypes.Backend.QtWebKit, True),
481        (None, usertypes.Backend.QtWebEngine, True),
482        (None, usertypes.Backend.QtWebKit, True),
483    ])
484    def test_backend(self, monkeypatch, backend, used, ok):
485        monkeypatch.setattr(command.objects, 'backend', used)
486        cmd = _get_cmd(backend=backend)
487        if ok:
488            cmd.run(win_id=0)
489        else:
490            with pytest.raises(cmdexc.PrerequisitesError,
491                               match=r'.* backend\.'):
492                cmd.run(win_id=0)
493
494    def test_no_args(self):
495        cmd = _get_cmd()
496        cmd.run(win_id=0)
497
498    def test_instance_unavailable_with_backend(self, monkeypatch):
499        """Test what happens when a backend doesn't have an objreg object.
500
501        For example, QtWebEngine doesn't have 'hintmanager' registered. We make
502        sure the backend checking happens before resolving the instance, so we
503        display an error instead of crashing.
504        """
505        @cmdutils.register(instance='doesnotexist',
506                           backend=usertypes.Backend.QtWebEngine)
507        def fun(self):
508            """Blah."""
509
510        monkeypatch.setattr(command.objects, 'backend',
511                            usertypes.Backend.QtWebKit)
512        cmd = objects.commands['fun']
513        with pytest.raises(cmdexc.PrerequisitesError, match=r'.* backend\.'):
514            cmd.run(win_id=0)
515
516    def test_deprecated(self, caplog, message_mock):
517        cmd = _get_cmd(deprecated='use something else')
518        with caplog.at_level(logging.WARNING):
519            cmd.run(win_id=0)
520
521        msg = message_mock.getmsg(usertypes.MessageLevel.warning)
522        assert msg.text == 'fun is deprecated - use something else'
523
524    def test_deprecated_name(self, caplog, message_mock):
525        @cmdutils.register(deprecated_name='dep')
526        def fun():
527            """Blah."""
528
529        original_cmd = objects.commands['fun']
530        original_cmd.run(win_id=0)
531
532        deprecated_cmd = objects.commands['dep']
533        with caplog.at_level(logging.WARNING):
534            deprecated_cmd.run(win_id=0)
535
536        msg = message_mock.getmsg(usertypes.MessageLevel.warning)
537        assert msg.text == 'dep is deprecated - use fun instead'
538