From 85d2baee5dfd6b23f22da575eb09aa308dcfe78f Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 19 Feb 2024 13:05:38 +0000 Subject: [PATCH 1/5] Raise CLI errors in debug mode --- kasa/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index e922ec81c..7879ef21c 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -102,7 +102,10 @@ def __call__(self, *args, **kwargs): asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) except Exception as ex: echo(f"Got error: {ex!r}") - raise + if "--debug" in sys.argv or "-d" in sys.argv: + raise + else: + echo("Run with --debug enabled to see stacktrace") def json_formatter_cb(result, **kwargs): From a1b7644553ec57456ebb013923651ba9220575ae Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 19 Feb 2024 13:59:46 +0000 Subject: [PATCH 2/5] Remove else --- kasa/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 7879ef21c..4593e9e3e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -104,8 +104,7 @@ def __call__(self, *args, **kwargs): echo(f"Got error: {ex!r}") if "--debug" in sys.argv or "-d" in sys.argv: raise - else: - echo("Run with --debug enabled to see stacktrace") + echo("Run with --debug enabled to see stacktrace") def json_formatter_cb(result, **kwargs): From a22e5f50b54f07de5adca21c648becdf443f483a Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 19 Feb 2024 16:30:40 +0000 Subject: [PATCH 3/5] Update click command handler class to enable testing --- kasa/cli.py | 65 +++++++++++++++++++++++++++++++++--------- kasa/tests/test_cli.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4593e9e3e..b96a71476 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -90,21 +90,60 @@ def wrapper(message=None, *args, **kwargs): pass_dev = click.make_pass_decorator(Device) -class ExceptionHandlerGroup(click.Group): - """Group to capture all exceptions and print them nicely. +def CatchAllExceptions(cls): + """Capture all exceptions and prints them nicely. - Idea from https://stackoverflow.com/a/44347763 + Idea from https://stackoverflow.com/a/44347763 and + https://stackoverflow.com/questions/52213375 """ - def __call__(self, *args, **kwargs): - """Run the coroutine in the event loop and print any exceptions.""" - try: - asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) - except Exception as ex: - echo(f"Got error: {ex!r}") - if "--debug" in sys.argv or "-d" in sys.argv: - raise - echo("Run with --debug enabled to see stacktrace") + def _handle_exception(cmd, info_name, exc): + if isinstance(exc, click.ClickException): + raise + echo(f"Kasa:: Command line: {info_name} {cmd._masked_args}") + echo(f"Kasa:: Raised error: {exc}") + if cmd._debug: + raise + echo("Run with --debug enabled to see stacktrace") + sys.exit(1) + + class Cls(cls): + _masked_args = None + _debug = False + + def _parse_args(self, args): + masked_args = [] + pw_index = un_index = None + for index, arg in enumerate(args): + if arg in ["--password", "-p"]: + pw_index = index + 1 + if arg in ["--username", "-u"]: + un_index = index + 1 + if arg in ["--debug", "-d", "--verbose", "-v"]: + self._debug = True + masked_args.append(str(arg)) + if pw_index: + masked_args[pw_index] = "PASSWORD" + if un_index: + masked_args[un_index] = "USERNAME" + self._masked_args = " ".join(masked_args) + + async def make_context(self, info_name, args, parent=None, **extra): + self._parse_args(args) + try: + return await super().make_context( + info_name, args, parent=parent, **extra + ) + except Exception as exc: + _handle_exception(self, info_name, exc) + + async def invoke(self, ctx): + try: + return await super().invoke(ctx) + except Exception as exc: + _handle_exception(self, ctx.info_name, exc) + + return Cls def json_formatter_cb(result, **kwargs): @@ -131,7 +170,7 @@ def _device_to_serializable(val: Device): @click.group( invoke_without_command=True, - cls=ExceptionHandlerGroup, + cls=CatchAllExceptions(click.Group), result_callback=json_formatter_cb, ) @click.option( diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 51155f407..33cfd136c 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -610,3 +610,53 @@ async def test_shell(dev: Device, mocker): res = await runner.invoke(cli, ["shell"], obj=dev) assert res.exit_code == 0 embed.assert_called() + + +async def test_error(mocker): + runner = CliRunner() + err = SmartDeviceException("Foobar") + + # Test masking + mocker.patch("kasa.Discover.discover", side_effect=err) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar"], + ) + assert res.exit_code == 1 + assert ( + "Kasa:: Command line: cli --username USERNAME --password PASSWORD" in res.output + ) + assert "Kasa:: Raised error: Foobar" in res.output + assert "SmartDeviceException" not in res.output + + # Test --debug + res = await runner.invoke( + cli, + ["--debug"], + ) + assert res.exit_code == 1 + assert "Kasa:: Command line: cli --debug" in res.output + assert "Kasa:: Raised error: Foobar" in res.output + assert res.exception == err + + # Test no device passed to subcommand + mocker.patch("kasa.Discover.discover", return_value={}) + res = await runner.invoke( + cli, + ["sysinfo"], + ) + assert res.exit_code == 1 + assert "Kasa:: Command line: cli sysinfo" in res.output + assert ( + "Kasa:: Raised error: Managed to invoke callback without a context object of type 'Device' existing." + in res.output + ) + assert isinstance(res.exception, RuntimeError) + + # Test click error + res = await runner.invoke( + cli, + ["foobar"], + ) + assert res.exit_code == 2 + assert "Kasa:: Raised error:" not in res.output From 867f8a1f485f74764a9eab49ab2bfb380bc117e5 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 19 Feb 2024 18:46:07 +0000 Subject: [PATCH 4/5] Fix coverage issue --- kasa/cli.py | 6 ++++-- kasa/tests/test_cli.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index b96a71476..1c7125a65 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -108,11 +108,12 @@ def _handle_exception(cmd, info_name, exc): sys.exit(1) class Cls(cls): - _masked_args = None _debug = False + _masked_args = None def _parse_args(self, args): masked_args = [] + debug = False pw_index = un_index = None for index, arg in enumerate(args): if arg in ["--password", "-p"]: @@ -120,13 +121,14 @@ def _parse_args(self, args): if arg in ["--username", "-u"]: un_index = index + 1 if arg in ["--debug", "-d", "--verbose", "-v"]: - self._debug = True + debug = True masked_args.append(str(arg)) if pw_index: masked_args[pw_index] = "PASSWORD" if un_index: masked_args[un_index] = "USERNAME" self._masked_args = " ".join(masked_args) + self._debug = debug async def make_context(self, info_name, args, parent=None, **extra): self._parse_args(args) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 33cfd136c..51f1046aa 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -504,6 +504,7 @@ async def test_host_unsupported(unsupported_device_info): "foo", "--password", "bar", + "--debug", ], ) @@ -563,6 +564,7 @@ async def test_host_auth_failed(discovery_mock, mocker): "foo", "--password", "bar", + "--debug", ], ) @@ -612,7 +614,7 @@ async def test_shell(dev: Device, mocker): embed.assert_called() -async def test_error(mocker): +async def test_errors(mocker): runner = CliRunner() err = SmartDeviceException("Foobar") @@ -628,6 +630,8 @@ async def test_error(mocker): ) assert "Kasa:: Raised error: Foobar" in res.output assert "SmartDeviceException" not in res.output + assert "Run with --debug enabled to see stacktrace" in res.output + print(res.output) # Test --debug res = await runner.invoke( @@ -651,12 +655,12 @@ async def test_error(mocker): "Kasa:: Raised error: Managed to invoke callback without a context object of type 'Device' existing." in res.output ) - assert isinstance(res.exception, RuntimeError) + assert isinstance(res.exception, SystemExit) # Test click error res = await runner.invoke( cli, - ["foobar"], + ["--foobar"], ) assert res.exit_code == 2 assert "Kasa:: Raised error:" not in res.output From e0a66b2f176a2b19b171d19e84b7137654cdf43c Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Tue, 20 Feb 2024 08:14:38 +0000 Subject: [PATCH 5/5] Update post review --- kasa/cli.py | 39 ++++++++++----------------------------- kasa/tests/test_cli.py | 16 +++++----------- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 433bd73b1..b075866b0 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -97,55 +97,36 @@ def CatchAllExceptions(cls): https://stackoverflow.com/questions/52213375 """ - def _handle_exception(cmd, info_name, exc): + def _handle_exception(debug, exc): if isinstance(exc, click.ClickException): raise - echo(f"Kasa:: Command line: {info_name} {cmd._masked_args}") - echo(f"Kasa:: Raised error: {exc}") - if cmd._debug: + echo(f"Raised error: {exc}") + if debug: raise echo("Run with --debug enabled to see stacktrace") sys.exit(1) - class Cls(cls): + class _CommandCls(cls): _debug = False - _masked_args = None - - def _parse_args(self, args): - masked_args = [] - debug = False - pw_index = un_index = None - for index, arg in enumerate(args): - if arg in ["--password", "-p"]: - pw_index = index + 1 - if arg in ["--username", "-u"]: - un_index = index + 1 - if arg in ["--debug", "-d", "--verbose", "-v"]: - debug = True - masked_args.append(str(arg)) - if pw_index: - masked_args[pw_index] = "PASSWORD" - if un_index: - masked_args[un_index] = "USERNAME" - self._masked_args = " ".join(masked_args) - self._debug = debug async def make_context(self, info_name, args, parent=None, **extra): - self._parse_args(args) + self._debug = any( + [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] + ) try: return await super().make_context( info_name, args, parent=parent, **extra ) except Exception as exc: - _handle_exception(self, info_name, exc) + _handle_exception(self._debug, exc) async def invoke(self, ctx): try: return await super().invoke(ctx) except Exception as exc: - _handle_exception(self, ctx.info_name, exc) + _handle_exception(self._debug, exc) - return Cls + return _CommandCls def json_formatter_cb(result, **kwargs): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 51f1046aa..2e776c1dc 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -625,13 +625,9 @@ async def test_errors(mocker): ["--username", "foo", "--password", "bar"], ) assert res.exit_code == 1 - assert ( - "Kasa:: Command line: cli --username USERNAME --password PASSWORD" in res.output - ) - assert "Kasa:: Raised error: Foobar" in res.output - assert "SmartDeviceException" not in res.output + assert "Raised error: Foobar" in res.output assert "Run with --debug enabled to see stacktrace" in res.output - print(res.output) + assert isinstance(res.exception, SystemExit) # Test --debug res = await runner.invoke( @@ -639,8 +635,7 @@ async def test_errors(mocker): ["--debug"], ) assert res.exit_code == 1 - assert "Kasa:: Command line: cli --debug" in res.output - assert "Kasa:: Raised error: Foobar" in res.output + assert "Raised error: Foobar" in res.output assert res.exception == err # Test no device passed to subcommand @@ -650,9 +645,8 @@ async def test_errors(mocker): ["sysinfo"], ) assert res.exit_code == 1 - assert "Kasa:: Command line: cli sysinfo" in res.output assert ( - "Kasa:: Raised error: Managed to invoke callback without a context object of type 'Device' existing." + "Raised error: Managed to invoke callback without a context object of type 'Device' existing." in res.output ) assert isinstance(res.exception, SystemExit) @@ -663,4 +657,4 @@ async def test_errors(mocker): ["--foobar"], ) assert res.exit_code == 2 - assert "Kasa:: Raised error:" not in res.output + assert "Raised error:" not in res.output