From 856139d9de3bb0fbfc320e8ca3d1f898ee5d5bd5 Mon Sep 17 00:00:00 2001 From: Hannah Bast Date: Tue, 1 Sep 2026 04:25:12 +0200 Subject: [PATCH] Add a `qlever upgrade-index` command So far, upgrading an index to the new format introduced on 2026-09-01 required running the `qlever-upgrade-index` binary by hand. For a setup that runs QLever with Docker or Podman, that binary is inside the image and not on the user's machine, so the command from the error message did not work there. There is now `qlever upgrade-index`, which runs the binary natively or in a container, depending on the `SYSTEM` from the Qleverfile, just like `qlever index` does. The output is also written to a log file. The command only converts from the previous index format to the one introduced on 2026-09-01, which is also stated in its help text. --- src/qlever/commands/upgrade_index.py | 93 ++++++++++ .../commands/test_upgrade_index_execute.py | 159 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/qlever/commands/upgrade_index.py create mode 100644 test/qlever/commands/test_upgrade_index_execute.py 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()