openbq 0.1.0__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.
- openbq/__init__.py +2 -0
- openbq/__main__.py +3 -0
- openbq/cli.py +58 -0
- openbq/utils.py +629 -0
- openbq-0.1.0.dist-info/METADATA +98 -0
- openbq-0.1.0.dist-info/RECORD +8 -0
- openbq-0.1.0.dist-info/WHEEL +4 -0
- openbq-0.1.0.dist-info/entry_points.txt +3 -0
openbq/__init__.py
ADDED
openbq/__main__.py
ADDED
openbq/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from openbq.utils import handle_uninstall, handle_update, run_container, show_version
|
|
4
|
+
|
|
5
|
+
IMAGE_NAME = "ghcr.io/open-source-biometric-quality-framework/bqcore-service:latest"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Main execution function for the script."""
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
description="openbq command-line interface.",
|
|
12
|
+
add_help=False, # Will be passed to the container
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--version",
|
|
16
|
+
"-V",
|
|
17
|
+
action="store_true",
|
|
18
|
+
help="Show version information.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--update",
|
|
22
|
+
"-U",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Check for and pull updates to the container image.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--uninstall",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="Uninstall the CLI and remove the container image.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--tag",
|
|
33
|
+
help="Specify container image tag.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--shm",
|
|
37
|
+
help="Specify shared memory size for the container (e.g., 8192MB).",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
args, unknown_args = parser.parse_known_args()
|
|
41
|
+
|
|
42
|
+
if not args.tag:
|
|
43
|
+
image_tag = IMAGE_NAME
|
|
44
|
+
else:
|
|
45
|
+
image_tag = args.tag
|
|
46
|
+
|
|
47
|
+
if args.version:
|
|
48
|
+
show_version(image_tag)
|
|
49
|
+
elif args.update:
|
|
50
|
+
handle_update(image_tag)
|
|
51
|
+
elif args.uninstall:
|
|
52
|
+
handle_uninstall(image_tag)
|
|
53
|
+
else:
|
|
54
|
+
run_container(image_tag, unknown_args, args.shm)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
openbq/utils.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
9
|
+
|
|
10
|
+
from openbq import __package__, __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_host_arch():
|
|
14
|
+
"""Get the host system architecture."""
|
|
15
|
+
arch = platform.machine()
|
|
16
|
+
if arch in ("x86_64", "amd64", "i386"):
|
|
17
|
+
return "amd64"
|
|
18
|
+
else:
|
|
19
|
+
return arch
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_host_info():
|
|
23
|
+
"""Get the host system info."""
|
|
24
|
+
info = {
|
|
25
|
+
"Host Platform": platform.platform(),
|
|
26
|
+
"Memory": f"{get_total_memory_mb() / 1024:.2f} GB",
|
|
27
|
+
# "Architecture": platform.machine(),
|
|
28
|
+
# "OS": platform.system(),
|
|
29
|
+
# "OS Version": platform.release(),
|
|
30
|
+
"Python Version": platform.python_version(),
|
|
31
|
+
}
|
|
32
|
+
return info
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_total_memory_mb():
|
|
36
|
+
"""Get the total system memory in megabytes (MB)."""
|
|
37
|
+
system = platform.system()
|
|
38
|
+
try:
|
|
39
|
+
if system == "Linux":
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["vmstat", "-s", "-S", "M"], capture_output=True, text=True, check=True
|
|
42
|
+
)
|
|
43
|
+
match = re.search(r"(\d+)\s*M\s*total memory", result.stdout)
|
|
44
|
+
if match:
|
|
45
|
+
return int(match.group(1))
|
|
46
|
+
elif system == "Darwin": # macOS
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["sysctl", "-n", "hw.memsize"],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
check=True,
|
|
52
|
+
)
|
|
53
|
+
return int(result.stdout.strip()) // (1024 * 1024)
|
|
54
|
+
elif system == "Windows":
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["wmic", "OS", "get", "TotalVisibleMemorySize", "/Value"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
match = re.search(r"TotalVisibleMemorySize=(\d+)", result.stdout)
|
|
62
|
+
if match:
|
|
63
|
+
return int(match.group(1)) // 1024
|
|
64
|
+
except (subprocess.CalledProcessError, FileNotFoundError, ValueError, TypeError):
|
|
65
|
+
print("Warning: Could not determine total system memory.", file=sys.stderr)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_shm_size(total_memory_mb):
|
|
70
|
+
"""Calculate the shared memory size (half of total memory) and format it."""
|
|
71
|
+
if total_memory_mb > 0:
|
|
72
|
+
shm_mb = total_memory_mb // 2
|
|
73
|
+
return f"{shm_mb}MB"
|
|
74
|
+
# Default to a safe size if memory could not be determined
|
|
75
|
+
return "8192MB"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# def get_digest_from_cli(command):
|
|
79
|
+
# """
|
|
80
|
+
# Executes a Docker CLI command and extracts the image digest.
|
|
81
|
+
|
|
82
|
+
# :param command: The full command as a list (e.g., ['docker', 'manifest', 'inspect', 'image:tag']).
|
|
83
|
+
# :return: The image digest (SHA-256 hash) as a string, or None if not found/error.
|
|
84
|
+
# """
|
|
85
|
+
# try:
|
|
86
|
+
# # Run the command and capture the output
|
|
87
|
+
# result = subprocess.run(command, capture_output=True, text=True, check=True)
|
|
88
|
+
|
|
89
|
+
# # The output is a JSON string
|
|
90
|
+
# manifest_data = json.loads(result.stdout)
|
|
91
|
+
|
|
92
|
+
# # The digest is usually stored in the 'Descriptor' key for 'docker manifest inspect'
|
|
93
|
+
# # or the first element of 'RepoDigests' for a local 'docker inspect'.
|
|
94
|
+
|
|
95
|
+
# # Check for remote manifest digest
|
|
96
|
+
# if isinstance(manifest_data, dict) and "Descriptor" in manifest_data:
|
|
97
|
+
# return manifest_data["Descriptor"]["digest"]
|
|
98
|
+
|
|
99
|
+
# # Check for local image digest (which is often returned as a list of repo digests)
|
|
100
|
+
# elif (
|
|
101
|
+
# isinstance(manifest_data, list)
|
|
102
|
+
# and manifest_data
|
|
103
|
+
# and "RepoDigests" in manifest_data[0]
|
|
104
|
+
# ):
|
|
105
|
+
# # Expecting a list of digests in the format 'repo@sha256:...'
|
|
106
|
+
# return manifest_data[0]["Id"]
|
|
107
|
+
|
|
108
|
+
# # Handle the raw manifest digest if it's the only thing returned
|
|
109
|
+
# # This handles cases where docker manifest inspect returns a list of manifests for multi-arch images
|
|
110
|
+
# elif (
|
|
111
|
+
# isinstance(manifest_data, list)
|
|
112
|
+
# and manifest_data
|
|
113
|
+
# and "digest" in manifest_data[0]
|
|
114
|
+
# ):
|
|
115
|
+
# # For a multi-arch manifest list, we take the digest of the list itself
|
|
116
|
+
# return manifest_data[0].get(
|
|
117
|
+
# "digest"
|
|
118
|
+
# ) # This is less precise but safer for general use
|
|
119
|
+
|
|
120
|
+
# # Check for remote manifest digest for multi-platform build
|
|
121
|
+
# if isinstance(manifest_data, dict) and "manifests" in manifest_data:
|
|
122
|
+
# arch = get_host_arch()
|
|
123
|
+
# for manifest in manifest_data["manifests"]:
|
|
124
|
+
# if manifest["platform"]["architecture"] == arch:
|
|
125
|
+
# return manifest["digest"]
|
|
126
|
+
|
|
127
|
+
# # Final fallback if parsing is tricky:
|
|
128
|
+
# if isinstance(manifest_data, dict) and "digest" in manifest_data.get(
|
|
129
|
+
# "config", {}
|
|
130
|
+
# ):
|
|
131
|
+
# return manifest_data["config"]["digest"]
|
|
132
|
+
|
|
133
|
+
# except subprocess.CalledProcessError as e:
|
|
134
|
+
# # Handles errors like "No such image" or "manifest unknown"
|
|
135
|
+
# if "No such image" in e.stderr or "manifest unknown" in e.stderr:
|
|
136
|
+
# return None
|
|
137
|
+
# print(f"Error executing command: {' '.join(command)}\n{e.stderr.strip()}")
|
|
138
|
+
# return None
|
|
139
|
+
# except json.JSONDecodeError:
|
|
140
|
+
# print("Error: Failed to parse JSON output from Docker CLI.")
|
|
141
|
+
# return None
|
|
142
|
+
# except Exception as e:
|
|
143
|
+
# print(f"An unexpected error occurred: {e}")
|
|
144
|
+
# return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# def check_update(image_tag) -> bool:
|
|
148
|
+
# """
|
|
149
|
+
# Checks if a newer version of the 'bq-cli' Docker image is available.
|
|
150
|
+
|
|
151
|
+
# Returns:
|
|
152
|
+
# bool: True if an update is available, False otherwise.
|
|
153
|
+
# """
|
|
154
|
+
# try:
|
|
155
|
+
# # 1. Get Local Image Digest
|
|
156
|
+
# # We use `docker inspect` with a format filter to get JSON containing RepoDigests
|
|
157
|
+
# local_command = ["docker", "inspect", image_tag, "--format", "json"]
|
|
158
|
+
# local_digest = get_digest_from_cli(local_command)
|
|
159
|
+
# digest_tag = f"{image_tag.split(':', 1)[0]}@{local_digest}"
|
|
160
|
+
# local_digest = get_digest_from_cli(
|
|
161
|
+
# ["docker", "manifest", "inspect", digest_tag],
|
|
162
|
+
# )
|
|
163
|
+
|
|
164
|
+
# # 2. Get Remote Image Digest (without pulling)
|
|
165
|
+
# # We use `docker manifest inspect` to query the registry directly
|
|
166
|
+
# remote_command = ["docker", "manifest", "inspect", image_tag]
|
|
167
|
+
# remote_digest = get_digest_from_cli(remote_command)
|
|
168
|
+
|
|
169
|
+
# print(f"Local Image Digest: {local_digest or 'N/A'}")
|
|
170
|
+
# print(f"Remote Image Digest: {remote_digest or 'N/A'}")
|
|
171
|
+
|
|
172
|
+
# # 3. Compare Digests
|
|
173
|
+
# if local_digest is None and remote_digest is None:
|
|
174
|
+
# print(
|
|
175
|
+
# "🛑 Neither local image nor remote manifest could be retrieved. Cannot determine status."
|
|
176
|
+
# )
|
|
177
|
+
# return True # No image, so consider an "update" (initial pull) to be available.
|
|
178
|
+
# elif local_digest is None and remote_digest:
|
|
179
|
+
# print(
|
|
180
|
+
# "✅ Image not found locally, but remote version exists. **New image available** (or needs initial pull)."
|
|
181
|
+
# )
|
|
182
|
+
# return True # No image, so consider an "update" (initial pull) to be available.
|
|
183
|
+
# elif local_digest and remote_digest is None:
|
|
184
|
+
# print(
|
|
185
|
+
# "⚠️ Local image exists, but remote manifest check failed (e.g., image deleted, auth issue). Status uncertain."
|
|
186
|
+
# )
|
|
187
|
+
# return False
|
|
188
|
+
# elif local_digest == remote_digest:
|
|
189
|
+
# print("👍 The local image is **UP-TO-DATE** with the remote registry.")
|
|
190
|
+
# return False
|
|
191
|
+
# elif local_digest != remote_digest:
|
|
192
|
+
# print("🚨 A **NEW** version of the image is available in the registry!")
|
|
193
|
+
# return True
|
|
194
|
+
|
|
195
|
+
# except (
|
|
196
|
+
# subprocess.CalledProcessError,
|
|
197
|
+
# FileNotFoundError,
|
|
198
|
+
# ):
|
|
199
|
+
# # If docker pull fails, it could be because the image doesn't exist locally yet,
|
|
200
|
+
# # or Docker isn't running. In either case, we can consider an "update" (initial pull)
|
|
201
|
+
# # to be available.
|
|
202
|
+
# return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_cli_version(package_name: str) -> bool:
|
|
206
|
+
# Get the current version of the package
|
|
207
|
+
try:
|
|
208
|
+
current_version = subprocess.check_output(
|
|
209
|
+
[sys.executable, "-m", "pip", "show", package_name],
|
|
210
|
+
stderr=subprocess.STDOUT,
|
|
211
|
+
).decode("utf-8")
|
|
212
|
+
|
|
213
|
+
for line in current_version.splitlines():
|
|
214
|
+
if line.startswith("Version:"):
|
|
215
|
+
current_version = line.split(" ")[1]
|
|
216
|
+
break
|
|
217
|
+
except subprocess.CalledProcessError as e:
|
|
218
|
+
print(
|
|
219
|
+
f"Error getting the current version of {package_name}: {e.output.decode('utf-8')}"
|
|
220
|
+
)
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Check for outdated packages using pip list --outdated
|
|
224
|
+
try:
|
|
225
|
+
outdated = subprocess.check_output(
|
|
226
|
+
[sys.executable, "-m", "pip", "list", "--outdated"],
|
|
227
|
+
stderr=subprocess.STDOUT,
|
|
228
|
+
).decode("utf-8")
|
|
229
|
+
|
|
230
|
+
if package_name in outdated:
|
|
231
|
+
# Extract the latest version from the output
|
|
232
|
+
for line in outdated.splitlines():
|
|
233
|
+
if package_name in line:
|
|
234
|
+
latest_version = line.split()[2]
|
|
235
|
+
print(
|
|
236
|
+
f"> A new version of '{package_name}' is available: {latest_version}. You have version {current_version}."
|
|
237
|
+
)
|
|
238
|
+
break
|
|
239
|
+
else:
|
|
240
|
+
print(
|
|
241
|
+
f"> The installed '{package_name}' is up-to-date (version {current_version})."
|
|
242
|
+
)
|
|
243
|
+
return False
|
|
244
|
+
except Exception as e:
|
|
245
|
+
print(f"Error checking for outdated packages: {str(e)}")
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def handle_update(image_tag, package_name="openbq"):
|
|
252
|
+
print(f"Pulling the latest '{image_tag}' image...")
|
|
253
|
+
try:
|
|
254
|
+
# Pull the image
|
|
255
|
+
subprocess.run(
|
|
256
|
+
["docker", "pull", f"{image_tag}"],
|
|
257
|
+
check=True,
|
|
258
|
+
)
|
|
259
|
+
# # Inspect to show the version
|
|
260
|
+
# result = subprocess.run(
|
|
261
|
+
# ["docker", "inspect", f"{image_tag}"],
|
|
262
|
+
# capture_output=True,
|
|
263
|
+
# text=True,
|
|
264
|
+
# check=True,
|
|
265
|
+
# )
|
|
266
|
+
# image_info = json.loads(result.stdout)
|
|
267
|
+
# labels = image_info[0].get("Config", {}).get("Labels", {})
|
|
268
|
+
# image_version = labels.get("bq.cli.version", "not found")
|
|
269
|
+
# core_version = labels.get("bq.core.version", "not found")
|
|
270
|
+
|
|
271
|
+
# print(f"OpenBQ Service container version: {image_version}")
|
|
272
|
+
# print(f"OpenBQ Core version: {core_version}\n")
|
|
273
|
+
except subprocess.CalledProcessError as e:
|
|
274
|
+
error_output = (
|
|
275
|
+
e.stderr.strip() if e.stderr else "See the output above for details."
|
|
276
|
+
)
|
|
277
|
+
print(
|
|
278
|
+
f"Error during Docker operation: {error_output}",
|
|
279
|
+
file=sys.stderr,
|
|
280
|
+
)
|
|
281
|
+
except FileNotFoundError:
|
|
282
|
+
print(
|
|
283
|
+
"Error: 'docker' command not found. Ensure Docker is installed and in your PATH.",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
except (json.JSONDecodeError, IndexError) as e:
|
|
287
|
+
print(f"Error parsing Docker image information: {e}", file=sys.stderr)
|
|
288
|
+
|
|
289
|
+
if not check_cli_version(package_name):
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# Upgrade the package if a new version is available
|
|
293
|
+
print("Upgrading package...")
|
|
294
|
+
try:
|
|
295
|
+
subprocess.check_call(
|
|
296
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", package_name]
|
|
297
|
+
)
|
|
298
|
+
print(f"> Successfully upgrade '{package_name}'.")
|
|
299
|
+
except subprocess.CalledProcessError as e:
|
|
300
|
+
print(f"Error upgrading '{package_name}': {e.output.decode('utf-8')}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def delete_image(image_tag):
|
|
304
|
+
print(f"Attempting to remove the '{image_tag}' Docker image...")
|
|
305
|
+
try:
|
|
306
|
+
subprocess.run(
|
|
307
|
+
["docker", "rmi", image_tag], check=True, capture_output=True, text=True
|
|
308
|
+
)
|
|
309
|
+
print(f"Successfully removed image '{image_tag}'.")
|
|
310
|
+
except subprocess.CalledProcessError as e:
|
|
311
|
+
error_message = e.stderr.decode().strip()
|
|
312
|
+
if "No such image" in error_message:
|
|
313
|
+
print(f"Image '{image_tag}' not found locally.")
|
|
314
|
+
else:
|
|
315
|
+
print(f"Error removing Docker image: {error_message}", file=sys.stderr)
|
|
316
|
+
print(
|
|
317
|
+
"This might be because a container is currently using the image.",
|
|
318
|
+
file=sys.stderr,
|
|
319
|
+
)
|
|
320
|
+
except FileNotFoundError:
|
|
321
|
+
print(
|
|
322
|
+
"Error: 'docker' command not found. Ensure Docker is installed and in your PATH.",
|
|
323
|
+
file=sys.stderr,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _uninstall_package():
|
|
328
|
+
"""Function to be called on exit to uninstall the package."""
|
|
329
|
+
try:
|
|
330
|
+
print(f"Uninstalling '{__package__}' package...")
|
|
331
|
+
# Use subprocess.run and check for errors
|
|
332
|
+
if "python" not in sys.executable:
|
|
333
|
+
print(
|
|
334
|
+
"You are probably running via EXE instead of PyPI package, no need to uninstall."
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
subprocess.run(
|
|
338
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", __package__],
|
|
339
|
+
check=True,
|
|
340
|
+
)
|
|
341
|
+
except subprocess.CalledProcessError as e:
|
|
342
|
+
print(f"Failed to uninstall '{__package__}': {str(e)}", file=sys.stderr)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def handle_uninstall(image_tag):
|
|
346
|
+
"""Handles the uninstall process."""
|
|
347
|
+
try:
|
|
348
|
+
confirm = input(f"Are you sure you want to uninstall {__package__}? (y/N): ")
|
|
349
|
+
|
|
350
|
+
if confirm.lower() not in ("y", "yes"):
|
|
351
|
+
print("Aborted")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
# Remove container image
|
|
355
|
+
confirm = input(
|
|
356
|
+
f"Are you sure you want to remove the container {image_tag} too? (y/N): "
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
print("Starting uninstall process...")
|
|
360
|
+
|
|
361
|
+
if confirm.lower() in ("y", "yes"):
|
|
362
|
+
delete_image(image_tag)
|
|
363
|
+
|
|
364
|
+
# Register the uninstall function to run when this script exits.
|
|
365
|
+
# This avoids issues with the script trying to delete itself while running.
|
|
366
|
+
atexit.register(_uninstall_package)
|
|
367
|
+
except (KeyboardInterrupt, EOFError):
|
|
368
|
+
print("\nAborted")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# def handle_cli_update(image_tag):
|
|
372
|
+
# """Handles the update check and process."""
|
|
373
|
+
# print(f'Checking for updates to "{image_tag}"...')
|
|
374
|
+
# if check_update(image_tag):
|
|
375
|
+
# confirm = input("> Do you want to pull the latest? (y/N): ")
|
|
376
|
+
# if confirm.lower() in ("y", "yes"):
|
|
377
|
+
# handle_update(image_tag)
|
|
378
|
+
# else:
|
|
379
|
+
# print(f"Your '{image_tag}' image is up to date.")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def show_version(image_tag):
|
|
383
|
+
"""Displays the version of the CLI and the container image."""
|
|
384
|
+
# Version of the CLI app
|
|
385
|
+
print(f"openbq: v{__version__}")
|
|
386
|
+
# Version of the container image
|
|
387
|
+
try:
|
|
388
|
+
result = subprocess.run(
|
|
389
|
+
["docker", "inspect", image_tag],
|
|
390
|
+
capture_output=True,
|
|
391
|
+
text=True,
|
|
392
|
+
check=True,
|
|
393
|
+
)
|
|
394
|
+
image_info = json.loads(result.stdout)
|
|
395
|
+
core_version = (
|
|
396
|
+
image_info[0]
|
|
397
|
+
.get("Config", {})
|
|
398
|
+
.get("Labels", {})
|
|
399
|
+
.get("bq.core.version", "not found")
|
|
400
|
+
)
|
|
401
|
+
image_version = (
|
|
402
|
+
image_info[0]
|
|
403
|
+
.get("Config", {})
|
|
404
|
+
.get("Labels", {})
|
|
405
|
+
.get("bq.service.version", "not found")
|
|
406
|
+
)
|
|
407
|
+
image_arch = image_info[0].get("Architecture", "not found")
|
|
408
|
+
|
|
409
|
+
print(f"bqCore: {core_version}")
|
|
410
|
+
print(f"Backend service image: {image_version}")
|
|
411
|
+
print(f"Image architecture: {image_arch}")
|
|
412
|
+
|
|
413
|
+
sys_info = get_host_info()
|
|
414
|
+
for key, value in sys_info.items():
|
|
415
|
+
print(f"{key}: {value}")
|
|
416
|
+
|
|
417
|
+
print()
|
|
418
|
+
except (
|
|
419
|
+
subprocess.CalledProcessError,
|
|
420
|
+
FileNotFoundError,
|
|
421
|
+
json.JSONDecodeError,
|
|
422
|
+
IndexError,
|
|
423
|
+
):
|
|
424
|
+
print(
|
|
425
|
+
f"Container image version: Could not determine (image '{image_tag}' not found or Docker not running).",
|
|
426
|
+
file=sys.stderr,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def is_subdir(child: Path, parent: Path) -> bool:
|
|
431
|
+
try:
|
|
432
|
+
return child.is_relative_to(parent)
|
|
433
|
+
except ValueError:
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def run_container(image_tag, bq_args: list[str], shm_size=None):
|
|
438
|
+
"""Builds and executes the docker run command."""
|
|
439
|
+
|
|
440
|
+
# Calculate SHM size
|
|
441
|
+
if not shm_size:
|
|
442
|
+
total_mem = get_total_memory_mb()
|
|
443
|
+
shm_size = get_shm_size(total_mem)
|
|
444
|
+
|
|
445
|
+
# Build the base docker command
|
|
446
|
+
docker_cmd = ["docker", "run", "--rm", "-it", f"--shm-size={shm_size}"]
|
|
447
|
+
|
|
448
|
+
# Optional CWD for reporting
|
|
449
|
+
current_dir = Path.cwd()
|
|
450
|
+
|
|
451
|
+
# Optional prefix to reconstruct the file path
|
|
452
|
+
input_prefix = ""
|
|
453
|
+
|
|
454
|
+
# Check input folder flag
|
|
455
|
+
for item in bq_args:
|
|
456
|
+
if item in ("-I", "--input"):
|
|
457
|
+
input_path_raw = bq_args.pop(bq_args.index(item) + 1)
|
|
458
|
+
# PowerShell auto added trailing '\' will escapes the closing quote
|
|
459
|
+
if '"' in input_path_raw:
|
|
460
|
+
split_list = input_path_raw.split('"')
|
|
461
|
+
input_path_raw = split_list[0]
|
|
462
|
+
if len(split_list) > 1:
|
|
463
|
+
bq_args.extend(split_list[1:])
|
|
464
|
+
input_path = Path(Path(input_path_raw).expanduser().resolve())
|
|
465
|
+
bq_args.remove(item)
|
|
466
|
+
break
|
|
467
|
+
input_path = None
|
|
468
|
+
|
|
469
|
+
if input_path:
|
|
470
|
+
# Sanitise input path
|
|
471
|
+
if not input_path.exists():
|
|
472
|
+
print(f"Exit. Input directory not found: {input_path_raw}")
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
if not input_path.is_dir():
|
|
475
|
+
print(f"Exit. Input directory not a folder: {input_path_raw}")
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
if not input_path.is_relative_to(current_dir): # Handle '..'
|
|
478
|
+
current_dir = input_path.parent
|
|
479
|
+
elif input_path.relative_to(current_dir).as_posix() == ".": # Handle '.'
|
|
480
|
+
current_dir = current_dir.parent
|
|
481
|
+
input_mount = input_path.relative_to(current_dir)
|
|
482
|
+
bq_args.extend(["--input", f"'{input_mount}'"])
|
|
483
|
+
volume_path = f"{input_path.resolve()}:/app/{input_mount}"
|
|
484
|
+
|
|
485
|
+
input_prefix = strip_suffix_path(Path(input_path_raw), input_mount)
|
|
486
|
+
if os.name == "nt":
|
|
487
|
+
input_prefix = input_prefix + "\\"
|
|
488
|
+
else:
|
|
489
|
+
input_prefix = input_prefix + "/"
|
|
490
|
+
|
|
491
|
+
docker_cmd.extend(["-v", volume_path])
|
|
492
|
+
|
|
493
|
+
# Check output folder flag
|
|
494
|
+
for item in bq_args:
|
|
495
|
+
if item in ("-O", "--output"):
|
|
496
|
+
output_path = (
|
|
497
|
+
Path(Path(bq_args.pop(bq_args.index(item) + 1))).expanduser().resolve()
|
|
498
|
+
)
|
|
499
|
+
bq_args.remove(item)
|
|
500
|
+
break
|
|
501
|
+
output_path = None
|
|
502
|
+
|
|
503
|
+
if output_path and not is_subdir(output_path, Path(input_path)):
|
|
504
|
+
# Sanitise output path
|
|
505
|
+
try:
|
|
506
|
+
if not output_path.exists():
|
|
507
|
+
output_path.mkdir(parents=True)
|
|
508
|
+
elif not output_path.is_dir():
|
|
509
|
+
print(f"Invalid output path: {output_path}")
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
except Exception as e:
|
|
512
|
+
print("Failed to create output folder:", str(e))
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
|
|
515
|
+
output_cwd = Path.cwd()
|
|
516
|
+
if not output_path.is_relative_to(output_cwd): # Handle '..'
|
|
517
|
+
output_cwd = output_path.parent
|
|
518
|
+
elif output_path.relative_to(output_cwd).as_posix() == ".": # Handle '.'
|
|
519
|
+
output_cwd = output_cwd.parent
|
|
520
|
+
output_mount = output_path.relative_to(output_cwd)
|
|
521
|
+
|
|
522
|
+
bq_args.extend(["--output", f"'{output_mount}'"])
|
|
523
|
+
volume_path = f"{output_path.expanduser().resolve()}:/app/{output_mount}"
|
|
524
|
+
docker_cmd.extend(["-v", volume_path])
|
|
525
|
+
|
|
526
|
+
docker_cmd.append(image_tag)
|
|
527
|
+
|
|
528
|
+
# The command to run inside the container
|
|
529
|
+
if not bq_args:
|
|
530
|
+
show_version(image_tag)
|
|
531
|
+
inner_command = ["python3 -m openbq --help"]
|
|
532
|
+
else:
|
|
533
|
+
inner_command = [
|
|
534
|
+
f"python3 -m openbq -P '{input_prefix}' -W '{current_dir.as_posix()}' {' '.join(bq_args)}"
|
|
535
|
+
]
|
|
536
|
+
docker_cmd.extend(inner_command)
|
|
537
|
+
|
|
538
|
+
# # Check image update
|
|
539
|
+
# local_digest = get_digest_from_cli(
|
|
540
|
+
# ["docker", "inspect", image_tag, "--format", "json"],
|
|
541
|
+
# )
|
|
542
|
+
# digest_tag = f"{image_tag.split(':', 1)[0]}@{local_digest}"
|
|
543
|
+
# local_digest = get_digest_from_cli(
|
|
544
|
+
# ["docker", "manifest", "inspect", digest_tag],
|
|
545
|
+
# )
|
|
546
|
+
|
|
547
|
+
# remote_digest = get_digest_from_cli(
|
|
548
|
+
# ["docker", "manifest", "inspect", image_tag],
|
|
549
|
+
# )
|
|
550
|
+
|
|
551
|
+
# if (
|
|
552
|
+
# local_digest is not None
|
|
553
|
+
# and remote_digest is not None
|
|
554
|
+
# and local_digest != remote_digest
|
|
555
|
+
# ):
|
|
556
|
+
# confirm = input(
|
|
557
|
+
# "🆕 A **NEW** version is available! Do you want to pull the latest? (y/N): "
|
|
558
|
+
# )
|
|
559
|
+
# if confirm.lower() in ("y", "yes"):
|
|
560
|
+
# handle_update(image_tag)
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
subprocess.run(docker_cmd, check=True)
|
|
564
|
+
except FileNotFoundError:
|
|
565
|
+
print(
|
|
566
|
+
"Error: 'docker' command not found. Ensure Docker is installed and in your PATH.",
|
|
567
|
+
file=sys.stderr,
|
|
568
|
+
)
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
except subprocess.CalledProcessError as e:
|
|
571
|
+
# The subprocess will have already printed its stderr.
|
|
572
|
+
# We exit with the same return code as the docker command.
|
|
573
|
+
# print(f"Command failed: {docker_cmd}")
|
|
574
|
+
sys.exit(e.returncode)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def strip_suffix_path(a: str | Path, b: str | Path) -> str:
|
|
578
|
+
"""
|
|
579
|
+
Removes the path suffix 'b' from path 'a'.
|
|
580
|
+
Returns a string.
|
|
581
|
+
Preserves '~' if present, and contracts absolute paths to '~' if they match the home directory.
|
|
582
|
+
|
|
583
|
+
Example:
|
|
584
|
+
strip_suffix_path("/home/user/project/src", "src") -> "~/project"
|
|
585
|
+
strip_suffix_path("~/a/b", "b") -> "~/a"
|
|
586
|
+
"""
|
|
587
|
+
a_str = str(a)
|
|
588
|
+
b_str = str(b)
|
|
589
|
+
|
|
590
|
+
# Detect path flavor: use Windows logic if backslashes are present or if running on Windows
|
|
591
|
+
# (checking backslashes first allows handling Windows paths on Linux)
|
|
592
|
+
is_win = "\\" in a_str or "\\" in b_str or os.name == "nt"
|
|
593
|
+
PureCls = PureWindowsPath if is_win else PurePosixPath
|
|
594
|
+
|
|
595
|
+
a_pure = PureCls(a_str)
|
|
596
|
+
b_pure = PureCls(b_str)
|
|
597
|
+
|
|
598
|
+
# 1. Strip the suffix
|
|
599
|
+
b_len = len(b_pure.parts)
|
|
600
|
+
if (
|
|
601
|
+
b_len > 0
|
|
602
|
+
and b_len <= len(a_pure.parts)
|
|
603
|
+
and a_pure.parts[-b_len:] == b_pure.parts
|
|
604
|
+
):
|
|
605
|
+
remaining = a_pure.parts[:-b_len]
|
|
606
|
+
if not remaining:
|
|
607
|
+
return "."
|
|
608
|
+
res_pure = PureCls(*remaining)
|
|
609
|
+
else:
|
|
610
|
+
res_pure = a_pure
|
|
611
|
+
|
|
612
|
+
res_str = str(res_pure)
|
|
613
|
+
|
|
614
|
+
# 2. Contract to '~' if the path is absolute and matches the current user's home.
|
|
615
|
+
# We can only do this reliably if the path flavor matches the current OS.
|
|
616
|
+
if is_win == (os.name == "nt"):
|
|
617
|
+
try:
|
|
618
|
+
# Resolve to a concrete Path object to check against home
|
|
619
|
+
p = Path(res_str)
|
|
620
|
+
if p.is_absolute():
|
|
621
|
+
home = Path.home()
|
|
622
|
+
# relative_to throws ValueError if p is not inside home
|
|
623
|
+
rel = p.relative_to(home)
|
|
624
|
+
return str(Path("~") / rel)
|
|
625
|
+
except ValueError:
|
|
626
|
+
# Path is not inside home directory
|
|
627
|
+
pass
|
|
628
|
+
|
|
629
|
+
return res_str
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openbq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Source Biometric Quality Framework Command-Line Interface (bqConnect CLI).
|
|
5
|
+
Keywords: openbq,bqconnect,cli,biometric
|
|
6
|
+
Author: Liam Wu
|
|
7
|
+
Author-email: Liam Wu <liam@openbq.io>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Maintainer: Liam Wu
|
|
27
|
+
Maintainer-email: Liam Wu <liam@openbq.io>
|
|
28
|
+
Requires-Python: >=3.10, <3.15
|
|
29
|
+
Project-URL: Documentation, https://openbq.github.io/getting_started/cli.html
|
|
30
|
+
Project-URL: Changelog, https://openbq.github.io/changelog/#bqconnectcli
|
|
31
|
+
Project-URL: Repository, https://github.com/openbq/bqconnectcli
|
|
32
|
+
Project-URL: Releases, https://github.com/openbq/bqconnectcli/releases
|
|
33
|
+
Project-URL: Issues, https://github.com/openbq/bqconnectcli/discussions
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# bqConnect CLI
|
|
37
|
+
|
|
38
|
+
[](https://pypi.python.org/pypi/openbq)
|
|
39
|
+
[](https://pypi.python.org/pypi/openbq)
|
|
40
|
+
[](https://pypi.python.org/pypi/openbq)
|
|
41
|
+
[](https://pypi.python.org/pypi/openbq)
|
|
42
|
+
[](https://pypi.python.org/pypi/openbq)
|
|
43
|
+
|
|
44
|
+
> bqconnect CLI (`openbq`) is part of the openbq framework. openbq is an open-source tool designed to empower biometric quality assessment, for more details please visit: [openbq](https://openbq.io).
|
|
45
|
+
|
|
46
|
+
> bqconnect CLI provides a command line interface to access biometric data quality evaluations for various modalities, including [face](https://docs.openbq.io/modalities/face.html), [fingerprint](https://docs.openbq.io/modalities/fingerprint.html), [iris](https://docs.openbq.io/modalities/iris.html), and [voice](https://docs.openbq.io/modalities/voice.html).
|
|
47
|
+
|
|
48
|
+
## Highlights
|
|
49
|
+
|
|
50
|
+
- 🚀 An easy‑to‑use tool for working with the `openbq` framework.
|
|
51
|
+
- 🖥️ Compatible with macOS, Linux, and Windows.
|
|
52
|
+
|
|
53
|
+
## Prerequisites
|
|
54
|
+
|
|
55
|
+
- [Docker](https://www.docker.com/)
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
1. Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pip install openbq
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
2. Display version info
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
openbq --version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
3. Validate installation (run benchmarking task)
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
openbq --benchmark
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
4. Run biometric quality assessment
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
openbq --mode face --input data/face
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
5. Run biometric quality assessment and compile a EDA report
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
openbq --mode fingerprint --input data/fingerprint --report
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Option flags
|
|
90
|
+
|
|
91
|
+
| Flag | Description |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| --help | Print help info. |
|
|
94
|
+
| --version, -V | Display version info. |
|
|
95
|
+
| --update, -U | Update `openbq` backend service container. |
|
|
96
|
+
| --uninstall | Uninstall `openbq`. |
|
|
97
|
+
|
|
98
|
+
> Please refer to the [openbq](https://docs.openbq.io/) documentations for further details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
openbq/__init__.py,sha256=J6H2XepDg7GtxLQKyMKadvIpVtNcCZxTDgM-1OOgcQQ,45
|
|
2
|
+
openbq/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
openbq/cli.py,sha256=uz6UN2mzfdcqLBHRxNio9o7YumH0rWHSBnSeY3iCwuA,1504
|
|
4
|
+
openbq/utils.py,sha256=9absHuo3BQo-W8WhJGGp2z5HbLN3jKJrKp9ABwBOYPk,22832
|
|
5
|
+
openbq-0.1.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
6
|
+
openbq-0.1.0.dist-info/entry_points.txt,sha256=KO6ZqUwNPt-DwjCpR8G1-HkQy8vtMAmd8KpT69FZQeU,44
|
|
7
|
+
openbq-0.1.0.dist-info/METADATA,sha256=3e-FjdrPGERHZUdUeu-r_05R2H-Oj2FqoKoB9yEDXDc,3717
|
|
8
|
+
openbq-0.1.0.dist-info/RECORD,,
|