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 ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ __package__ = "openbq"
openbq/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
+ [![PyPI - Version](https://img.shields.io/pypi/v/openbq)](https://pypi.python.org/pypi/openbq)
39
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/openbq)](https://pypi.python.org/pypi/openbq)
40
+ [![PyPI - Format](https://img.shields.io/pypi/format/openbq)](https://pypi.python.org/pypi/openbq)
41
+ [![PyPI - License](https://img.shields.io/pypi/l/openbq)](https://pypi.python.org/pypi/openbq)
42
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openbq)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.30
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ openbq = openbq:cli.main
3
+