From ed70897c396e9bd8c7d5854d2b1c1ea618241bda Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 6 Jan 2025 01:07:13 +0200 Subject: [PATCH] fix: suppress traceback in shell if no ipython installed Old implementation was showing import error, new implementation instead hides it behind separated call and if-else check --- src/ahriman/application/interactive_shell.py | 18 ++++++++++++-- .../application/test_interactive_shell.py | 24 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/ahriman/application/interactive_shell.py b/src/ahriman/application/interactive_shell.py index 0c28d5c4..0170920f 100644 --- a/src/ahriman/application/interactive_shell.py +++ b/src/ahriman/application/interactive_shell.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # from code import InteractiveConsole +from importlib.util import find_spec from typing import Any @@ -26,6 +27,19 @@ class InteractiveShell(InteractiveConsole): wrapper around :class:`code.InteractiveConsole` to pass :func:`interact()` to IPython shell """ + @staticmethod + def has_ipython() -> bool: + """ + check if IPython shell is available + + Returns: + bool: ``True`` if IPython shell is available, ``False`` otherwise + """ + try: + return find_spec("IPython.terminal.embed") is not None + except ModuleNotFoundError: + return False + def interact(self, *args: Any, **kwargs: Any) -> None: """ pass controller to IPython shell @@ -34,13 +48,13 @@ class InteractiveShell(InteractiveConsole): *args(Any): positional arguments **kwargs(Any): keyword arguments """ - try: + if self.has_ipython(): from IPython.terminal.embed import InteractiveShellEmbed shell = InteractiveShellEmbed(user_ns=self.locals) # type: ignore[no-untyped-call] shell.show_banner() # type: ignore[no-untyped-call] shell.interact() # type: ignore[no-untyped-call] - except ImportError: + else: # fallback to default import readline # pylint: disable=unused-import InteractiveConsole.interact(self, *args, **kwargs) diff --git a/tests/ahriman/application/test_interactive_shell.py b/tests/ahriman/application/test_interactive_shell.py index cb5c2589..b0f75d60 100644 --- a/tests/ahriman/application/test_interactive_shell.py +++ b/tests/ahriman/application/test_interactive_shell.py @@ -1,14 +1,30 @@ -import pytest - from pytest_mock import MockerFixture from ahriman.application.interactive_shell import InteractiveShell +def test_has_ipython(mocker: MockerFixture) -> None: + """ + must correctly check if IPython is installed + """ + find_spec_mock = mocker.patch("ahriman.application.interactive_shell.find_spec") + assert InteractiveShell.has_ipython() + find_spec_mock.assert_called_once_with("IPython.terminal.embed") + + +def test_has_ipython_module_not_found(mocker: MockerFixture) -> None: + """ + must return False if IPython is not installed + """ + mocker.patch("ahriman.application.interactive_shell.find_spec", side_effect=ModuleNotFoundError) + assert not InteractiveShell.has_ipython() + + def test_interact(mocker: MockerFixture) -> None: """ must call IPython shell """ + mocker.patch("ahriman.application.interactive_shell.InteractiveShell.has_ipython", return_value=True) banner_mock = mocker.patch("IPython.terminal.embed.InteractiveShellEmbed.show_banner") interact_mock = mocker.patch("IPython.terminal.embed.InteractiveShellEmbed.interact") @@ -18,11 +34,11 @@ def test_interact(mocker: MockerFixture) -> None: interact_mock.assert_called_once_with() -def test_interact_import_error(mocker: MockerFixture) -> None: +def test_interact_no_ipython(mocker: MockerFixture) -> None: """ must call builtin shell if no IPython available """ - pytest.helpers.import_error("IPython.terminal.embed", ["InteractiveShellEmbed"], mocker) + mocker.patch("ahriman.application.interactive_shell.InteractiveShell.has_ipython", return_value=None) interact_mock = mocker.patch("code.InteractiveConsole.interact") shell = InteractiveShell()