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]