diff --git a/src/qeval/base_commands/__init__.py b/src/qeval/base_commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/qeval/base_commands/benchmark_queries.py b/src/qeval/base_commands/benchmark_queries.py new file mode 100644 index 00000000..adeff742 --- /dev/null +++ b/src/qeval/base_commands/benchmark_queries.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from abc import abstractmethod + +from qeval.base_commands.index_stats import BaseIndexStatsCommand +from qlever.commands.benchmark_queries import ( + BenchmarkQueriesCommand as QleverBenchmarkQueriesCommand, +) + + +class BaseBenchmarkQueriesCommand(QleverBenchmarkQueriesCommand): + """ + Run benchmark queries against the SPARQL endpoint of an engine. + """ + + @abstractmethod + def index_stats_command(self) -> BaseIndexStatsCommand: + """The command that reads the index time and size of the engine.""" + + def can_clear_cache(self, args, engine: str | None) -> bool: + # Clearing the cache before each query only works for QLever. + return False diff --git a/src/qeval/base_commands/index_stats.py b/src/qeval/base_commands/index_stats.py new file mode 100644 index 00000000..08f69062 --- /dev/null +++ b/src/qeval/base_commands/index_stats.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from abc import abstractmethod +from pathlib import Path + +from qlever.commands.index_stats import ( + IndexStatsCommand as QleverIndexStatsCommand, +) +from qlever.commands.index_stats import ( + get_size_unit, + get_size_unit_factor, + get_time_unit, + get_time_unit_factor, +) +from qlever.log import log +from qlever.util import get_total_file_size + + +def read_index_log(log_file_name: str | Path) -> str | None: + """ + The text of the index log, or None if it cannot be read, which is + reported as an error. + """ + try: + return Path(log_file_name).read_text(errors="replace") + except OSError as e: + log.error(f"Problem reading index log file {log_file_name}: {e}") + return None + + +class BaseIndexStatsCommand(QleverIndexStatsCommand): + """ + Show how long the index build of an engine took and how much space its + index uses, from the durations in its index log and the size of its + index files. + """ + + @abstractmethod + def index_size_patterns(self, args) -> list[str]: + """The glob patterns of the index files, whose sizes add up.""" + + @abstractmethod + def parse_index_durations(self, log_text: str) -> dict[str, float]: + """ + How long each phase of the build took in seconds, from the text of + the index log, in the order to show them. + """ + + def index_durations(self, log_file_name: str | Path) -> dict[str, float]: + """ + Read the index log and parse it. Empty if the log cannot be read. + """ + log_text = read_index_log(log_file_name) + if log_text is None: + return {} + return self.parse_index_durations(log_text) + + def execute_time( + self, args, log_file_name: str + ) -> dict[str, tuple[float | None, str]]: + """ + The duration of each phase, all in the same unit, picked to suit + the longest one. + """ + durations = self.index_durations(log_file_name) + if not durations: + return {} + time_unit = get_time_unit(args.time_unit, max(durations.values())) + unit_factor = get_time_unit_factor(time_unit) + return { + heading: (seconds / unit_factor, time_unit) + for heading, seconds in durations.items() + } + + def execute_space(self, args) -> dict[str, tuple[float, str]]: + """The total size of the index files, in a unit that suits it.""" + index_size = get_total_file_size(self.index_size_patterns(args)) + size_unit = get_size_unit(args.size_unit, index_size) + unit_factor = get_size_unit_factor(size_unit) + return {"TOTAL size": (index_size / unit_factor, size_unit)} diff --git a/src/qeval/base_commands/query.py b/src/qeval/base_commands/query.py new file mode 100644 index 00000000..305b2b0c --- /dev/null +++ b/src/qeval/base_commands/query.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from qlever.commands.query import QueryCommand as QleverQueryCommand + + +class BaseQueryCommand(QleverQueryCommand): + """ + Send a query to the SPARQL endpoint of an engine. QLever's option to pin + the result to its cache, and the access token that this needs, are left + out. + """ + + def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: + return {"server": ["host_name", "port"]} + + def accept_header_choices(self) -> list[str]: + return [ + "text/tab-separated-values", + "text/csv", + "application/sparql-results+json", + "application/sparql-results+xml", + ] + + def can_pin_to_cache(self) -> bool: + return False diff --git a/src/qeval/base_commands/setup_config.py b/src/qeval/base_commands/setup_config.py new file mode 100644 index 00000000..91575387 --- /dev/null +++ b/src/qeval/base_commands/setup_config.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from abc import abstractmethod +from pathlib import Path + +from qlever.commands.setup_config import ( + SetupConfigCommand as QleverSetupConfigCommand, +) +from qlever.commands.setup_config import check_qleverfile_exists +from qlever.log import log +from qlever.qleverfile import Qleverfile + + +class BaseSetupConfigCommand(QleverSetupConfigCommand): + """ + Create a Qleverfile for an engine from one of QLever's pre-configured + Qleverfiles: keep only the parts that every engine needs, then add the + engine's image and its own settings. + """ + + def filter_criteria(self) -> dict[str, list[str] | None]: + """ + The sections and options of a pre-configured Qleverfile to keep, by + default those that every engine needs. `None` keeps every option of + the section. An engine that needs more can add to what this returns. + """ + return { + "data": None, + "index": ["INPUT_FILES"], + "server": ["PORT"], + "runtime": ["SYSTEM", "IMAGE"], + "ui": ["UI_CONFIG"], + } + + @abstractmethod + def image(self) -> str: + """The container image of the engine.""" + + @abstractmethod + def engine_settings(self, args) -> dict[str, dict[str, str]]: + """The engine's own Qleverfile values, as {section: {option: value}}.""" + + def execute(self, args) -> bool: + template_path = ( + self.qleverfiles_path / f"Qleverfile.{args.config_name}" + ) + self.show( + f"Create a Qleverfile from {template_path}, keeping only the " + "parts that apply to this graph database", + only_show=args.show, + ) + if args.show: + return True + + if check_qleverfile_exists(args.main_command_name): + return False + + # Each step can override the values of the steps before it. + try: + qleverfile = Qleverfile.filter( + template_path, self.filter_criteria() + ) + qleverfile.set("runtime", "IMAGE", self.image()) + for section, options in self.engine_settings(args).items(): + # Skip sections that the pre-configured Qleverfile lacks. + if qleverfile.has_section(section): + for option, value in options.items(): + qleverfile.set(section, option, value) + for section, arg_name in self.override_args: + if arg_value := getattr(args, arg_name): + qleverfile.set(section, arg_name.upper(), str(arg_value)) + with Path("Qleverfile").open("w") as qleverfile_file: + qleverfile.write(qleverfile_file) + except Exception as e: + log.error(f"Could not create the Qleverfile: {e}") + return False + + log.info( + f'Created Qleverfile for config "{args.config_name}" in the ' + "current directory" + ) + return True diff --git a/src/qeval/base_commands/status.py b/src/qeval/base_commands/status.py new file mode 100644 index 00000000..a4d6eca5 --- /dev/null +++ b/src/qeval/base_commands/status.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from abc import abstractmethod + +from qlever.commands.status import StatusCommand as QleverStatusCommand + + +class BaseStatusCommand(QleverStatusCommand): + """ + Show the processes of an engine. + """ + + @abstractmethod + def default_regex(self) -> str: + """Regex for the command lines of the engine's processes.""" + + def description(self) -> str: + return ( + "Show the processes of this graph database running on this machine" + ) diff --git a/src/qeval/base_commands/stop.py b/src/qeval/base_commands/stop.py new file mode 100644 index 00000000..2ce2a7db --- /dev/null +++ b/src/qeval/base_commands/stop.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from abc import abstractmethod + +from qeval.base_commands.status import BaseStatusCommand +from qlever.command import QleverCommand +from qlever.commands.stop import stop_container +from qlever.containerize import Containerize +from qlever.log import log +from qlever.util import stop_process_with_regex + + +class BaseStopCommand(QleverCommand): + """ + Stop the server of an engine for a given dataset: natively by killing + the processes that match a regex, in a container by stopping and + removing the server container. + """ + + def __init__(self): + pass + + @abstractmethod + def default_regex(self) -> str: + """ + Regex for the command line of the engine's server, where `%%NAME%%` + stands for the dataset name. + """ + + @abstractmethod + def status_command(self) -> BaseStatusCommand: + """The engine's `status` command, shown when no process matches.""" + + def description(self) -> str: + return "Stop the server of this graph database for a given dataset" + + def should_have_qleverfile(self) -> bool: + return True + + def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: + return { + "data": ["name"], + "runtime": ["system", "server_container"], + } + + def additional_arguments(self, subparser) -> None: + subparser.add_argument( + "--cmdline-regex", + default=self.default_regex(), + help="Stop the processes whose command line matches this regex", + ) + + def execute(self, args) -> bool: + # Only match the server of this dataset. + cmdline_regex = args.cmdline_regex.replace("%%NAME%%", args.name) + is_containerized = args.system in Containerize.supported_systems() + description = ( + f"Checking for container with name {args.server_container}" + if is_containerized + else f'Checking for processes matching "{cmdline_regex}"' + ) + self.show(description, only_show=args.show) + if args.show: + return True + + if is_containerized: + if stop_container(args.server_container): + return True + log.error( + f'No container with name "{args.server_container}" found' + ) + return False + + stop_process_results = stop_process_with_regex(cmdline_regex) + if stop_process_results is None: + return False + if len(stop_process_results) > 0: + return all(stop_process_results) + + # Nothing matched, so show what is running instead. + log.error("No matching process found") + status_command = self.status_command() + args.cmdline_regex = status_command.default_regex() + log.info("") + status_command.execute(args) + return True diff --git a/src/qeval/config.py b/src/qeval/config.py index 79678a77..215139ba 100644 --- a/src/qeval/config.py +++ b/src/qeval/config.py @@ -63,7 +63,7 @@ def parse_command_line() -> argparse.Namespace: tool_description = ( f"{SCRIPT_NAME} sets up, indexes, queries, and benchmarks " "graph databases in a uniform way. " - f"Supported engines: {supported_engines}" + f"Supported graph databases: {supported_engines}" ) parser = argparse.ArgumentParser( description=colored(tool_description, attrs=["bold"]) @@ -99,6 +99,7 @@ def parse_command_line() -> argparse.Namespace: script_name=SCRIPT_NAME, engine_short_name=engine, engine_display_name=engine_info.display_name, + endpoint_path=engine_info.endpoint_path, main_command_name=main_command_name, ) subparsers = engine_parser.add_subparsers( diff --git a/src/qeval/engines.py b/src/qeval/engines.py index d9d39cca..83e67a9b 100644 --- a/src/qeval/engines.py +++ b/src/qeval/engines.py @@ -6,16 +6,19 @@ class Engine(NamedTuple): """ - The package with the commands of an engine, and the name of the engine as - shown to the user. + The package with the commands of an engine, the name of the engine as + shown to the user, and the path of its SPARQL endpoint on the server. """ package: str display_name: str + endpoint_path: str ENGINES = { - "qlever": Engine(package="qlever", display_name="QLever"), + "qlever": Engine( + package="qlever", display_name="QLever", endpoint_path="/" + ), } diff --git a/src/qlever/commands/benchmark_queries.py b/src/qlever/commands/benchmark_queries.py index eb3f2c0b..9caef5b2 100644 --- a/src/qlever/commands/benchmark_queries.py +++ b/src/qlever/commands/benchmark_queries.py @@ -722,6 +722,23 @@ def additional_arguments(self, subparser) -> None: ), ) + def index_stats_command(self) -> IndexStatsCommand: + """The command that reads the index time and size of the engine.""" + return IndexStatsCommand() + + def can_clear_cache(self, args, engine: str | None) -> bool: + """ + Whether the cache can be cleared before each query. Only QLever can + do that, so guess from the endpoint and the result file name. + """ + is_qlever = ( + not args.sparql_endpoint + or args.sparql_endpoint.startswith("https://qlever") + ) + if engine is not None: + is_qlever = is_qlever or "qlever" in engine.lower() + return is_qlever + def execute(self, args) -> bool: # We can't have both `--remove-offset-and-limit` and `--limit`. if args.remove_offset_and_limit and args.limit: @@ -806,14 +823,8 @@ def execute(self, args) -> bool: return False # Clear cache only works for QLever. - is_qlever = ( - not args.sparql_endpoint - or args.sparql_endpoint.startswith("https://qlever") - ) - if engine is not None: - is_qlever = is_qlever or "qlever" in engine.lower() if args.clear_cache == "yes": - if is_qlever: + if self.can_clear_cache(args, engine): log.warning( "Clearing the cache before each query" " (only works for QLever)" @@ -830,7 +841,8 @@ def execute(self, args) -> bool: f"curl -sv https://qlever.dev/api/examples/{args.ui_config}" ) sparql_endpoint = ( - args.sparql_endpoint or f"{args.host_name}:{args.port}" + args.sparql_endpoint + or f"{args.host_name}:{args.port}{args.endpoint_path}" ) self.show( @@ -898,7 +910,9 @@ def execute(self, args) -> bool: if timeout: result_yml_query_records["timeout"] = timeout - index_time, index_size = compute_index_stats(IndexStatsCommand()) + index_time, index_size = compute_index_stats( + self.index_stats_command() + ) result_yml_query_records["index_time"] = index_time result_yml_query_records["index_size"] = index_size diff --git a/src/qlever/commands/index_stats.py b/src/qlever/commands/index_stats.py index 28aa334c..831289cc 100644 --- a/src/qlever/commands/index_stats.py +++ b/src/qlever/commands/index_stats.py @@ -291,9 +291,8 @@ def execute(self, args) -> bool: if not args.only_space: log_file_name = f"{args.name}.index-log.txt" self.show( - f"Breakdown of the time used for " - f"building the index, based on the timestamps for key " - f'lines in "{log_file_name}"', + "Breakdown of the time used for building the index, " + f'based on key lines in "{log_file_name}"', only_show=args.show, ) if not args.show: diff --git a/src/qlever/commands/query.py b/src/qlever/commands/query.py index a2005ad1..3367c22d 100644 --- a/src/qlever/commands/query.py +++ b/src/qlever/commands/query.py @@ -40,6 +40,21 @@ def should_have_qleverfile(self) -> bool: def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: return {"server": ["host_name", "port", "access_token"]} + def accept_header_choices(self) -> list[str]: + """The media types that `--accept` offers.""" + return [ + "text/tab-separated-values", + "text/csv", + "application/sparql-results+json", + "application/sparql-results+xml", + "application/qlever-results+json", + "application/octet-stream", + ] + + def can_pin_to_cache(self) -> bool: + """Whether `--pin-to-cache` is offered, which needs an access token.""" + return True + def additional_arguments(self, subparser) -> None: subparser.add_argument( "query", @@ -54,26 +69,23 @@ def additional_arguments(self, subparser) -> None: choices=self.predefined_queries.keys(), help="Use a predefined query", ) - subparser.add_argument( - "--pin-to-cache", - action="store_true", - default=False, - help="Pin the query to the cache", - ) + if self.can_pin_to_cache(): + subparser.add_argument( + "--pin-to-cache", + action="store_true", + default=False, + help="Pin the query to the cache", + ) + else: + # `execute` reads this option, so it needs a value. + subparser.set_defaults(pin_to_cache=False) subparser.add_argument( "--sparql-endpoint", type=str, help="URL of the SPARQL endpoint" ) subparser.add_argument( "--accept", type=str, - choices=[ - "text/tab-separated-values", - "text/csv", - "application/sparql-results+json", - "application/sparql-results+xml", - "application/qlever-results+json", - "application/octet-stream", - ], + choices=self.accept_header_choices(), default="text/tab-separated-values", help="Accept header for the SPARQL query", ) @@ -110,7 +122,7 @@ def execute(self, args) -> bool: sparql_endpoint = ( args.sparql_endpoint if args.sparql_endpoint - else f"{args.host_name}:{args.port}" + else f"{args.host_name}:{args.port}{args.endpoint_path}" ) curl_cmd = ( f"curl -s {sparql_endpoint}" diff --git a/src/qlever/commands/status.py b/src/qlever/commands/status.py index e9a89a2c..72b1dd34 100644 --- a/src/qlever/commands/status.py +++ b/src/qlever/commands/status.py @@ -23,14 +23,18 @@ def should_have_qleverfile(self) -> bool: def relevant_qleverfile_arguments(self) -> dict[str, list[str]]: return {} + def default_regex(self) -> str: + """Regex for the command lines of QLever's processes.""" + # A server that runs as a systemd unit (see `start`) has the + # absolute path of its binary in its command line, because + # `systemd-run` resolves it; a server started with `nohup` has + # the name as given in `SERVER_BINARY`. + return r"^(\S*/)?(qlever-server|qlever-index)" + def additional_arguments(self, subparser) -> None: subparser.add_argument( "--cmdline-regex", - # A server that runs as a systemd unit (see `start`) has the - # absolute path of its binary in its command line, because - # `systemd-run` resolves it; a server started with `nohup` has - # the name as given in `SERVER_BINARY`. - default=r"^(\S*/)?(qlever-server|qlever-index)", + default=self.default_regex(), help="Show only processes where the command " "line matches this regex", ) diff --git a/src/qlever/config.py b/src/qlever/config.py index 802925de..e8f05c2b 100644 --- a/src/qlever/config.py +++ b/src/qlever/config.py @@ -278,14 +278,15 @@ def parse_command_line() -> argparse.Namespace: # The name of the script (the console script from `pyproject.toml`), the # short name of the engine (used for the container names), the name of the - # engine as shown to the user, and the name of the main command (what the - # user types before a command, which for a script that supports several - # engines may consist of more than one word). These are provided to all - # commands via `args`. + # engine as shown to the user, the path of its SPARQL endpoint on the + # server, and the name of the main command (what the user types before a + # command, which for a script that supports several engines may consist + # of more than one word). These are provided to all commands via `args`. parser.set_defaults( script_name="qlever", engine_short_name="qlever", engine_display_name="QLever", + endpoint_path="/", main_command_name="qlever", ) diff --git a/src/qlever/qleverfile.py b/src/qlever/qleverfile.py index c4d9350c..41ee8a52 100644 --- a/src/qlever/qleverfile.py +++ b/src/qlever/qleverfile.py @@ -770,14 +770,15 @@ def read(qleverfile_path: Path, engine_short_name: str) -> ConfigParser: @staticmethod def filter( - qleverfile_path: Path, options_included: dict[str, list[str]] + qleverfile_path: Path, options_included: dict[str, list[str] | None] ) -> RawConfigParser: """ Given a filter criteria (key: section_header, value: list[options]), return a RawConfigParser object to create a new filtered Qleverfile - with only the specified sections and options (selects all options if - list[options] is empty). Mainly to be used by `qeval` for the - `setup-config` command of non-qlever engines. + with only the specified sections and options (a value of `None` keeps + every option of the section, an empty list keeps the section without + options). Mainly to be used by `qeval` for the `setup-config` command + of non-qlever engines. """ # Read the Qleverfile. config = RawConfigParser() @@ -791,8 +792,8 @@ def filter( if config.has_section(section): filtered_config.add_section(section) - # If the list is empty, copy all fields - if not desired_fields: + # Without a list of fields, copy all of them. + if desired_fields is None: for field, value in config.items(section): filtered_config.set(section, field, value) else: