diff --git a/src/qlever/commands/upgrade_index.py b/src/qlever/commands/upgrade_index.py new file mode 100644 index 00000000..d6e0bacc --- /dev/null +++ b/src/qlever/commands/upgrade_index.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from qlever.command import QleverCommand +from qlever.containerize import Containerize +from qlever.log import log +from qlever.util import binary_exists, run_command + + +class UpgradeIndexCommand(QleverCommand): + """ + Class for executing the `upgrade-index` command. + """ + + def __init__(self): + pass + + def description(self) -> str: + return ( + "Upgrade an index in the previous index format to the " + "format introduced on 2026-09-01 (only this conversion, " + "older indexes have to be rebuilt)" + ) + + def should_have_qleverfile(self) -> bool: + return True + + def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: + return { + "data": ["name"], + "index": ["index_binary"], + "runtime": ["system", "image", "index_container"], + } + + def additional_arguments(self, subparser) -> None: + subparser.add_argument( + "--upgrade-index-binary", + type=str, + default=None, + help="The binary for upgrading the index (default: " + "`qlever-upgrade-index` from the directory of the " + "index binary)", + ) + + def execute(self, args) -> bool: + # By default, take the `qlever-upgrade-index` that sits next to the + # index binary (which is just `qlever-upgrade-index` from the `PATH`, + # or from the container image, when the index binary is a plain + # `qlever-index`). + upgrade_index_binary = args.upgrade_index_binary + if upgrade_index_binary is None: + directory, slash, _ = args.index_binary.rpartition("/") + upgrade_index_binary = ( + f"{directory}/qlever-upgrade-index" + if slash + else "qlever-upgrade-index" + ) + + # Construct the command line. + upgrade_index_cmd = ( + f"{upgrade_index_binary} {args.name}" + f" 2>&1 | tee {args.name}.upgrade-index-log.txt" + ) + + # Run the command in a container (if so desired). + if args.system in Containerize.supported_systems(): + upgrade_index_cmd = Containerize().containerize_command( + upgrade_index_cmd, + args.system, + "run --rm", + args.image, + args.index_container, + volumes=[("$(pwd)", "/index")], + working_directory="/index", + ) + + # Show the command line. + self.show(upgrade_index_cmd, only_show=args.show) + if args.show: + return True + + if not binary_exists( + upgrade_index_binary, "upgrade-index-binary", args + ): + return False + + # Run the upgrade command. + try: + run_command(upgrade_index_cmd, show_output=True) + except Exception as e: + log.error(f"Upgrading the index failed: {e}") + return False + + return True diff --git a/test/qlever/commands/test_upgrade_index_execute.py b/test/qlever/commands/test_upgrade_index_execute.py new file mode 100644 index 00000000..58820ef7 --- /dev/null +++ b/test/qlever/commands/test_upgrade_index_execute.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from qlever.commands.upgrade_index import UpgradeIndexCommand + + +def make_args() -> MagicMock: + args = MagicMock() + args.name = "TestName" + args.index_binary = "qlever-index" + args.upgrade_index_binary = None + args.system = "native" + args.image = "test_image" + args.index_container = "test_container" + args.show = False + return args + + +@patch("qlever.commands.upgrade_index.run_command") +@patch("qlever.commands.upgrade_index.binary_exists") +@patch("qlever.commands.upgrade_index.Containerize") +class TestUpgradeIndexCommand(unittest.TestCase): + # Native system, index binary from the PATH: the upgrade binary is + # `qlever-upgrade-index` from the PATH. + def test_execute_native( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = True + + result = UpgradeIndexCommand().execute(args) + + self.assertTrue(result) + mock_run_command.assert_called_once_with( + "qlever-upgrade-index TestName" + " 2>&1 | tee TestName.upgrade-index-log.txt", + show_output=True, + ) + + # Index binary given with a path: the upgrade binary is taken from the + # same directory. + def test_execute_binary_next_to_index_binary( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + args.index_binary = "/test/path/qlever-index" + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = True + + result = UpgradeIndexCommand().execute(args) + + self.assertTrue(result) + mock_run_command.assert_called_once_with( + "/test/path/qlever-upgrade-index TestName" + " 2>&1 | tee TestName.upgrade-index-log.txt", + show_output=True, + ) + + # An explicitly given `--upgrade-index-binary` is used as is. + def test_execute_explicit_binary( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + args.index_binary = "/test/path/qlever-index" + args.upgrade_index_binary = "/other/path/upgrade-binary" + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = True + + result = UpgradeIndexCommand().execute(args) + + self.assertTrue(result) + mock_run_command.assert_called_once_with( + "/other/path/upgrade-binary TestName" + " 2>&1 | tee TestName.upgrade-index-log.txt", + show_output=True, + ) + + # With a container system, the command is wrapped by `Containerize`. + def test_execute_containerized( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + args.system = "docker" + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = True + containerized_cmd = "docker run --rm ..." + containerize_instance = mock_containerize.return_value + containerize_instance.containerize_command.return_value = ( + containerized_cmd + ) + + result = UpgradeIndexCommand().execute(args) + + self.assertTrue(result) + containerize_instance.containerize_command.assert_called_once_with( + "qlever-upgrade-index TestName" + " 2>&1 | tee TestName.upgrade-index-log.txt", + "docker", + "run --rm", + args.image, + args.index_container, + volumes=[("$(pwd)", "/index")], + working_directory="/index", + ) + mock_run_command.assert_called_once_with( + containerized_cmd, show_output=True + ) + + # With `--show`, the command is only shown, not run. + def test_execute_show( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + args.show = True + mock_containerize.supported_systems.return_value = ["docker"] + + result = UpgradeIndexCommand().execute(args) + + self.assertTrue(result) + mock_run_command.assert_not_called() + + # A missing binary fails the command before anything is run. + def test_execute_binary_missing( + self, mock_containerize, mock_binary_exists, mock_run_command + ): + args = make_args() + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = False + + result = UpgradeIndexCommand().execute(args) + + self.assertFalse(result) + mock_run_command.assert_not_called() + + # A failing upgrade binary fails the command. + @patch("qlever.commands.upgrade_index.log") + def test_execute_upgrade_fails( + self, + mock_log, + mock_containerize, + mock_binary_exists, + mock_run_command, + ): + args = make_args() + mock_containerize.supported_systems.return_value = ["docker"] + mock_binary_exists.return_value = True + mock_run_command.side_effect = Exception("upgrade failed") + + result = UpgradeIndexCommand().execute(args) + + self.assertFalse(result) + mock_log.error.assert_called_once() + + +if __name__ == "__main__": + unittest.main()