feat: use IPython shell if available

This commit is contained in:
Evgenii Alekseev 2024-12-13 15:15:53 +02:00
parent 56114ecc1e
commit 4880ca4fee
9 changed files with 103 additions and 12 deletions

View File

@ -18,7 +18,7 @@ if [[ -z $MINIMAL_INSTALL ]]; then
# web server # 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 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 # 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 fi
# FIXME since 1.0.4 devtools requires dbus to be run, which doesn't work now in container # 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" cp "docker/systemd-nspawn.sh" "/usr/local/bin/systemd-nspawn"

View File

@ -29,6 +29,14 @@ ahriman.application.help\_formatter module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.interactive\_shell module
---------------------------------------------
.. automodule:: ahriman.application.interactive_shell
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.lock module ahriman.application.lock module
------------------------------- -------------------------------

View File

@ -30,6 +30,7 @@ package_ahriman-core() {
pkgname='ahriman-core' pkgname='ahriman-core'
optdepends=('ahriman-triggers: additional extensions for the application' optdepends=('ahriman-triggers: additional extensions for the application'
'ahriman-web: web server' 'ahriman-web: web server'
'ipython: an enhanced shell interpreter'
'python-boto3: sync to s3' 'python-boto3: sync to s3'
'python-cerberus: configuration validator' 'python-cerberus: configuration validator'
'python-matplotlib: usage statistics chart' 'python-matplotlib: usage statistics chart'

View File

@ -60,6 +60,9 @@ pacman = [
s3 = [ s3 = [
"boto3", "boto3",
] ]
shell = [
"IPython"
]
stats = [ stats = [
"matplotlib", "matplotlib",
] ]

View File

@ -18,12 +18,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import argparse import argparse
import code
import sys import sys
from pathlib import Path from pathlib import Path
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.application.interactive_shell import InteractiveShell
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.formatters import StringPrinter from ahriman.core.formatters import StringPrinter
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -58,11 +58,13 @@ class Shell(Handler):
"configuration": configuration, "configuration": configuration,
"repository_id": repository_id, "repository_id": repository_id,
} }
console = InteractiveShell(locals=local_variables)
if args.code is None: match args.code:
code.interact(local=local_variables) case None:
else: console.interact()
code.InteractiveConsole(locals=local_variables).runcode(args.code) case snippet:
console.runcode(snippet)
@staticmethod @staticmethod
def _set_service_shell_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_service_shell_parser(root: SubParserAction) -> argparse.ArgumentParser:
@ -79,6 +81,7 @@ class Shell(Handler):
description="drop into python shell") description="drop into python shell")
parser.add_argument("code", help="instead of dropping into shell, just execute the specified code", nargs="?") 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("-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) parser.set_defaults(lock=None, report=False)
return parser return parser

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -30,11 +30,11 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) 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() _, repository_id = configuration.check_loaded()
Shell.run(args, repository_id, configuration, report=False) 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, 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 = _default_args(args)
args.code = """print("hello world")""" args.code = """print("hello world")"""
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) 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() _, repository_id = configuration.check_loaded()
Shell.run(args, repository_id, configuration, report=False) 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) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
read_mock = mocker.patch("pathlib.Path.read_text", return_value="") read_mock = mocker.patch("pathlib.Path.read_text", return_value="")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print") 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() _, repository_id = configuration.check_loaded()
Shell.run(args, repository_id, configuration, report=False) 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") read_mock.assert_called_once_with(encoding="utf8")
print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ") print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ")

View File

@ -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)

View File

@ -3,7 +3,7 @@ envlist = check, tests
isolated_build = True isolated_build = True
labels = labels =
release = version, docs, publish release = version, docs, publish
dependencies = -e .[journald,pacman,s3,stats,validator,web] dependencies = -e .[journald,pacman,s3,shell,stats,validator,web]
project_name = ahriman project_name = ahriman
[mypy] [mypy]