qlever 0.5.20__py3-none-any.whl → 0.5.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of qlever might be problematic. Click here for more details.

qlever/commands/ui.py CHANGED
@@ -1,12 +1,37 @@
1
1
  from __future__ import annotations
2
2
 
3
- import subprocess
4
3
  from os import environ
4
+ from pathlib import Path
5
+
6
+ import yaml
5
7
 
6
8
  from qlever.command import QleverCommand
7
9
  from qlever.containerize import Containerize
8
10
  from qlever.log import log
9
- from qlever.util import is_port_used
11
+ from qlever.util import is_port_used, run_command
12
+
13
+
14
+ # Return a YAML string for the given dictionary. Format values with
15
+ # newlines using the "|" style.
16
+ def dict_to_yaml(dictionary):
17
+ # Custom representer for yaml, which uses the "|" style only for
18
+ # multiline strings.
19
+ #
20
+ # NOTE: We replace all `\r\n` with `\n` because otherwise the `|` style
21
+ # does not work as expected.
22
+ class MultiLineDumper(yaml.Dumper):
23
+ def represent_scalar(self, tag, value, style=None):
24
+ value = value.replace("\r\n", "\n")
25
+ if isinstance(value, str) and "\n" in value:
26
+ style = "|"
27
+ return super().represent_scalar(tag, value, style)
28
+
29
+ # Dump as yaml.
30
+ return yaml.dump(
31
+ dictionary,
32
+ sort_keys=False,
33
+ Dumper=MultiLineDumper,
34
+ )
10
35
 
11
36
 
12
37
  class UiCommand(QleverCommand):
@@ -37,7 +62,30 @@ class UiCommand(QleverCommand):
37
62
  }
38
63
 
39
64
  def additional_arguments(self, subparser) -> None:
40
- pass
65
+ subparser.add_argument(
66
+ "--ui-config-file",
67
+ default="Qleverfile-ui.yml",
68
+ help="Name of the config file for the QLever UI "
69
+ "(default: Qleverfile-ui.yml)",
70
+ )
71
+ subparser.add_argument(
72
+ "--ui-db-file",
73
+ help="Name of the database file for the QLever UI "
74
+ "(default: {name}.ui-db.sqlite3)",
75
+ )
76
+ subparser.add_argument(
77
+ "--no-pull-latest",
78
+ action="store_true",
79
+ default=False,
80
+ help="Do not pull the latest image for the QLever UI "
81
+ "(default: pull the latest image if image name contains '/')",
82
+ )
83
+ subparser.add_argument(
84
+ "--stop",
85
+ action="store_true",
86
+ default=False,
87
+ help="Stop the running container",
88
+ )
41
89
 
42
90
  def execute(self, args) -> bool:
43
91
  # If QLEVER_OVERRIDE_DISABLE_UI is set, this command is disabled.
@@ -59,57 +107,161 @@ class UiCommand(QleverCommand):
59
107
  log.info("")
60
108
 
61
109
  # Construct commands and show them.
62
- server_url = f"http://{args.host_name}:{args.port}"
110
+ pull_latest_image = "/" in args.ui_image and not args.no_pull_latest
111
+ ui_config_name = args.name
112
+ ui_db_file = args.ui_db_file or f"{args.name}.ui-db.sqlite3"
113
+ ui_db_file_from_image = "qleverui.sqlite3"
114
+ ui_config_file = args.ui_config_file
115
+ sparql_endpoint = f"http://{args.host_name}:{args.port}"
63
116
  ui_url = f"http://{args.host_name}:{args.ui_port}"
64
117
  pull_cmd = f"{args.ui_system} pull -q {args.ui_image}"
65
- run_cmd = (
118
+ get_db_cmd = (
119
+ f"{args.ui_system} create "
120
+ f"--name {args.ui_container} "
121
+ f"{args.ui_image} "
122
+ f"&& {args.ui_system} cp "
123
+ f"{args.ui_container}:/app/db/{ui_db_file_from_image} {ui_db_file} "
124
+ f"&& {args.ui_system} rm -f {args.ui_container}"
125
+ )
126
+ start_ui_cmd = (
66
127
  f"{args.ui_system} run -d "
128
+ f"--volume $(pwd):/app/db "
129
+ f"--env QLEVERUI_DATABASE_URL=sqlite:////app/db/{ui_db_file} "
67
130
  f"--publish {args.ui_port}:7000 "
68
131
  f"--name {args.ui_container} "
69
132
  f"{args.ui_image}"
70
133
  )
71
- exec_cmd = (
72
- f"{args.ui_system} exec -it "
134
+ get_config_cmd = (
135
+ f"{args.ui_system} exec -i "
73
136
  f"{args.ui_container} "
74
- f'bash -c "python manage.py configure '
75
- f'{args.ui_config} {server_url}"'
137
+ f'bash -c "python manage.py config {ui_config_name}"'
76
138
  )
77
- self.show(
78
- "\n".join(
79
- ["Stop running containers", pull_cmd, run_cmd, exec_cmd]
80
- ),
81
- only_show=args.show,
139
+ set_config_cmd = (
140
+ f"{args.ui_system} exec -i "
141
+ f"{args.ui_container} "
142
+ f'bash -c "python manage.py config {ui_config_name} '
143
+ f'/app/db/{ui_config_file} --hide-all-other-backends"'
82
144
  )
145
+ commands_to_show = []
146
+ if not args.stop:
147
+ if pull_latest_image:
148
+ commands_to_show.append(pull_cmd)
149
+ if not Path(ui_db_file).exists():
150
+ commands_to_show.append(get_db_cmd)
151
+ commands_to_show.append(start_ui_cmd)
152
+ if not Path(ui_config_file).exists():
153
+ commands_to_show.append(get_config_cmd)
154
+ else:
155
+ commands_to_show.append(set_config_cmd)
156
+ self.show("\n".join(commands_to_show), only_show=args.show)
83
157
  if qlever_is_running_in_container:
84
158
  return False
85
159
  if args.show:
86
160
  return True
87
161
 
88
162
  # Stop running containers.
163
+ was_found_and_stopped = False
89
164
  for container_system in Containerize.supported_systems():
90
- Containerize.stop_and_remove_container(
165
+ was_found_and_stopped |= Containerize.stop_and_remove_container(
91
166
  container_system, args.ui_container
92
167
  )
168
+ if was_found_and_stopped:
169
+ log.debug(f"Stopped and removed container `{args.ui_container}`")
170
+ else:
171
+ log.debug(f"No container with name `{args.ui_container}` found")
172
+ if args.stop:
173
+ return True
174
+
175
+ # Pull the latest image.
176
+ if pull_latest_image:
177
+ log.debug(f"Pulling image `{args.ui_image}` for QLever UI")
178
+ run_command(pull_cmd)
93
179
 
94
180
  # Check if the UI port is already being used.
95
181
  if is_port_used(args.ui_port):
96
182
  log.warning(
97
- f"It looks like the specified port for the UI ({args.ui_port}) is already in use. You can set another port in the Qleverfile in the [ui] section with the UI_PORT variable."
183
+ f"It looks like port {args.ui_port} for the QLever UI "
184
+ f"is already in use. You can set another port in the "
185
+ f" Qleverfile in the [ui] section with the UI_PORT variable."
98
186
  )
99
187
 
100
- # Try to start the QLever UI.
188
+ # Get the QLever UI database from the image, unless it already exists.
189
+ if Path(ui_db_file).exists():
190
+ log.debug(f"Found QLever UI database `{ui_db_file}`, reusing it")
191
+ else:
192
+ log.debug(f"Getting QLever UI database `{ui_db_file}` from image")
193
+ try:
194
+ run_command(get_db_cmd)
195
+ except Exception as e:
196
+ log.error(
197
+ f"Failed to get {ui_db_file} from {args.ui_image} "
198
+ f"({e})"
199
+ )
200
+ return False
201
+
202
+ # Start the QLever UI.
203
+ try:
204
+ log.debug(
205
+ f"Starting new container with name `{args.ui_container}`"
206
+ )
207
+ run_command(start_ui_cmd)
208
+ except Exception as e:
209
+ log.error(f"Failed to start container `{args.ui_container}` ({e})")
210
+ return False
211
+
212
+ # Check if config file with name `ui_config_file` exists. If not, try
213
+ # to obtain it via `get_config_cmd` and set it as default.
214
+ if Path(ui_config_file).exists():
215
+ log.info(f"Found config file `{ui_config_file}` and reusing it")
216
+ else:
217
+ try:
218
+ log.info(
219
+ f"Get default config file `{ui_config_file}` from image "
220
+ f"`{args.ui_image}` and set endpoint to `{sparql_endpoint}`"
221
+ )
222
+ config_yaml = run_command(get_config_cmd, return_output=True)
223
+ config_dict = yaml.safe_load(config_yaml)
224
+ except Exception as e:
225
+ log.error("")
226
+ log.error(
227
+ f"An error occured while getting and parsing the "
228
+ f"config file ({e})"
229
+ )
230
+ return False
231
+ try:
232
+ config_dict["config"]["backend"]["isDefault"] = True
233
+ config_dict["config"]["backend"]["baseUrl"] = sparql_endpoint
234
+ config_dict["config"]["backend"]["sortKey"] = 1
235
+ config_yaml = dict_to_yaml(config_dict)
236
+ with open(ui_config_file, "w") as f:
237
+ f.write(config_yaml)
238
+ except Exception as e:
239
+ log.error("")
240
+ log.error(
241
+ f"An error occured while modifying and writing the "
242
+ f"config file ({e})"
243
+ )
244
+ return False
245
+
246
+ # Configure the QLever UI.
101
247
  try:
102
- subprocess.run(pull_cmd, shell=True, stdout=subprocess.DEVNULL)
103
- subprocess.run(run_cmd, shell=True, stdout=subprocess.DEVNULL)
104
- subprocess.run(exec_cmd, shell=True, stdout=subprocess.DEVNULL)
105
- except subprocess.CalledProcessError as e:
106
- log.error(f"Failed to start the QLever UI ({e})")
248
+ run_command(set_config_cmd)
249
+ except Exception as e:
250
+ log.error(f"Failed to configure the QLever UI ({e})")
107
251
  return False
108
252
 
109
- # Success.
253
+ # If we come this far, everything should work.
254
+ log.info("")
255
+ log.info(
256
+ f"The QLever UI should now be up at {ui_url}/{ui_config_name}"
257
+ )
258
+ log.info("")
259
+ log.debug(
260
+ "If you must, you can log in as QLever UI admin with "
261
+ 'username and password "demo"'
262
+ )
110
263
  log.info(
111
- f"The QLever UI should now be up at {ui_url} ..."
112
- f"You can log in as QLever UI admin with username and "
113
- f'password "demo"'
264
+ f"You can modify the config file at `{ui_config_file}` "
265
+ f"and then just run `qlever ui` again"
114
266
  )
115
267
  return True
qlever/config.py CHANGED
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import argcomplete
10
10
  from termcolor import colored
11
11
 
12
- from qlever import command_objects, script_name
12
+ from qlever import command_objects, engine_name, script_name
13
13
  from qlever.log import log, log_levels
14
14
  from qlever.qleverfile import Qleverfile
15
15
 
@@ -176,11 +176,18 @@ class QleverConfig:
176
176
  # are defined in the modules in `qlever/commands`. In `__init__.py`
177
177
  # an object of each class is created and stored in `command_objects`.
178
178
  parser = argparse.ArgumentParser(
179
- description=colored("This is the qlever command line tool, "
180
- "it's all you need to work with QLever",
181
- attrs=["bold"]))
182
- parser.add_argument("--version", action="version",
183
- version=f"%(prog)s {version('qlever')}")
179
+ description=colored(
180
+ f"This is the {script_name} command line tool, "
181
+ f"it's all you need to work with {engine_name}",
182
+ attrs=["bold"],
183
+ )
184
+ )
185
+ if script_name == "qlever":
186
+ parser.add_argument(
187
+ "--version",
188
+ action="version",
189
+ version=f"%(prog)s {version('qlever')}",
190
+ )
184
191
  add_qleverfile_option(parser)
185
192
  subparsers = parser.add_subparsers(dest='command')
186
193
  subparsers.required = True
qlever/containerize.py CHANGED
@@ -9,7 +9,7 @@ import subprocess
9
9
  from typing import Optional
10
10
 
11
11
  from qlever.log import log
12
- from qlever.util import run_command, get_random_string
12
+ from qlever.util import get_random_string, run_command
13
13
 
14
14
 
15
15
  class ContainerizeException(Exception):
@@ -40,6 +40,7 @@ class Containerize:
40
40
  volumes: list[tuple[str, str]] = [],
41
41
  ports: list[tuple[int, int]] = [],
42
42
  working_directory: Optional[str] = None,
43
+ use_bash: bool = True,
43
44
  ) -> str:
44
45
  """
45
46
  Get the command to run `cmd` with the given `container_system` and the
@@ -80,11 +81,15 @@ class Containerize:
80
81
  f"{volume_options}"
81
82
  f"{port_options}"
82
83
  f"{working_directory_option}"
84
+ f" --name {container_name}"
83
85
  f" --init"
84
- f" --entrypoint bash"
85
- f" --name {container_name} {image_name}"
86
- f" -c {shlex.quote(cmd)}"
87
86
  )
87
+ if use_bash:
88
+ containerized_cmd += (
89
+ f" --entrypoint bash {image_name} -c {shlex.quote(cmd)}"
90
+ )
91
+ else:
92
+ containerized_cmd += f" {image_name} {cmd}"
88
93
  return containerized_cmd
89
94
 
90
95
  @staticmethod
qlever/qlever_main.py CHANGED
@@ -12,7 +12,7 @@ import traceback
12
12
 
13
13
  from termcolor import colored
14
14
 
15
- from qlever import command_objects
15
+ from qlever import command_objects, script_name
16
16
  from qlever.config import ConfigException, QleverConfig
17
17
  from qlever.log import log, log_levels
18
18
 
@@ -35,9 +35,9 @@ def main():
35
35
  log.info("")
36
36
  log.info(colored(f"Command: {args.command}", attrs=["bold"]))
37
37
  log.info("")
38
- commandWasSuccesful = command_object.execute(args)
38
+ command_successful = command_object.execute(args)
39
39
  log.info("")
40
- if not commandWasSuccesful:
40
+ if not command_successful:
41
41
  exit(1)
42
42
  except KeyboardInterrupt:
43
43
  log.info("")
@@ -47,9 +47,14 @@ def main():
47
47
  except Exception as e:
48
48
  # Check if it's a certain kind of `AttributeError` and give a hint in
49
49
  # that case.
50
+ log.debug(
51
+ "Command failed with exception, full traceback: "
52
+ f"{traceback.format_exc()}"
53
+ )
50
54
  match_error = re.search(r"object has no attribute '(.+)'", str(e))
51
55
  match_trace = re.search(
52
- r"(qlever/commands/.+\.py)\", line (\d+)", traceback.format_exc()
56
+ rf"({script_name}/commands/.+\.py)\", line (\d+)",
57
+ traceback.format_exc(),
53
58
  )
54
59
  if isinstance(e, AttributeError) and match_error and match_trace:
55
60
  attribute = match_error.group(1)
qlever/qleverfile.py CHANGED
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ import socket
4
5
  import subprocess
5
- from configparser import ConfigParser, ExtendedInterpolation
6
+ from configparser import ConfigParser, ExtendedInterpolation, RawConfigParser
7
+ from pathlib import Path
6
8
 
9
+ from qlever import script_name
7
10
  from qlever.containerize import Containerize
8
11
  from qlever.log import log
9
12
 
@@ -242,6 +245,13 @@ class Qleverfile:
242
245
  default=8,
243
246
  help="The number of threads used for query processing",
244
247
  )
248
+ server_args["persist_updates"] = arg(
249
+ "--persist-updates",
250
+ action="store_true",
251
+ default=False,
252
+ help="Persist updates to the index (write updates to disk and "
253
+ "read them back in when restarting the server)",
254
+ )
245
255
  server_args["only_pso_and_pos_permutations"] = arg(
246
256
  "--only-pso-and-pos-permutations",
247
257
  action="store_true",
@@ -291,12 +301,12 @@ class Qleverfile:
291
301
  runtime_args["index_container"] = arg(
292
302
  "--index-container",
293
303
  type=str,
294
- help="The name of the container used by `qlever index`",
304
+ help=f"The name of the container used by `{script_name} index`",
295
305
  )
296
306
  runtime_args["server_container"] = arg(
297
307
  "--server-container",
298
308
  type=str,
299
- help="The name of the container used by `qlever start`",
309
+ help=f"The name of the container used by `{script_name} start`",
300
310
  )
301
311
 
302
312
  ui_args["ui_port"] = arg(
@@ -393,9 +403,9 @@ class Qleverfile:
393
403
  name = config["data"]["name"]
394
404
  runtime = config["runtime"]
395
405
  if "server_container" not in runtime:
396
- runtime["server_container"] = f"qlever.server.{name}"
406
+ runtime["server_container"] = f"{script_name}.server.{name}"
397
407
  if "index_container" not in runtime:
398
- runtime["index_container"] = f"qlever.index.{name}"
408
+ runtime["index_container"] = f"{script_name}.index.{name}"
399
409
  if "ui_container" not in config["ui"]:
400
410
  config["ui"]["ui_container"] = f"qlever.ui.{name}"
401
411
  index = config["index"]
@@ -407,5 +417,49 @@ class Qleverfile:
407
417
  if index.get("text_index", "none") != "none":
408
418
  server["use_text_index"] = "yes"
409
419
 
420
+ # Add other non-trivial default values.
421
+ try:
422
+ config["server"]["host_name"] = socket.gethostname()
423
+ except Exception:
424
+ log.warning(
425
+ "Could not get the hostname, using `localhost` as default"
426
+ )
427
+ pass
428
+
410
429
  # Return the parsed Qleverfile with the added inherited values.
411
430
  return config
431
+
432
+ @staticmethod
433
+ def filter(
434
+ qleverfile_path: Path, options_included: dict[str, list[str]]
435
+ ) -> RawConfigParser:
436
+ """
437
+ Given a filter criteria (key: section_header, value: list[options]),
438
+ return a RawConfigParser object to create a new filtered Qleverfile
439
+ with only the specified sections and options (selects all options if
440
+ list[options] is empty). Mainly to be used by non-qlever scripts for
441
+ the setup-config command
442
+ """
443
+ # Read the Qleverfile.
444
+ config = RawConfigParser()
445
+ config.optionxform = str # Preserve case sensitivity of keys
446
+ config.read(qleverfile_path)
447
+
448
+ filtered_config = RawConfigParser()
449
+ filtered_config.optionxform = str
450
+
451
+ for section, desired_fields in options_included.items():
452
+ if config.has_section(section):
453
+ filtered_config.add_section(section)
454
+
455
+ # If the list is empty, copy all fields
456
+ if not desired_fields:
457
+ for field, value in config.items(section):
458
+ filtered_config.set(section, field, value)
459
+ else:
460
+ for desired_field in desired_fields:
461
+ if config.has_option(section, desired_field):
462
+ value = config.get(section, desired_field)
463
+ filtered_config.set(section, desired_field, value)
464
+
465
+ return filtered_config
qlever/util.py CHANGED
@@ -10,7 +10,9 @@ import string
10
10
  import subprocess
11
11
  from datetime import date, datetime
12
12
  from pathlib import Path
13
- from typing import Optional
13
+ from typing import Any, Optional
14
+
15
+ import psutil
14
16
 
15
17
  from qlever.log import log
16
18
 
@@ -33,6 +35,7 @@ def run_command(
33
35
  cmd: str,
34
36
  return_output: bool = False,
35
37
  show_output: bool = False,
38
+ show_stderr: bool = False,
36
39
  use_popen: bool = False,
37
40
  ) -> Optional[str | subprocess.Popen]:
38
41
  """
@@ -50,7 +53,7 @@ def run_command(
50
53
  "shell": True,
51
54
  "text": True,
52
55
  "stdout": None if show_output else subprocess.PIPE,
53
- "stderr": subprocess.PIPE,
56
+ "stderr": None if show_stderr else subprocess.PIPE,
54
57
  }
55
58
 
56
59
  # With `Popen`, the command runs in the current shell and a process object
@@ -72,8 +75,8 @@ def run_command(
72
75
  raise Exception(result.stderr.replace("\n", " ").strip())
73
76
  else:
74
77
  raise Exception(
75
- f"Command failed with exit code {result.returncode}"
76
- f" but nothing written to stderr"
78
+ f"Command failed with exit code {result.returncode}, "
79
+ f" nothing written to stderr"
77
80
  )
78
81
  # Optionally, return what was written to `stdout`.
79
82
  if return_output:
@@ -242,3 +245,85 @@ def format_size(bytes, suffix="B"):
242
245
  if bytes < factor:
243
246
  return f"{bytes:.2f} {unit}{suffix}"
244
247
  bytes /= factor
248
+
249
+
250
+ def stop_process(proc: psutil.Process, pinfo: dict[str, Any]) -> bool:
251
+ """
252
+ Try to kill the given process, return True iff it was killed
253
+ successfully. The process_info is used for logging.
254
+ """
255
+ try:
256
+ proc.kill()
257
+ log.info(f"Killed process {pinfo['pid']}")
258
+ return True
259
+ except Exception as e:
260
+ log.error(
261
+ f"Could not kill process with PID "
262
+ f"{pinfo['pid']} ({e}) ... try to kill it "
263
+ f"manually"
264
+ )
265
+ log.info("")
266
+ show_process_info(proc, "", show_heading=True)
267
+ return False
268
+
269
+
270
+ def stop_process_with_regex(cmdline_regex: str) -> list[bool] | None:
271
+ """
272
+ Given a cmdline_regex for a native process, try to kill the processes that
273
+ match the regex and return a list of their stopped status (bool).
274
+ Show the matched processes as log info.
275
+ """
276
+ stop_process_results = []
277
+ for proc in psutil.process_iter():
278
+ try:
279
+ pinfo = proc.as_dict(
280
+ attrs=[
281
+ "pid",
282
+ "username",
283
+ "create_time",
284
+ "memory_info",
285
+ "cmdline",
286
+ ]
287
+ )
288
+ cmdline = " ".join(pinfo["cmdline"])
289
+ except Exception as e:
290
+ log.debug(f"Error getting process info: {e}")
291
+ return None
292
+ if re.search(cmdline_regex, cmdline):
293
+ log.info(
294
+ f"Found process {pinfo['pid']} from user "
295
+ f"{pinfo['username']} with command line: {cmdline}"
296
+ )
297
+ log.info("")
298
+ stop_process_results.append(stop_process(proc, pinfo))
299
+ return stop_process_results
300
+
301
+
302
+ def binary_exists(binary: str, cmd_arg: str) -> bool:
303
+ """
304
+ When a command is run natively, check if the binary exists on the system
305
+ """
306
+ try:
307
+ run_command(f"{binary} --help")
308
+ return True
309
+ except Exception as e:
310
+ log.error(
311
+ f'Running "{binary}" failed, '
312
+ f"set `--{cmd_arg}` to a different binary or "
313
+ f"set `--system to a container system`"
314
+ )
315
+ log.info("")
316
+ log.info(f"The error message was: {e}")
317
+ return False
318
+
319
+
320
+ def is_server_alive(url: str) -> bool:
321
+ """
322
+ Check if the server is already alive at the given endpoint url
323
+ """
324
+ check_server_cmd = f"curl -s {url}"
325
+ try:
326
+ run_command(check_server_cmd)
327
+ return True
328
+ except Exception:
329
+ return False
@@ -1,11 +1,11 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: qlever
3
- Version: 0.5.20
4
- Summary: Script for using the QLever SPARQL engine.
3
+ Version: 0.5.22
4
+ Summary: Command-line tool for using the QLever graph database
5
5
  Author-email: Hannah Bast <bast@cs.uni-freiburg.de>
6
6
  License: Apache-2.0
7
7
  Project-URL: Github, https://github.com/ad-freiburg/qlever
8
- Keywords: SPARQL,RDF,Knowledge Graphs,Triple Store
8
+ Keywords: Graph database,Triplestore,Knowledge graphs,SPARQL,RDF
9
9
  Classifier: Topic :: Database :: Database Engines/Servers
10
10
  Classifier: Topic :: Database :: Front-Ends
11
11
  Requires-Python: >=3.8
@@ -14,6 +14,8 @@ License-File: LICENSE
14
14
  Requires-Dist: psutil
15
15
  Requires-Dist: termcolor
16
16
  Requires-Dist: argcomplete
17
+ Requires-Dist: pyyaml
18
+ Dynamic: license-file
17
19
 
18
20
  # QLever
19
21