From 4880ca4fee7732822a973c97b8f1b1cfd77ba068 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Fri, 13 Dec 2024 15:15:53 +0200 Subject: [PATCH] feat: use IPython shell if available --- .github/workflows/setup.sh | 2 +- docs/ahriman.application.rst | 8 ++++ package/archlinux/PKGBUILD | 1 + pyproject.toml | 3 ++ src/ahriman/application/handlers/shell.py | 13 ++++-- src/ahriman/application/interactive_shell.py | 46 +++++++++++++++++++ .../handlers/test_handler_shell.py | 10 ++-- .../application/test_interactive_shell.py | 30 ++++++++++++ tox.ini | 2 +- 9 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 src/ahriman/application/interactive_shell.py create mode 100644 tests/ahriman/application/test_interactive_shell.py diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh index 1d16d87f..2a52bfc2 100755 --- a/.github/workflows/setup.sh +++ b/.github/workflows/setup.sh @@ -18,7 +18,7 @@ if [[ -z $MINIMAL_INSTALL ]]; then # web server pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-cryptography python-jinja # additional features - pacman -S --noconfirm gnupg python-boto3 python-cerberus python-matplotlib rsync + pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync fi # FIXME since 1.0.4 devtools requires dbus to be run, which doesn't work now in container cp "docker/systemd-nspawn.sh" "/usr/local/bin/systemd-nspawn" diff --git a/docs/ahriman.application.rst b/docs/ahriman.application.rst index 9496d491..59c29cfe 100644 --- a/docs/ahriman.application.rst +++ b/docs/ahriman.application.rst @@ -29,6 +29,14 @@ ahriman.application.help\_formatter module :no-undoc-members: :show-inheritance: +ahriman.application.interactive\_shell module +--------------------------------------------- + +.. automodule:: ahriman.application.interactive_shell + :members: + :no-undoc-members: + :show-inheritance: + ahriman.application.lock module ------------------------------- diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 1b4922b3..446bd95a 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -30,6 +30,7 @@ package_ahriman-core() { pkgname='ahriman-core' optdepends=('ahriman-triggers: additional extensions for the application' 'ahriman-web: web server' + 'ipython: an enhanced shell interpreter' 'python-boto3: sync to s3' 'python-cerberus: configuration validator' 'python-matplotlib: usage statistics chart' diff --git a/pyproject.toml b/pyproject.toml index d878626d..99a0b8c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ pacman = [ s3 = [ "boto3", ] +shell = [ + "IPython" +] stats = [ "matplotlib", ] diff --git a/src/ahriman/application/handlers/shell.py b/src/ahriman/application/handlers/shell.py index 750f3a09..ca59beb9 100644 --- a/src/ahriman/application/handlers/shell.py +++ b/src/ahriman/application/handlers/shell.py @@ -18,12 +18,12 @@ # along with this program. If not, see . # import argparse -import code import sys from pathlib import Path from ahriman.application.handlers.handler import Handler, SubParserAction +from ahriman.application.interactive_shell import InteractiveShell from ahriman.core.configuration import Configuration from ahriman.core.formatters import StringPrinter from ahriman.models.repository_id import RepositoryId @@ -58,11 +58,13 @@ class Shell(Handler): "configuration": configuration, "repository_id": repository_id, } + console = InteractiveShell(locals=local_variables) - if args.code is None: - code.interact(local=local_variables) - else: - code.InteractiveConsole(locals=local_variables).runcode(args.code) + match args.code: + case None: + console.interact() + case snippet: + console.runcode(snippet) @staticmethod def _set_service_shell_parser(root: SubParserAction) -> argparse.ArgumentParser: @@ -79,6 +81,7 @@ class Shell(Handler): description="drop into python shell") parser.add_argument("code", help="instead of dropping into shell, just execute the specified code", nargs="?") parser.add_argument("-v", "--verbose", help=argparse.SUPPRESS, action="store_true") + parser.add_argument("-o", "--output", help="output commands and result to the file", type=Path) parser.set_defaults(lock=None, report=False) return parser diff --git a/src/ahriman/application/interactive_shell.py b/src/ahriman/application/interactive_shell.py new file mode 100644 index 00000000..ade42175 --- /dev/null +++ b/src/ahriman/application/interactive_shell.py @@ -0,0 +1,46 @@ +# +# Copyright (c) 2021-2024 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from code import InteractiveConsole +from typing import Any + + +class InteractiveShell(InteractiveConsole): + """ + wrapper around :class:`code.InteractiveConsole` to pass :func:`interact()` to IPython shell + """ + + def interact(self, *args: Any, **kwargs: Any) -> None: + """ + pass controller to IPython shell + + Args: + *args(Any): positional arguments + **kwargs(Any): keyword arguments + """ + try: + 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: + # fallback to default + import readline # pylint: disable=unused-import + InteractiveConsole.interact(self, *args, **kwargs) diff --git a/tests/ahriman/application/handlers/test_handler_shell.py b/tests/ahriman/application/handlers/test_handler_shell.py index 23187cd9..2fd0da12 100644 --- a/tests/ahriman/application/handlers/test_handler_shell.py +++ b/tests/ahriman/application/handlers/test_handler_shell.py @@ -30,11 +30,11 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: """ args = _default_args(args) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - application_mock = mocker.patch("code.interact") + application_mock = mocker.patch("ahriman.application.interactive_shell.InteractiveShell.interact") _, repository_id = configuration.check_loaded() Shell.run(args, repository_id, configuration, report=False) - application_mock.assert_called_once_with(local=pytest.helpers.anyvar(int)) + application_mock.assert_called_once_with() def test_run_eval(args: argparse.Namespace, configuration: Configuration, repository: Repository, @@ -45,7 +45,7 @@ def test_run_eval(args: argparse.Namespace, configuration: Configuration, reposi args = _default_args(args) args.code = """print("hello world")""" mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - application_mock = mocker.patch("code.InteractiveConsole.runcode") + application_mock = mocker.patch("ahriman.application.interactive_shell.InteractiveShell.runcode") _, repository_id = configuration.check_loaded() Shell.run(args, repository_id, configuration, report=False) @@ -62,11 +62,11 @@ def test_run_verbose(args: argparse.Namespace, configuration: Configuration, rep mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) read_mock = mocker.patch("pathlib.Path.read_text", return_value="") print_mock = mocker.patch("ahriman.core.formatters.Printer.print") - application_mock = mocker.patch("code.interact") + application_mock = mocker.patch("ahriman.application.interactive_shell.InteractiveShell.interact") _, repository_id = configuration.check_loaded() Shell.run(args, repository_id, configuration, report=False) - application_mock.assert_called_once_with(local=pytest.helpers.anyvar(int)) + application_mock.assert_called_once_with() read_mock.assert_called_once_with(encoding="utf8") print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ") diff --git a/tests/ahriman/application/test_interactive_shell.py b/tests/ahriman/application/test_interactive_shell.py new file mode 100644 index 00000000..cb5c2589 --- /dev/null +++ b/tests/ahriman/application/test_interactive_shell.py @@ -0,0 +1,30 @@ +import pytest + +from pytest_mock import MockerFixture + +from ahriman.application.interactive_shell import InteractiveShell + + +def test_interact(mocker: MockerFixture) -> None: + """ + must call IPython shell + """ + banner_mock = mocker.patch("IPython.terminal.embed.InteractiveShellEmbed.show_banner") + interact_mock = mocker.patch("IPython.terminal.embed.InteractiveShellEmbed.interact") + + shell = InteractiveShell() + shell.interact() + banner_mock.assert_called_once_with() + interact_mock.assert_called_once_with() + + +def test_interact_import_error(mocker: MockerFixture) -> None: + """ + must call builtin shell if no IPython available + """ + pytest.helpers.import_error("IPython.terminal.embed", ["InteractiveShellEmbed"], mocker) + interact_mock = mocker.patch("code.InteractiveConsole.interact") + + shell = InteractiveShell() + shell.interact() + interact_mock.assert_called_once_with(shell) diff --git a/tox.ini b/tox.ini index 28c46c3a..73b3e2cf 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = check, tests isolated_build = True labels = release = version, docs, publish -dependencies = -e .[journald,pacman,s3,stats,validator,web] +dependencies = -e .[journald,pacman,s3,shell,stats,validator,web] project_name = ahriman [mypy]