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