From 49d6b0d43ca7f4b85941e4f8d482f76fcb0366c0 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Wed, 8 Jul 2026 14:33:46 +0200 Subject: [PATCH 01/17] feat: add `blueye bundle-model` CLI for creating CV model packages Adds the SDK's first console script: a `blueye` umbrella command whose `bundle-model` subcommand turns an exported ONNX model into a BlueyeCV-ready package zip (model.onnx + auto-generated model_meta.json at the zip root). - Introspects the ONNX graph and embedded Ultralytics metadata to infer the decoder format (all 8 detection formats plus OSTrack/MixFormerV2 single-object trackers), class count, labels, and input size; clearly unsupported models (classifiers, fp16 inputs, non-image inputs) are rejected with an explanation. - Interactive rich/questionary UI: model summary and inference panels, spinners, byte-level progress while zipping, prompts with autocompletion for everything that cannot be inferred. Every prompt can be pre-answered by a flag, and --yes/--dry-run enable scripting. - Runtime configuration prompts include a DLA-fitness analysis of the network (op-histogram heuristic) that recommends the Jetson DLA cores for convolution-style models and explains why not for NMS-in-graph/transformer models; the rate prompt defaults to unlimited. - Dependencies live in a new optional extra `blueye.sdk[cli]`; a stdlib-only gate prints per-platform install guidance (uv vs pip, shell quoting, Python-version wheel hints) instead of an ImportError when the extra is missing. - Generated metadata is validated against the BlueyeCV model-meta spec and matches the reference packages byte-for-semantics; smoke-tested by loading a generated bundle with be-cv. - 88 unit tests (heuristics, introspection, meta building/validation, zip writing, prompt seams, end-to-end main()) plus a docs page and mkdocs nav/reference entries. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/__init__.py | 11 + blueye/sdk/cli/bundle.py | 111 ++++++++ blueye/sdk/cli/bundle_model.py | 427 ++++++++++++++++++++++++++++++ blueye/sdk/cli/deps.py | 83 ++++++ blueye/sdk/cli/heuristics.py | 438 +++++++++++++++++++++++++++++++ blueye/sdk/cli/introspect.py | 183 +++++++++++++ blueye/sdk/cli/main.py | 78 ++++++ blueye/sdk/cli/meta.py | 247 +++++++++++++++++ blueye/sdk/cli/prompts.py | 98 +++++++ blueye/sdk/cli/ui.py | 100 +++++++ docs/bundling-cv-models.md | 97 +++++++ docs/reference/blueye/sdk/cli.md | 7 + mkdocs.yml | 2 + pyproject.toml | 14 + tests/test_cli_bundle.py | 77 ++++++ tests/test_cli_heuristics.py | 262 ++++++++++++++++++ tests/test_cli_introspect.py | 134 ++++++++++ tests/test_cli_main.py | 225 ++++++++++++++++ tests/test_cli_meta.py | 167 ++++++++++++ tests/test_cli_prompts.py | 65 +++++ uv.lock | 133 +++++++++- 21 files changed, 2955 insertions(+), 4 deletions(-) create mode 100644 blueye/sdk/cli/__init__.py create mode 100644 blueye/sdk/cli/bundle.py create mode 100644 blueye/sdk/cli/bundle_model.py create mode 100644 blueye/sdk/cli/deps.py create mode 100644 blueye/sdk/cli/heuristics.py create mode 100644 blueye/sdk/cli/introspect.py create mode 100644 blueye/sdk/cli/main.py create mode 100644 blueye/sdk/cli/meta.py create mode 100644 blueye/sdk/cli/prompts.py create mode 100644 blueye/sdk/cli/ui.py create mode 100644 docs/bundling-cv-models.md create mode 100644 docs/reference/blueye/sdk/cli.md create mode 100644 tests/test_cli_bundle.py create mode 100644 tests/test_cli_heuristics.py create mode 100644 tests/test_cli_introspect.py create mode 100644 tests/test_cli_main.py create mode 100644 tests/test_cli_meta.py create mode 100644 tests/test_cli_prompts.py diff --git a/blueye/sdk/cli/__init__.py b/blueye/sdk/cli/__init__.py new file mode 100644 index 00000000..9255c39d --- /dev/null +++ b/blueye/sdk/cli/__init__.py @@ -0,0 +1,11 @@ +"""Command line interface for the Blueye SDK. + +This package provides the `blueye` console script. It is deliberately kept out of +`blueye.sdk.__init__` so the SDK itself imports without the optional `[cli]` extra +installed. Only the standard library may be imported at module level here — the CLI +must be able to start (and print dependency guidance) when the extra is missing. +""" + +from .main import main + +__all__ = ["main"] diff --git a/blueye/sdk/cli/bundle.py b/blueye/sdk/cli/bundle.py new file mode 100644 index 00000000..ee830c96 --- /dev/null +++ b/blueye/sdk/cli/bundle.py @@ -0,0 +1,111 @@ +"""Zip writing for the `blueye bundle-model` CLI. + +Stdlib-only. The bundle is a flat zip: ``model.onnx``, any external weight files under +their exact embedded names, and ``model_meta.json`` — all at the archive root, matching +how BlueyeCV packages are unpacked onto the drone (``unzip -d ``). +""" + +from __future__ import annotations + +import json +import logging +import zipfile +from pathlib import Path +from typing import Callable + +logger = logging.getLogger(__name__) + +#: Name the model file always gets inside the bundle (the reference-package convention). +MODEL_FILE_NAME = "model.onnx" +META_FILE_NAME = "model_meta.json" + +_CHUNK_SIZE = 4 * 1024 * 1024 + + +class BundleError(Exception): + """A user-facing bundling problem (missing files, name collisions, ...).""" + + +def _copy_into_zip( + archive: zipfile.ZipFile, + source: Path, + arcname: str, + progress: Callable[[int], None], +) -> None: + """Stream one file into the archive in chunks, reporting bytes written.""" + info = zipfile.ZipInfo.from_file(source, arcname) + info.compress_type = zipfile.ZIP_DEFLATED # from_file defaults to ZIP_STORED + with source.open("rb") as reader, archive.open(info, "w") as writer: + while True: + chunk = reader.read(_CHUNK_SIZE) + if not chunk: + break + writer.write(chunk) + progress(len(chunk)) + + +def bundle_size(onnx_path: Path, external_files: list[str]) -> int: + """Total input size in bytes (for progress reporting). + + Args: + onnx_path: Path to the .onnx file. + external_files: External-data file names living next to the .onnx. + """ + total = onnx_path.stat().st_size + for name in external_files: + total += (onnx_path.parent / name).stat().st_size + return total + + +def write_bundle( + meta: dict, + onnx_path: Path, + external_files: list[str], + output_path: Path, + progress: Callable[[int], None] | None = None, +) -> None: + """Write the model package zip. + + The archive is written to ``.part`` and atomically renamed on success, so + an interrupted run never leaves a truncated zip behind. + + Args: + meta: The validated model_meta dict. + onnx_path: The source .onnx file (stored as ``model.onnx``). + external_files: External-data file names (must exist beside the .onnx; stored + under their exact names because the .onnx references them by name). + output_path: Destination zip path. + progress: Optional callback receiving the number of bytes just written. + + Raises: + BundleError: When an external file is missing or collides with a reserved name. + """ + progress = progress or (lambda _byte_count: None) + + for name in external_files: + if "/" in name or "\\" in name: + raise BundleError( + f"External data location '{name}' contains a path separator. Re-save the " + "model with all tensors in one file next to it, e.g.:\n" + " onnx.save(onnx.load(p), out, save_as_external_data=True,\n" + " all_tensors_to_one_file=True, location='model.onnx_data')" + ) + if name in (MODEL_FILE_NAME, META_FILE_NAME): + raise BundleError(f"External data file '{name}' collides with a reserved bundle name.") + if not (onnx_path.parent / name).is_file(): + raise BundleError( + f"The model references external data file '{name}', but it was not found " + f"next to {onnx_path.name}. Copy it into {onnx_path.parent} first." + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + partial_path = output_path.with_suffix(output_path.suffix + ".part") + try: + with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + _copy_into_zip(archive, onnx_path, MODEL_FILE_NAME, progress) + for name in external_files: + _copy_into_zip(archive, onnx_path.parent / name, name, progress) + archive.writestr(META_FILE_NAME, json.dumps(meta, indent=2, ensure_ascii=False) + "\n") + partial_path.replace(output_path) + finally: + partial_path.unlink(missing_ok=True) diff --git a/blueye/sdk/cli/bundle_model.py b/blueye/sdk/cli/bundle_model.py new file mode 100644 index 00000000..d4d70a68 --- /dev/null +++ b/blueye/sdk/cli/bundle_model.py @@ -0,0 +1,427 @@ +"""The `blueye bundle-model` subcommand. + +Bundles an ONNX model plus an auto-generated ``model_meta.json`` into a zip that can be +deployed as a BlueyeCV model package. Argument definitions are stdlib-only; everything +that needs the ``[cli]`` extra is imported inside :func:`run`, after the dependency +gate in ``main``. +""" + +from __future__ import annotations + +import argparse +import logging +import re +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +_DEVICES = ("cpu", "cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1", "coreml") +_RATE_PRESETS = ("max (unlimited)", "30", "15", "10", "5", "2", "custom...") + + +def add_parser(subparsers) -> None: + """Register the ``bundle-model`` subcommand on the ``blueye`` parser.""" + parser = subparsers.add_parser( + "bundle-model", + help="Bundle an ONNX model into a BlueyeCV model-package zip", + description=( + "Validate an ONNX model, auto-generate its model_meta.json, and bundle both " + "into a zip ready to deploy on a Blueye drone. Run without options for a " + "guided interactive session; every prompt can also be answered with a flag." + ), + ) + parser.add_argument("onnx_path", nargs="?", help="Path to the ONNX model file") + parser.add_argument("--name", help="Human-readable model name") + parser.add_argument("-o", "--output", help="Output zip path (default: _package.zip)") + parser.add_argument( + "--format", + dest="output_format", + choices=[ + "yolov2_grid", + "yolov5_flat", + "yolov8_flat", + "yolov8_seg", + "yolo_e2e", + "yolo_e2e_seg", + "ssd_multi", + "detr", + "ostrack", + "mixformerv2", + ], + help="Decoder format (default: inferred from the model)", + ) + parser.add_argument("--labels", help="Path to a labels file (one class name per line)") + parser.add_argument("--num-classes", type=int, help="Number of object classes") + parser.add_argument("--input-size", metavar="WxH", help="Model input size, e.g. 640x640") + parser.add_argument( + "--anchors", + help='Anchor pairs for yolov2_grid, e.g. "1.08,1.19 3.42,4.41" (grid-cell units)', + ) + parser.add_argument("--grid-size", type=int, help="Grid size for yolov2_grid") + parser.add_argument( + "--one-indexed-classes", + action="store_true", + help="Class IDs start at 1 (TensorFlow SSD exports)", + ) + parser.add_argument("--color-order", choices=["rgb", "bgr"], help="Model channel order") + parser.add_argument("--normalize-scale", type=float, help="Pixel scale (1/255 or 1.0)") + parser.add_argument("--normalize-mean", help="Per-channel mean, e.g. 0.485,0.456,0.406") + parser.add_argument("--normalize-std", help="Per-channel std, e.g. 0.229,0.224,0.225") + parser.add_argument("--confidence-threshold", type=float, help="Detection confidence threshold") + parser.add_argument("--nms-threshold", type=float, help="NMS IoU threshold") + parser.add_argument( + "--tracking", + choices=["none", "iou", "byte_track"], + help="Multi-object tracking algorithm (detection packages)", + ) + parser.add_argument( + "--runtime-device", choices=list(_DEVICES), help="Execution provider on the drone" + ) + parser.add_argument("--runtime-hz", help='Maximum inference rate in Hz, or "max" for unlimited') + parser.add_argument( + "--runtime-enabled", + action="store_true", + help="Autolaunch this package on the drone (runtime.enabled=true)", + ) + parser.add_argument("--template-size", type=int, help="SOT template crop size (px)") + parser.add_argument("--search-size", type=int, help="SOT search region size (px)") + parser.add_argument( + "-y", "--yes", action="store_true", help="Accept all inferred defaults, no prompts" + ) + parser.add_argument("--force", action="store_true", help="Overwrite an existing zip") + parser.add_argument( + "--dry-run", action="store_true", help="Print the generated model_meta.json and stop" + ) + parser.add_argument( + "--strict", action="store_true", help="Treat onnx.checker failures as fatal" + ) + parser.add_argument("--quiet", action="store_true", help="Only errors and the result path") + + +def _sanitize_name(name: str) -> str: + """Turn a model name into a filesystem-friendly package stem.""" + stem = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") + return stem or "model" + + +def _parse_input_size(value: str): + match = re.fullmatch(r"(\d+)[xX](\d+)", value.strip()) + if not match: + from .main import CliError + + raise CliError(f'--input-size must look like "640x640", got "{value}"') + return int(match.group(1)), int(match.group(2)) + + +def _parse_anchors(value: str) -> list[list[float]]: + from .main import CliError + + anchors = [] + for pair in value.replace(";", " ").split(): + parts = pair.split(",") + if len(parts) != 2: + raise CliError(f'--anchors pairs must be "w,h", got "{pair}"') + anchors.append([float(parts[0]), float(parts[1])]) + return anchors + + +def _parse_float_list(value: str, flag: str) -> list[float]: + from .main import CliError + + try: + return [float(part) for part in value.split(",") if part.strip()] + except ValueError as error: + raise CliError(f"{flag} must be a comma-separated float list: {error}") from error + + +def _read_labels_file(path: Path) -> list[str]: + from .main import CliError + + if not path.is_file(): + raise CliError(f"Labels file not found: {path}") + labels = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()] + return [label for label in labels if label] + + +def _resolve_labels(args, config, prompter, console) -> list[str]: + """Resolve the labels list from flags, embedded metadata, or prompts.""" + from .main import CliError + + if args.labels: + labels = _read_labels_file(Path(args.labels)) + elif config.labels and config.kind != "sot": + preview = ", ".join(config.labels[:5]) + ("..." if len(config.labels) > 5 else "") + use_embedded = prompter.confirm( + f"Use the {len(config.labels)} embedded labels ({preview})?", True, "--labels" + ) + labels = list(config.labels) if use_embedded else [] + if not labels: + path = prompter.path("Path to a labels file (one name per line):", None, "--labels") + labels = _read_labels_file(Path(path)) + elif config.kind == "sot": + return ["tracked"] + else: + path = prompter.path("Path to a labels file (one name per line):", None, "--labels") + labels = _read_labels_file(Path(path)) + + num_classes = config.num_classes + if num_classes is not None and len(labels) != num_classes: + console.print( + f"[yellow]The model implies {num_classes} classes but {len(labels)} labels " + "were provided.[/yellow]" + ) + pad = prompter.confirm( + f"Pad/truncate the labels to {num_classes} entries?", False, "--labels" + ) + if not pad: + raise CliError( + f"Label count mismatch: {len(labels)} labels vs {num_classes} classes. " + "Provide a matching --labels file (DETR-style models include a leading " + '"N/A" no-object label).' + ) + labels = (labels + [f"class_{i}" for i in range(len(labels), num_classes)])[:num_classes] + return labels + + +def _resolve_runtime(args, dla, prompter): + """Resolve the runtime block: device (with DLA recommendation), rate, autolaunch.""" + if args.runtime_device: + device = args.runtime_device + else: + recommended = "tensorrt-dla0" if dla.good_fit else "tensorrt" + choices = [ + device + (" (recommended)" if device == recommended else "") for device in _DEVICES + ] + answer = prompter.select( + f"Execution device on the drone? ({dla.reason})", + choices, + recommended + " (recommended)", + "--runtime-device", + ) + device = answer.replace(" (recommended)", "") + + if args.runtime_hz: + hz = None if args.runtime_hz.lower() == "max" else float(args.runtime_hz) + else: + answer = prompter.select( + "Maximum inference rate?", list(_RATE_PRESETS), _RATE_PRESETS[0], "--runtime-hz" + ) + if answer.startswith("max"): + hz = None + elif answer == "custom...": + hz = float(prompter.text("Rate in Hz:", "10", "--runtime-hz")) + else: + hz = float(answer) + + enabled = args.runtime_enabled or prompter.confirm( + "Autolaunch this package on the drone (runtime.enabled)?", False, "--runtime-enabled" + ) + return device, hz, enabled + + +def run(args: argparse.Namespace) -> int: + """Run the bundle-model subcommand. Returns the process exit code.""" + from . import bundle, heuristics, introspect, meta, prompts, ui + from .main import CliError + + console = ui.make_console(quiet=args.quiet) + interactive = not args.yes and sys.stdin.isatty() and sys.stdout.isatty() + prompter = prompts.QuestionaryPrompter() if interactive else prompts.NonInteractivePrompter() + + try: + # Stage 0 — resolve the input path. + onnx_path = Path( + args.onnx_path or prompter.path("Path to the ONNX model:", None, "ONNX_PATH") + ).expanduser() + + # Stage 1 — load and check. + with console.status("[cyan]Loading ONNX model..."): + info = introspect.load_model_info(onnx_path) + console.print(ui.model_summary_panel(info)) + + with console.status("[cyan]Running the ONNX checker..."): + check_error = introspect.check_model(onnx_path) + if check_error is not None: + first_line = check_error.splitlines()[0] + if args.strict: + raise CliError(f"onnx.checker failed: {first_line}") + console.print(f"[yellow]onnx.checker warning:[/yellow] {first_line}") + if not prompter.confirm("Continue anyway?", True, "--strict"): + return 1 + + # Stage 2 — infer the configuration. + with console.status("[cyan]Analyzing model outputs..."): + config = heuristics.infer(info) + dla = heuristics.assess_dla_fitness(info) + console.print(ui.inference_panel(config)) + + # Stage 3 — confirm / collect. + output_format = args.output_format or config.output_format + if output_format is None or ( + not args.output_format and config.confidence == "low" and interactive + ): + output_format = prompter.select( + "Model output format?", + list(heuristics.ALL_FORMATS), + config.output_format, + "--format", + ) + kind = "sot" if output_format in heuristics.SOT_FORMATS else "detection" + + default_name = args.name or config.suggested_name or onnx_path.stem + name = args.name or prompter.text("Model name:", default_name, "--name") + + options = meta.MetaOptions(name=name, output_format=output_format, kind=kind) + + if args.input_size: + options.input_width, options.input_height = _parse_input_size(args.input_size) + else: + options.input_width = config.input_width + options.input_height = config.input_height + needs_size = kind == "detection" and ( + output_format in meta.FORMATS_REQUIRING_INPUT_SIZE or output_format == "yolov2_grid" + ) + if needs_size and (options.input_width is None or options.input_height is None): + size = prompter.text( + "Model input size (WxH, the model has dynamic dims):", "640x640", "--input-size" + ) + options.input_width, options.input_height = _parse_input_size(size) + + if kind == "detection": + options.num_classes = args.num_classes or config.num_classes + config.num_classes = options.num_classes + options.labels = _resolve_labels(args, config, prompter, console) + if options.num_classes is None: + options.num_classes = len(options.labels) + + if output_format == "yolov2_grid": + anchors_text = args.anchors or prompter.text( + 'Anchor pairs in grid-cell units (e.g. "1.08,1.19 3.42,4.41"):', + "", + "--anchors", + ) + options.anchors = _parse_anchors(anchors_text) + options.grid_size = args.grid_size or config.grid_size + if not options.grid_size: + options.grid_size = int( + prompter.text("Feature-map grid size:", "13", "--grid-size") + ) + if output_format == "ssd_multi": + options.one_indexed_classes = args.one_indexed_classes or prompter.confirm( + "Are class IDs one-indexed (TensorFlow SSD exports)?", + False, + "--one-indexed-classes", + ) + if args.confidence_threshold is not None: + options.confidence_threshold = args.confidence_threshold + if args.nms_threshold is not None: + options.nms_threshold = args.nms_threshold + + tracking = args.tracking or prompter.select( + "Multi-object tracking?", ["none", "iou", "byte_track"], "none", "--tracking" + ) + options.tracking_algorithm = tracking + else: + options.template_size = args.template_size or config.template_size + options.search_size = args.search_size or config.search_size + options.labels = ["tracked"] + + # Preprocessing: per-format defaults, customizable. + scale, mean, std = meta.default_preprocessing(output_format) + options.normalize_scale = scale + options.normalize_mean = mean + options.normalize_std = std + has_preproc_flags = any( + value is not None + for value in ( + args.color_order, + args.normalize_scale, + args.normalize_mean, + args.normalize_std, + ) + ) + if has_preproc_flags or prompter.confirm( + "Customize preprocessing (color order / normalization)?", False, "--color-order" + ): + options.color_order = args.color_order or prompter.select( + "Channel order the model expects?", ["rgb", "bgr"], "rgb", "--color-order" + ) + if args.normalize_scale is not None: + options.normalize_scale = args.normalize_scale + else: + scale_answer = prompter.select( + "Pixel normalization?", + ["1/255 (inputs in [0,1])", "1.0 (raw 0-255 pixels)"], + "1/255 (inputs in [0,1])" if scale != 1.0 else "1.0 (raw 0-255 pixels)", + "--normalize-scale", + ) + options.normalize_scale = ( + meta.SCALE_1_OVER_255 if scale_answer.startswith("1/255") else 1.0 + ) + if args.normalize_mean is not None: + options.normalize_mean = _parse_float_list(args.normalize_mean, "--normalize-mean") + if args.normalize_std is not None: + options.normalize_std = _parse_float_list(args.normalize_std, "--normalize-std") + + # Runtime block (always collected; DLA recommendation, rate, autolaunch). + device, hz, enabled = _resolve_runtime(args, dla, prompter) + options.runtime_device = device + options.runtime_hz = hz + options.runtime_enabled = enabled + + # Stage 4 — build and validate. + meta_dict = meta.build_meta(options) + problems = meta.validate_meta(meta_dict) + if problems: + for problem in problems: + console.print(f"[red]invalid metadata:[/red] {problem}") + return 1 + + if args.dry_run: + console.print(ui.meta_preview(meta_dict)) + return 0 + + # Stage 5 — write the zip. + default_output = f"{_sanitize_name(name)}_package.zip" + output_path = Path( + args.output or prompter.text("Output zip path:", default_output, "--output") + ).expanduser() + if output_path.exists() and not args.force: + if not prompter.confirm( + f"{output_path} exists — overwrite?", bool(args.yes), "--force" + ): + raise CliError(f"{output_path} already exists (use --force to overwrite).") + + external_files = list(info.external_data_files) + total = bundle.bundle_size(onnx_path, external_files) + with ui.make_progress(console) as progress: + task = progress.add_task("Writing bundle", total=total) + bundle.write_bundle( + meta_dict, + onnx_path, + external_files, + output_path, + progress=lambda byte_count: progress.update(task, advance=byte_count), + ) + + size_mb = output_path.stat().st_size / (1024 * 1024) + contents = ", ".join([bundle.MODEL_FILE_NAME, *external_files, bundle.META_FILE_NAME]) + console.print() + console.print(f"[green bold]Bundle written:[/green bold] {output_path} ({size_mb:.1f} MB)") + console.print(f"[dim]Contents: {contents}[/dim]") + console.print( + "[dim]Deploy: unzip into a directory on the drone and run " + "`be-cv --input `[/dim]" + ) + return 0 + + except (introspect.IntrospectionError, heuristics.UnsupportedModelError) as error: + console.print(f"[red]Error:[/red] {error}") + return 1 + except bundle.BundleError as error: + console.print(f"[red]Error:[/red] {error}") + return 1 + except prompts.PromptAborted: + console.print("[yellow]Cancelled.[/yellow]") + return 130 diff --git a/blueye/sdk/cli/deps.py b/blueye/sdk/cli/deps.py new file mode 100644 index 00000000..4d64a64f --- /dev/null +++ b/blueye/sdk/cli/deps.py @@ -0,0 +1,83 @@ +"""Optional-dependency detection and install guidance for the `blueye` CLI. + +This module must only import from the standard library: it runs precisely when the +optional `[cli]` extra (onnx, rich, questionary) is not installed, and its job is to +tell the user how to install it on their platform instead of failing with an +ImportError. +""" + +from __future__ import annotations + +import importlib.util +import logging +import shutil +import sys + +logger = logging.getLogger(__name__) + +#: Distributions required by the CLI, in import-name form. +CLI_DEPENDENCIES = ("onnx", "rich", "questionary") + +#: Newest CPython minor version the `onnx` project publishes prebuilt wheels for. Kept +#: conservative; only used to print a hint, never to block. +_NEWEST_PYTHON_WITH_ONNX_WHEELS = (3, 13) + + +def missing_cli_deps() -> list[str]: + """Return the CLI dependencies that are not importable in this environment. + + Returns: + The subset of :data:`CLI_DEPENDENCIES` for which no importable module was found. + """ + return [name for name in CLI_DEPENDENCIES if importlib.util.find_spec(name) is None] + + +def _install_command() -> str: + """Return the install command best matching the user's environment.""" + package_spec = "blueye.sdk[cli]" + on_windows = sys.platform.startswith("win") + if shutil.which("uv") is not None: + return f'uv pip install "{package_spec}"' + if on_windows: + # cmd.exe needs no quotes; PowerShell treats brackets specially, so single quotes + # are the safe recommendation. + return f"python -m pip install '{package_spec}'" + return f'python -m pip install "{package_spec}"' + + +def print_install_guidance(missing: list[str]) -> None: + """Print human-friendly guidance for installing the missing CLI dependencies. + + Uses plain print() on purpose: rich may itself be one of the missing packages. + + Args: + missing: The import names reported by :func:`missing_cli_deps`. + """ + print() + print("The `blueye` CLI needs a few extra packages that are not installed:") + print() + for name in missing: + print(f" - {name}") + print() + print("Install them with the SDK's [cli] extra:") + print() + print(f" {_install_command()}") + print() + if sys.platform == "darwin": + # zsh (the macOS default shell) expands square brackets, hence the quotes. + print('(Keep the quotes around "blueye.sdk[cli]" — zsh expands square brackets.)') + elif sys.platform.startswith("win"): + print("(In PowerShell, use single quotes: 'blueye.sdk[cli]'. In cmd.exe no quotes") + print("are needed.)") + else: + print('(Keep the quotes around "blueye.sdk[cli]" if your shell expands brackets.)') + if "onnx" in missing and sys.version_info[:2] > _NEWEST_PYTHON_WITH_ONNX_WHEELS: + newest = ".".join(str(v) for v in _NEWEST_PYTHON_WITH_ONNX_WHEELS) + running = ".".join(str(v) for v in sys.version_info[:2]) + print() + print( + f"Note: you are running Python {running}; the onnx package may not publish" + f" prebuilt wheels for it yet. If the install fails while building onnx from" + f" source, use Python 3.10-{newest} instead." + ) + print() diff --git a/blueye/sdk/cli/heuristics.py b/blueye/sdk/cli/heuristics.py new file mode 100644 index 00000000..87d8a4aa --- /dev/null +++ b/blueye/sdk/cli/heuristics.py @@ -0,0 +1,438 @@ +"""Model-type inference for the `blueye bundle-model` CLI. + +Pure functions over :class:`~blueye.sdk.cli.introspect.ModelInfo`-shaped data: no +onnx import, no I/O, no terminal — everything here is unit-testable with hand-built +dataclasses. The rules mirror what the BlueyeCV output decoders expect (see +BlueyeCV/docs/model-meta-spec.md). +""" + +from __future__ import annotations + +import ast +import logging +from dataclasses import dataclass, field +from typing import Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from .introspect import ModelInfo, TensorSpec + +logger = logging.getLogger(__name__) + +#: onnx.TensorProto element types (mirrored to avoid an onnx import). +_FLOAT32 = 1 +_FLOAT16 = 10 + +#: All output formats BlueyeCV supports, in the order shown to the user. +DETECTION_FORMATS = ( + "yolov2_grid", + "yolov5_flat", + "yolov8_flat", + "yolov8_seg", + "yolo_e2e", + "yolo_e2e_seg", + "ssd_multi", + "detr", +) +SOT_FORMATS = ("ostrack", "mixformerv2") +ALL_FORMATS = DETECTION_FORMATS + SOT_FORMATS + +#: Number of mask coefficients / prototype channels in YOLO -seg heads. +_NUM_MASK_COEFFS = 32 + + +class UnsupportedModelError(Exception): + """The model is clearly not usable by BlueyeCV; the message explains why.""" + + +@dataclass +class InferredConfig: + """What could be derived from the model, plus how sure we are. + + Attributes: + kind: "detection", "sot", or "unknown" (format must be chosen manually). + output_format: The inferred model_meta output_format, or None. + confidence: "high" when the shape/metadata evidence is unambiguous; "low" when + the format should be confirmed with the user. + num_classes: Class count derived from the output shape or metadata. + input_width: Model input width in pixels (None if dynamic/unknown). + input_height: Model input height in pixels (None if dynamic/unknown). + grid_size: Feature-map grid size (yolov2_grid only). + template_size: SOT template crop size (from the template input). + search_size: SOT search crop size (from the search input). + labels: Class labels from embedded metadata, index order preserved. + suggested_name: Human-readable name suggestion (metadata description or None). + notes: Human-readable reasoning, shown in the inference summary. + """ + + kind: Literal["detection", "sot", "unknown"] = "unknown" + output_format: str | None = None + confidence: Literal["high", "low"] = "low" + num_classes: int | None = None + input_width: int | None = None + input_height: int | None = None + grid_size: int | None = None + template_size: int | None = None + search_size: int | None = None + labels: list[str] | None = None + suggested_name: str | None = None + notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class DlaAssessment: + """Whether the model is a good fit for the Jetson DLA cores, and why.""" + + good_fit: bool + reason: str + + +def _fixed(dim: int | str | None) -> int | None: + """Return the dimension as an int when it is fixed, else None.""" + return dim if isinstance(dim, int) else None + + +def _is_image_input(spec: TensorSpec) -> bool: + """True for a 4-D float tensor with 1 or 3 channels in NCHW or NHWC layout.""" + if len(spec.dims) != 4 or spec.dtype not in (_FLOAT32, _FLOAT16): + return False + channels_first = _fixed(spec.dims[1]) + channels_last = _fixed(spec.dims[3]) + return channels_first in (1, 3) or channels_last in (1, 3) + + +def _image_hw(spec: TensorSpec) -> tuple[int | None, int | None]: + """Return (height, width) of an image input, handling NCHW vs NHWC.""" + if _fixed(spec.dims[1]) in (1, 3): # NCHW + return _fixed(spec.dims[2]), _fixed(spec.dims[3]) + return _fixed(spec.dims[1]), _fixed(spec.dims[2]) # NHWC + + +def parse_ultralytics_metadata(metadata: dict[str, str]) -> dict[str, object]: + """Parse the metadata_props Ultralytics embeds in its ONNX exports. + + Args: + metadata: The raw metadata_props key/value dict. + + Returns: + A dict possibly containing "labels" (list[str]), "imgsz" ((height, width)), + "task" (str), and "description" (str). Keys are absent when not parseable. + """ + parsed: dict[str, object] = {} + names_repr = metadata.get("names") + if names_repr: + try: + names = ast.literal_eval(names_repr) + if isinstance(names, dict): + parsed["labels"] = [str(names[key]) for key in sorted(names)] + elif isinstance(names, (list, tuple)): + parsed["labels"] = [str(name) for name in names] + except (ValueError, SyntaxError): + logger.debug("Could not parse metadata 'names': %r", names_repr) + imgsz_repr = metadata.get("imgsz") + if imgsz_repr: + try: + imgsz = ast.literal_eval(imgsz_repr) + if isinstance(imgsz, (list, tuple)) and len(imgsz) == 2: + parsed["imgsz"] = (int(imgsz[0]), int(imgsz[1])) + except (ValueError, SyntaxError): + logger.debug("Could not parse metadata 'imgsz': %r", imgsz_repr) + if metadata.get("task"): + parsed["task"] = metadata["task"] + if metadata.get("description"): + parsed["description"] = metadata["description"] + return parsed + + +def _reject_unsupported(info: ModelInfo, image_inputs: list[TensorSpec]) -> None: + """Raise UnsupportedModelError for models BlueyeCV can clearly never run.""" + if not image_inputs: + described = ( + ", ".join(f"{spec.name}: {spec.dtype_name} {spec.shape_str}" for spec in info.inputs) + or "(no inputs)" + ) + raise UnsupportedModelError( + "The model has no image-like input tensor. BlueyeCV expects a 4-D float " + f"tensor with 1 or 3 channels (e.g. [1, 3, 640, 640]); found: {described}." + ) + for spec in image_inputs: + if spec.dtype == _FLOAT16: + raise UnsupportedModelError( + f"Input '{spec.name}' is float16. BlueyeCV feeds float32 tensors — " + "re-export the model with float32 inputs (e.g. half=False for " + "Ultralytics exports). Models with fp16 weights but float32 " + "inputs/outputs are fine." + ) + if len(image_inputs) > 3: + raise UnsupportedModelError( + f"The model has {len(image_inputs)} image inputs; BlueyeCV supports " + "detection models (1 input) and single-object trackers (2-3 inputs)." + ) + if len(image_inputs) == 1 and len(info.outputs) == 1: + output = info.outputs[0] + dims = [_fixed(dim) for dim in output.dims] + is_2d_classes = len(dims) == 2 and (dims[1] or 0) > 1 + is_4d_squeezed = len(dims) == 4 and dims[2] == 1 and dims[3] == 1 and (dims[1] or 0) > 1 + if is_2d_classes or is_4d_squeezed: + raise UnsupportedModelError( + f"The output shape {output.shape_str} looks like an image classifier. " + "BlueyeCV runs object detection and single-object tracking models only." + ) + + +def _infer_sot(info: ModelInfo, image_inputs: list[TensorSpec], config: InferredConfig) -> None: + """Fill in the config for a 2- or 3-image-input single-object tracker.""" + config.kind = "sot" + by_height = sorted(image_inputs, key=lambda spec: _image_hw(spec)[0] or 0) + template, search = by_height[0], by_height[-1] + config.template_size = _image_hw(template)[0] + config.search_size = _image_hw(search)[0] + config.input_width = config.search_size + config.input_height = config.search_size + # OSTrack exports two inputs (template, search); MixFormerV2 exports three + # (template, online_template, search). + config.output_format = "ostrack" if len(image_inputs) == 2 else "mixformerv2" + config.confidence = "low" # Same input structure could come from other trackers. + config.labels = ["tracked"] + config.notes.append( + f"{len(image_inputs)} image inputs (template {config.template_size}px / search " + f"{config.search_size}px) -> single-object tracker, {config.output_format}-style" + ) + + +def _infer_detection_two_outputs(outputs: tuple[TensorSpec, ...], config: InferredConfig) -> None: + """Handle the two-output families: YOLO segmentation heads and DETR.""" + first_dims = [_fixed(dim) for dim in outputs[0].dims] + second_dims = [_fixed(dim) for dim in outputs[1].dims] + + # Segmentation: the second output is the [1, 32, h, w] mask prototype tensor. + if len(second_dims) == 4 and second_dims[1] == _NUM_MASK_COEFFS: + if len(first_dims) == 3 and (first_dims[2] or 0) == 6 + _NUM_MASK_COEFFS: + config.kind = "detection" + config.output_format = "yolo_e2e_seg" + config.confidence = "high" + config.notes.append( + f"outputs {outputs[0].shape_str} + mask prototypes -> end-to-end " + "segmentation (NMS in the model)" + ) + return + if len(first_dims) == 3 and first_dims[1] is not None: + config.kind = "detection" + config.output_format = "yolov8_seg" + config.confidence = "high" + config.num_classes = first_dims[1] - 4 - _NUM_MASK_COEFFS + config.notes.append( + f"outputs {outputs[0].shape_str} + mask prototypes -> YOLOv8-style " + f"segmentation, {config.num_classes} classes" + ) + return + + # DETR: logits [1, Q, C+1] + boxes [1, Q, 4]. + if ( + len(first_dims) == 3 + and len(second_dims) == 3 + and second_dims[2] == 4 + and (first_dims[2] or 0) > 4 + and first_dims[1] == second_dims[1] + ): + config.kind = "detection" + config.output_format = "detr" + config.confidence = "high" + config.num_classes = (first_dims[2] or 1) - 1 + config.notes.append( + f"logits {outputs[0].shape_str} + boxes {outputs[1].shape_str} -> DETR, " + f"{config.num_classes} classes (+1 no-object logit)" + ) + + +def _infer_detection_single_output(output: TensorSpec, config: InferredConfig) -> None: + """Handle the single-output families: e2e, v8-flat, v5-flat, and v2-grid.""" + dims = [_fixed(dim) for dim in output.dims] + + if len(dims) == 3 and dims[2] == 6: + config.kind = "detection" + config.output_format = "yolo_e2e" + config.confidence = "high" + config.notes.append( + f"output {output.shape_str} (x1,y1,x2,y2,score,class rows) -> end-to-end " + "YOLO (NMS in the model)" + ) + return + + if len(dims) == 3 and dims[1] is not None and dims[2] is not None: + rows, cols = dims[1], dims[2] + if rows < cols and rows > 4: + config.kind = "detection" + config.output_format = "yolov8_flat" + config.confidence = "high" + config.num_classes = rows - 4 + config.notes.append( + f"output {output.shape_str} (channels-first, 4+C rows) -> YOLOv8 flat, " + f"{config.num_classes} classes" + ) + return + if cols < rows and cols > 5: + config.kind = "detection" + config.output_format = "yolov5_flat" + config.confidence = "high" + config.num_classes = cols - 5 + config.notes.append( + f"output {output.shape_str} (rows of 5+C values) -> YOLOv5 flat, " + f"{config.num_classes} classes" + ) + return + + if len(dims) == 4 and dims[2] is not None and dims[2] == dims[3] and (dims[1] or 0) > 0: + config.kind = "detection" + config.output_format = "yolov2_grid" + config.confidence = "low" # anchors/classes are not derivable from the shape + config.grid_size = dims[2] + config.notes.append( + f"output {output.shape_str} -> YOLOv2-style grid (grid {dims[2]}x{dims[3]}); " + "anchors and class count come from the training config" + ) + + +def infer(info: ModelInfo) -> InferredConfig: + """Infer the BlueyeCV model configuration from the ONNX graph and metadata. + + Args: + info: The introspected model. + + Returns: + An InferredConfig; ``kind == "unknown"`` means the format must be chosen + manually. + + Raises: + UnsupportedModelError: When the model clearly cannot run in BlueyeCV + (classifier, no image input, float16 input, too many inputs). + """ + config = InferredConfig() + image_inputs = [spec for spec in info.inputs if _is_image_input(spec)] + _reject_unsupported(info, image_inputs) + + # Input geometry from the (primary) image input. + height, width = _image_hw(image_inputs[0]) + config.input_height = height + config.input_width = width + + # Ultralytics metadata beats shape guessing where present. + ultralytics = parse_ultralytics_metadata(info.metadata) + if "labels" in ultralytics: + config.labels = list(ultralytics["labels"]) # type: ignore[arg-type] + config.notes.append(f"{len(config.labels)} labels from embedded Ultralytics metadata") + if "imgsz" in ultralytics and (height is None or width is None): + config.input_height, config.input_width = ultralytics["imgsz"] # type: ignore[misc] + config.notes.append("input size from embedded 'imgsz' metadata") + if "description" in ultralytics: + config.suggested_name = str(ultralytics["description"]) + + if len(image_inputs) >= 2: + _infer_sot(info, image_inputs, config) + return config + + outputs = info.outputs + if len(outputs) == 2: + _infer_detection_two_outputs(outputs, config) + elif len(outputs) == 1: + _infer_detection_single_output(outputs[0], config) + elif len(outputs) == 4: + config.kind = "detection" + config.output_format = "ssd_multi" + config.confidence = "low" + config.notes.append("4 outputs (boxes/classes/scores/count) -> SSD-style multi-output") + + # Cross-check the class count against embedded labels. + if config.labels is not None and config.num_classes is None: + config.num_classes = len(config.labels) + if ( + config.labels is not None + and config.num_classes is not None + and len(config.labels) != config.num_classes + and config.kind == "detection" + ): + config.notes.append( + f"warning: {len(config.labels)} embedded labels but the output shape implies " + f"{config.num_classes} classes" + ) + + if config.output_format is None: + config.kind = "unknown" + config.notes.append("output shapes did not match any known decoder format") + return config + + +#: Ops that force (partial) GPU fallback or are architecture markers of transformer +#: models — both make a model a poor DLA candidate. +_DLA_UNFRIENDLY_OPS = { + "NonMaxSuppression", + "TopK", + "LayerNormalization", + "SkipLayerNormalization", + "Einsum", + "Attention", + "MultiHeadAttention", + "GridSample", + "ScatterND", + "RoiAlign", +} + +#: Convolution-network ops the DLA executes natively. +_DLA_FRIENDLY_OPS = { + "Conv", + "ConvTranspose", + "MaxPool", + "AveragePool", + "GlobalAveragePool", + "Relu", + "LeakyRelu", + "PRelu", + "Sigmoid", + "Tanh", + "BatchNormalization", + "Add", + "Mul", + "Concat", + "Resize", + "Upsample", +} + + +def assess_dla_fitness(info: ModelInfo) -> DlaAssessment: + """Judge whether the model is a good candidate for the Jetson DLA cores. + + The DLA natively executes convolution-style networks; NMS/TopK and + attention/normalization layers fall back to the GPU, negating the benefit. This is + a heuristic over the op histogram — TensorRT has the final word when the engine is + built on the drone. + + Args: + info: The introspected model. + + Returns: + A DlaAssessment with the verdict and a one-line reason. + """ + histogram = info.op_histogram + unfriendly = sorted(op for op in histogram if op in _DLA_UNFRIENDLY_OPS) + matmul_count = histogram.get("MatMul", 0) + histogram.get("Gemm", 0) + conv_count = sum(count for op, count in histogram.items() if op in _DLA_FRIENDLY_OPS) + + if unfriendly: + return DlaAssessment( + good_fit=False, + reason=f"contains {', '.join(unfriendly)} layers that fall back to the GPU", + ) + if matmul_count > max(4, histogram.get("Conv", 0)): + return DlaAssessment( + good_fit=False, + reason=f"MatMul-heavy graph ({matmul_count} MatMul/Gemm nodes) — likely a " + "transformer, which the DLA cannot accelerate", + ) + if histogram.get("Conv", 0) == 0: + return DlaAssessment( + good_fit=False, reason="no convolution layers found — not a CNN-style model" + ) + return DlaAssessment( + good_fit=True, + reason=f"convolution-dominant graph ({histogram.get('Conv', 0)} Conv, " + f"{conv_count} DLA-native nodes) with no GPU-fallback layers", + ) diff --git a/blueye/sdk/cli/introspect.py b/blueye/sdk/cli/introspect.py new file mode 100644 index 00000000..13e4450b --- /dev/null +++ b/blueye/sdk/cli/introspect.py @@ -0,0 +1,183 @@ +"""ONNX model introspection for the `blueye bundle-model` CLI. + +This is the only CLI module that imports the `onnx` package. It is imported lazily by +the subcommand, after the dependency gate in `main` has verified the `[cli]` extra is +installed. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path + +import onnx + +logger = logging.getLogger(__name__) + +#: onnx.TensorProto element types, mirrored so downstream modules need no onnx import. +FLOAT32 = int(onnx.TensorProto.FLOAT) +FLOAT16 = int(onnx.TensorProto.FLOAT16) + +_ELEMENT_TYPE_NAMES = { + int(value): name for name, value in onnx.TensorProto.DataType.items() # type: ignore[attr-defined] +} + + +class IntrospectionError(Exception): + """The file could not be read or is not a valid ONNX model.""" + + +@dataclass(frozen=True) +class TensorSpec: + """Shape and type of one graph input or output. + + Attributes: + name: Tensor name in the graph. + dtype: onnx.TensorProto element type as a plain int (see FLOAT32/FLOAT16). + dims: One entry per dimension: a fixed int, a symbolic name (str, e.g. "batch"), + or None when the dimension is completely unknown. + """ + + name: str + dtype: int + dims: tuple[int | str | None, ...] + + @property + def dtype_name(self) -> str: + """Human-readable element type name (e.g. "FLOAT", "FLOAT16").""" + return _ELEMENT_TYPE_NAMES.get(self.dtype, f"type#{self.dtype}") + + @property + def shape_str(self) -> str: + """Shape rendered like ``[1, 3, 640, 640]`` with ``?`` for unknown dims.""" + rendered = ", ".join("?" if d is None else str(d) for d in self.dims) + return f"[{rendered}]" + + +@dataclass(frozen=True) +class ModelInfo: + """Everything the bundler needs to know about an ONNX model. + + Attributes: + path: Path to the .onnx file. + inputs: Graph inputs (initializers excluded). + outputs: Graph outputs. + metadata: The model's metadata_props as a plain dict (Ultralytics exports embed + "names", "imgsz", "task", "stride", ... here). + external_data_files: Unique file names referenced by tensors stored as external + data. These files must live next to the .onnx and travel with it. + op_histogram: Node op_type -> count over the whole graph (used for the DLA + fitness assessment). + """ + + path: Path + inputs: tuple[TensorSpec, ...] = () + outputs: tuple[TensorSpec, ...] = () + metadata: dict[str, str] = field(default_factory=dict) + external_data_files: tuple[str, ...] = () + op_histogram: dict[str, int] = field(default_factory=dict) + + +def _tensor_spec(value_info: onnx.ValueInfoProto) -> TensorSpec: + """Convert a graph input/output ValueInfoProto into a TensorSpec.""" + tensor_type = value_info.type.tensor_type + dims: list[int | str | None] = [] + for dim in tensor_type.shape.dim: + if dim.HasField("dim_value"): + dims.append(int(dim.dim_value)) + elif dim.HasField("dim_param") and dim.dim_param: + dims.append(dim.dim_param) + else: + dims.append(None) + return TensorSpec(name=value_info.name, dtype=int(tensor_type.elem_type), dims=tuple(dims)) + + +def _iter_tensors(graph: onnx.GraphProto): + """Yield every TensorProto in the graph: initializers and tensor-valued node + attributes, recursing into subgraphs (If/Loop bodies).""" + yield from graph.initializer + for node in graph.node: + for attribute in node.attribute: + if attribute.HasField("t"): + yield attribute.t + yield from attribute.tensors + if attribute.HasField("g"): + yield from _iter_tensors(attribute.g) + for subgraph in attribute.graphs: + yield from _iter_tensors(subgraph) + + +def _external_data_files(model: onnx.ModelProto) -> tuple[str, ...]: + """Collect the unique external-data file locations referenced by the model.""" + locations: list[str] = [] + for tensor in _iter_tensors(model.graph): + if tensor.data_location == onnx.TensorProto.EXTERNAL: + for entry in tensor.external_data: + if entry.key == "location" and entry.value and entry.value not in locations: + locations.append(entry.value) + return tuple(locations) + + +def load_model_info(path: Path) -> ModelInfo: + """Load an ONNX file and extract the information the bundler needs. + + External tensor data is not loaded into memory (the file may be gigabytes); only + the referenced file names are recorded. + + Args: + path: Path to the .onnx file. + + Returns: + The extracted ModelInfo. + + Raises: + IntrospectionError: If the file does not exist or is not a parseable ONNX model. + """ + if not path.is_file(): + raise IntrospectionError(f"No such file: {path}") + try: + model = onnx.load(str(path), load_external_data=False) + except Exception as error: # onnx raises DecodeError and various ValueErrors. + raise IntrospectionError(f"Not a valid ONNX model: {path} ({error})") from error + + initializer_names = {initializer.name for initializer in model.graph.initializer} + inputs = tuple( + _tensor_spec(value_info) + for value_info in model.graph.input + if value_info.name not in initializer_names + ) + outputs = tuple(_tensor_spec(value_info) for value_info in model.graph.output) + + metadata = {prop.key: prop.value for prop in model.metadata_props} + + op_histogram: dict[str, int] = {} + for node in model.graph.node: + op_histogram[node.op_type] = op_histogram.get(node.op_type, 0) + 1 + + return ModelInfo( + path=path, + inputs=inputs, + outputs=outputs, + metadata=metadata, + external_data_files=_external_data_files(model), + op_histogram=op_histogram, + ) + + +def check_model(path: Path) -> str | None: + """Run the strict onnx checker on the model file. + + Args: + path: Path to the .onnx file. The path form is used so external data is found. + + Returns: + None when the model passes, otherwise the checker's error message. Some + perfectly deployable models fail strict checking, so the caller decides + whether this is fatal. + """ + try: + onnx.checker.check_model(str(path)) + except Exception as error: + return str(error) + return None diff --git a/blueye/sdk/cli/main.py b/blueye/sdk/cli/main.py new file mode 100644 index 00000000..9092a400 --- /dev/null +++ b/blueye/sdk/cli/main.py @@ -0,0 +1,78 @@ +"""Entry point for the `blueye` command line interface. + +Only the standard library may be imported at module level: the umbrella command and +`--help` must work (and print install guidance) when the optional `[cli]` extra is not +installed. Subcommand implementations are imported lazily after the dependency gate. +""" + +from __future__ import annotations + +import argparse +import logging +import sys + +from . import deps + +logger = logging.getLogger(__name__) + + +class CliError(Exception): + """A user-facing CLI error: printed as a message, never as a traceback.""" + + +def _build_parser() -> argparse.ArgumentParser: + """Build the root `blueye` parser with all subcommands registered.""" + parser = argparse.ArgumentParser( + prog="blueye", + description="Command line tools for Blueye underwater drones.", + ) + subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") + + # Subcommand argument definitions live in their own modules, but only argument + # *definitions* — anything importing optional dependencies stays behind the gate in + # main(). bundle_model.add_parser only uses argparse. + from . import bundle_model + + bundle_model.add_parser(subparsers) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the `blueye` CLI. + + Args: + argv: Argument list to parse; defaults to ``sys.argv[1:]``. + + Returns: + The process exit code (0 success, 1 user-facing error, 2 missing dependencies, + 130 interrupted). + """ + logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s") + + parser = _build_parser() + args = parser.parse_args(argv) + + if args.command is None: + parser.print_help() + return 0 + + missing = deps.missing_cli_deps() + if missing: + deps.print_install_guidance(missing) + return 2 + + try: + if args.command == "bundle-model": + from . import bundle_model + + return bundle_model.run(args) + except CliError as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\nCancelled.", file=sys.stderr) + return 130 + + parser.print_help() + return 0 diff --git a/blueye/sdk/cli/meta.py b/blueye/sdk/cli/meta.py new file mode 100644 index 00000000..26e9fe1c --- /dev/null +++ b/blueye/sdk/cli/meta.py @@ -0,0 +1,247 @@ +"""model_meta.json construction and validation. + +Pure, stdlib-only functions implementing the BlueyeCV model package contract +(BlueyeCV/docs/model-meta-spec.md). The generated JSON mirrors the field order used by +the reference packages so diffs against hand-written files stay readable. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +#: Formats whose decoders require input_width/input_height in the metadata. +FORMATS_REQUIRING_INPUT_SIZE = ( + "yolov5_flat", + "yolov8_flat", + "yolov8_seg", + "yolo_e2e", + "yolo_e2e_seg", + "detr", +) + +#: Default 1/255 pixel scale, written with the same precision as the reference packages. +SCALE_1_OVER_255 = 0.00392156862 + +#: ImageNet normalization used by DETR and the SOT models. +IMAGENET_MEAN = [0.485, 0.456, 0.406] +IMAGENET_STD = [0.229, 0.224, 0.225] + +#: Per-format SOT defaults, taken from the reference ostrack/mixformerv2 packages. +SOT_DEFAULTS = { + "ostrack": { + "template_size": 128, + "search_size": 256, + "template_crop_factor": 2.0, + "search_crop_factor": 4.0, + "confidence_threshold": 0.5, + "hann_window": True, + "template_update_interval": 0, + "search_expand_rate": 1.2, + "search_max_factor": 8.0, + }, + "mixformerv2": { + "template_size": 112, + "search_size": 224, + "template_crop_factor": 2.0, + "search_crop_factor": 4.5, + "confidence_threshold": 0.5, + "hann_window": False, + "template_update_interval": 15, + "search_expand_rate": 1.2, + "search_max_factor": 8.0, + "max_lost_frames": 10, + "distance_gate_factor": 5.0, + }, +} + +#: Default tracking parameters per algorithm (from the spec). +TRACKING_DEFAULTS = { + "iou": {"max_age": 50, "min_hits": 1, "iou_threshold": 0.2}, + "byte_track": { + "max_age": 50, + "min_hits": 1, + "iou_threshold": 0.2, + "high_threshold": 0.5, + "low_threshold": 0.1, + }, +} + + +@dataclass +class MetaOptions: + """Everything needed to build a model_meta.json, after prompting/flags. + + Attributes mirror the spec's blocks; None/empty means "omit or use the default". + """ + + name: str = "" + output_format: str = "" + kind: str = "detection" # "detection" or "sot" + num_classes: int | None = None + labels: list[str] = field(default_factory=list) + input_width: int | None = None + input_height: int | None = None + grid_size: int | None = None + anchors: list[list[float]] = field(default_factory=list) + confidence_threshold: float = 0.3 + nms_threshold: float = 0.45 + one_indexed_classes: bool = False + # Preprocessing. + color_order: str = "rgb" + normalize_scale: float = SCALE_1_OVER_255 + normalize_mean: list[float] = field(default_factory=list) + normalize_std: list[float] = field(default_factory=list) + # SOT. + template_size: int | None = None + search_size: int | None = None + sot_overrides: dict[str, object] = field(default_factory=dict) + # Tracking (detection only). + tracking_algorithm: str = "none" + tracking_overrides: dict[str, object] = field(default_factory=dict) + # Runtime. + runtime_device: str | None = None + runtime_hz: float | None = None + runtime_enabled: bool = False + + +def default_preprocessing(output_format: str) -> tuple[float, list[float], list[float]]: + """Return (normalize_scale, normalize_mean, normalize_std) defaults per format. + + Matches every reference package: YOLOv2 and TF-SSD models take raw 0-255 pixels, + DETR and the SOT trackers use ImageNet normalization, everything else scales to + [0, 1]. + """ + if output_format in ("yolov2_grid", "ssd_multi"): + return 1.0, [], [] + if output_format in ("detr", "ostrack", "mixformerv2"): + return SCALE_1_OVER_255, list(IMAGENET_MEAN), list(IMAGENET_STD) + return SCALE_1_OVER_255, [], [] + + +def build_meta(options: MetaOptions) -> dict: + """Build the model_meta.json dict from the collected options. + + Args: + options: The fully-resolved options (inference results merged with prompt/flag + answers). + + Returns: + A JSON-serializable dict in the reference packages' field order. + """ + meta: dict = { + "format_version": 1, + "model_file": "model.onnx", + "name": options.name, + } + + preprocessing: dict = { + "color_order": options.color_order, + "normalize_scale": options.normalize_scale, + } + if options.normalize_mean: + preprocessing["normalize_mean"] = options.normalize_mean + if options.normalize_std: + preprocessing["normalize_std"] = options.normalize_std + meta["preprocessing"] = preprocessing + + if options.kind == "sot": + sot: dict = {"output_format": options.output_format} + defaults = dict(SOT_DEFAULTS.get(options.output_format, SOT_DEFAULTS["ostrack"])) + if options.template_size is not None: + defaults["template_size"] = options.template_size + if options.search_size is not None: + defaults["search_size"] = options.search_size + defaults.update(options.sot_overrides) + sot.update(defaults) + meta["sot"] = sot + else: + detection: dict = {"output_format": options.output_format} + if options.output_format == "yolov2_grid": + detection["anchors"] = options.anchors + detection["grid_size"] = options.grid_size + detection["num_classes"] = options.num_classes + if options.input_width is not None and options.input_height is not None: + detection["input_width"] = options.input_width + detection["input_height"] = options.input_height + detection["confidence_threshold"] = options.confidence_threshold + detection["nms_threshold"] = options.nms_threshold + if options.one_indexed_classes: + detection["one_indexed_classes"] = True + meta["detection"] = detection + + if options.tracking_algorithm not in ("", "none"): + tracking: dict = {"algorithm": options.tracking_algorithm} + tracking.update(TRACKING_DEFAULTS.get(options.tracking_algorithm, {})) + tracking.update(options.tracking_overrides) + meta["tracking"] = tracking + + if options.runtime_device is not None or options.runtime_hz is not None: + runtime: dict = {"enabled": options.runtime_enabled} + if options.runtime_device is not None: + runtime["device"] = options.runtime_device + if options.runtime_hz is not None: + runtime["hz"] = options.runtime_hz + meta["runtime"] = runtime + + meta["labels"] = options.labels if options.labels else ["tracked"] + return meta + + +def validate_meta(meta: dict) -> list[str]: + """Check a model_meta dict against the BlueyeCV parser's validation rules. + + Args: + meta: The dict produced by :func:`build_meta` (or hand-assembled). + + Returns: + A list of human-readable problems; empty when the metadata is valid. + """ + errors: list[str] = [] + if meta.get("format_version") != 1: + errors.append("format_version must be 1") + if not meta.get("model_file"): + errors.append("model_file must be non-empty") + + detection = meta.get("detection") + sot = meta.get("sot") + if detection is None and sot is None: + errors.append("at least one of 'detection' or 'sot' must be present") + + if detection is not None: + output_format = detection.get("output_format", "") + if not output_format: + errors.append("detection.output_format must be non-empty") + num_classes = detection.get("num_classes", 0) + if not isinstance(num_classes, int) or num_classes <= 0: + errors.append("detection.num_classes must be a positive integer") + labels = meta.get("labels", []) + if isinstance(num_classes, int) and num_classes > 0 and len(labels) != num_classes: + errors.append( + f"labels has {len(labels)} entries but detection.num_classes is {num_classes}" + ) + if output_format == "yolov2_grid": + anchors = detection.get("anchors", []) + if not anchors: + errors.append("yolov2_grid requires a non-empty detection.anchors list") + elif any(len(pair) != 2 for pair in anchors): + errors.append("detection.anchors entries must be [width, height] pairs") + if not detection.get("grid_size"): + errors.append("yolov2_grid requires detection.grid_size > 0") + if output_format in FORMATS_REQUIRING_INPUT_SIZE: + if not detection.get("input_width") or not detection.get("input_height"): + errors.append( + f"{output_format} requires detection.input_width and " "detection.input_height" + ) + + if sot is not None: + if not sot.get("output_format"): + errors.append("sot.output_format must be non-empty") + if not isinstance(sot.get("template_size"), int) or sot.get("template_size", 0) <= 0: + errors.append("sot.template_size must be > 0") + if not isinstance(sot.get("search_size"), int) or sot.get("search_size", 0) <= 0: + errors.append("sot.search_size must be > 0") + + return errors diff --git a/blueye/sdk/cli/prompts.py b/blueye/sdk/cli/prompts.py new file mode 100644 index 00000000..265ffbfb --- /dev/null +++ b/blueye/sdk/cli/prompts.py @@ -0,0 +1,98 @@ +"""Interactive prompting seam for the `blueye bundle-model` CLI. + +All user questions go through the :class:`Prompter` protocol so the orchestration can +be tested with a fake, and so non-interactive runs (``--yes`` or no TTY) resolve every +question to its default — or fail with a message naming the flag to pass. +""" + +from __future__ import annotations + +import logging +from typing import Protocol, Sequence + +import questionary + +from .main import CliError + +logger = logging.getLogger(__name__) + + +class PromptAborted(Exception): + """The user cancelled a prompt (Ctrl+C / EOF).""" + + +class Prompter(Protocol): + """The questions the bundler can ask. Implementations decide how.""" + + def select( + self, question: str, choices: Sequence[str], default: str | None, flag: str + ) -> str: ... + + def text(self, question: str, default: str, flag: str) -> str: ... + + def confirm(self, question: str, default: bool, flag: str) -> bool: ... + + def path(self, question: str, default: str | None, flag: str) -> str: ... + + +def _require(answer: object) -> object: + """Translate questionary's None (Ctrl+C) into PromptAborted.""" + if answer is None: + raise PromptAborted() + return answer + + +class QuestionaryPrompter: + """Interactive prompts with arrow-key selection and path autocompletion.""" + + def select(self, question: str, choices: Sequence[str], default: str | None, flag: str) -> str: + default_choice = default if default in choices else None + return str( + _require( + questionary.select( + question, + choices=list(choices), + default=default_choice, + use_search_filter=True, + use_jk_keys=False, + ).ask() + ) + ) + + def text(self, question: str, default: str, flag: str) -> str: + return str(_require(questionary.text(question, default=default).ask())) + + def confirm(self, question: str, default: bool, flag: str) -> bool: + return bool(_require(questionary.confirm(question, default=default).ask())) + + def path(self, question: str, default: str | None, flag: str) -> str: + return str(_require(questionary.path(question, default=default or "").ask())) + + +class NonInteractivePrompter: + """Prompt resolution for ``--yes`` runs and non-TTY environments. + + Every question resolves to its default. A question without a usable default is a + hard error that names the command line flag which would have answered it. + """ + + def select(self, question: str, choices: Sequence[str], default: str | None, flag: str) -> str: + if default is None: + raise CliError( + f"Cannot answer '{question}' non-interactively — pass {flag} " + f"(one of: {', '.join(choices)})." + ) + return default + + def text(self, question: str, default: str, flag: str) -> str: + if not default: + raise CliError(f"Cannot answer '{question}' non-interactively — pass {flag}.") + return default + + def confirm(self, question: str, default: bool, flag: str) -> bool: + return default + + def path(self, question: str, default: str | None, flag: str) -> str: + if not default: + raise CliError(f"Cannot answer '{question}' non-interactively — pass {flag}.") + return default diff --git a/blueye/sdk/cli/ui.py b/blueye/sdk/cli/ui.py new file mode 100644 index 00000000..2c6cecea --- /dev/null +++ b/blueye/sdk/cli/ui.py @@ -0,0 +1,100 @@ +"""Rich-based terminal UI helpers for the `blueye` CLI.""" + +from __future__ import annotations + +import logging + +from rich.console import Console +from rich.json import JSON +from rich.panel import Panel +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + SpinnerColumn, + TextColumn, + TransferSpeedColumn, +) +from rich.table import Table + +logger = logging.getLogger(__name__) + + +def make_console(quiet: bool = False) -> Console: + """Create the console all CLI output goes through. + + Args: + quiet: Suppress everything except errors and the final result line. + """ + return Console(quiet=quiet, highlight=False) + + +def model_summary_panel(info) -> Panel: + """Render the introspected model as a summary panel. + + Args: + info: A :class:`~blueye.sdk.cli.introspect.ModelInfo`. + """ + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + table.add_column("Tensor") + table.add_column("Type") + table.add_column("Shape") + for spec in info.inputs: + table.add_row(f"in {spec.name}", spec.dtype_name, spec.shape_str) + for spec in info.outputs: + table.add_row(f"out {spec.name}", spec.dtype_name, spec.shape_str) + if info.external_data_files: + table.add_row("data", "", ", ".join(info.external_data_files)) + for key in ("description", "task", "imgsz", "stride", "date"): + if key in info.metadata: + table.add_row(f"meta {key}", "", info.metadata[key]) + if "names" in info.metadata: + names = info.metadata["names"] + preview = names if len(names) <= 60 else names[:57] + "..." + table.add_row("meta names", "", preview) + return Panel(table, title=f"[bold]{info.path.name}[/bold]", border_style="cyan") + + +def inference_panel(config) -> Panel: + """Render the inference result and its reasoning. + + Args: + config: A :class:`~blueye.sdk.cli.heuristics.InferredConfig`. + """ + lines = [] + if config.output_format: + certainty = "" if config.confidence == "high" else " [yellow](unconfirmed)[/yellow]" + lines.append(f"[bold]Format:[/bold] {config.output_format}{certainty}") + else: + lines.append("[bold]Format:[/bold] [yellow]could not be inferred[/yellow]") + if config.num_classes is not None: + lines.append(f"[bold]Classes:[/bold] {config.num_classes}") + if config.input_width and config.input_height: + lines.append(f"[bold]Input:[/bold] {config.input_width}x{config.input_height}") + if config.kind == "sot": + lines.append( + f"[bold]Template/search:[/bold] {config.template_size}px / {config.search_size}px" + ) + for note in config.notes: + style = "yellow" if note.startswith("warning") else "dim" + lines.append(f"[{style}]- {note}[/{style}]") + return Panel("\n".join(lines), title="[bold]Inferred configuration[/bold]", border_style="cyan") + + +def meta_preview(meta: dict) -> JSON: + """Render the generated model_meta.json with syntax highlighting.""" + import json + + return JSON(json.dumps(meta, indent=2, ensure_ascii=False)) + + +def make_progress(console: Console) -> Progress: + """Create the byte-level progress bar used while writing the bundle.""" + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=console, + ) diff --git a/docs/bundling-cv-models.md b/docs/bundling-cv-models.md new file mode 100644 index 00000000..743c53ec --- /dev/null +++ b/docs/bundling-cv-models.md @@ -0,0 +1,97 @@ +# Bundling CV models + +Blueye drones with an onboard GPU can run your own computer vision models: object +detection, instance segmentation, and single-object tracking. The drone's vision +pipeline consumes **model packages** — a zip containing an ONNX model and a +`model_meta.json` file that describes how to preprocess frames and decode the model's +outputs. + +The `blueye bundle-model` command turns an exported ONNX file into such a package. It + +- validates that the model is of a supported type, +- auto-generates `model_meta.json` from the ONNX graph and any embedded metadata + (Ultralytics exports carry their class names and input size along), +- interactively asks about anything that cannot be inferred, and +- writes a deployable zip. + +## Installation + +The CLI needs a few extra packages, installed with the SDK's `cli` extra: + +```shell +pip install "blueye.sdk[cli]" +``` + +or with [uv](https://docs.astral.sh/uv/): + +```shell +uv pip install "blueye.sdk[cli]" +``` + +(Keep the quotes — most shells treat square brackets specially.) If the extra is +missing, `blueye bundle-model` will detect it and print the install command for your +platform instead of failing. + +## Interactive use + +Point the command at your ONNX file and answer the prompts: + +```shell +blueye bundle-model path/to/model.onnx +``` + +The CLI inspects the model, shows what it inferred (output format, class count, input +size, labels), and walks through the remaining choices with interactive prompts — model +name, tracking algorithm, and the runtime configuration for the drone: + +- **Execution device** — the CLI analyzes the network and recommends the Jetson DLA + cores (`tensorrt-dla0`/`tensorrt-dla1`) for convolution-style models, which frees the + GPU for other work. Models with layers the DLA cannot run (NMS-in-graph, + transformers) get `tensorrt` recommended instead. You can always pick any device. +- **Inference rate** — maximum rate in Hz, defaulting to unlimited. +- **Autolaunch** — whether the drone should start this model automatically. + +The result is a zip with `model.onnx` and `model_meta.json` at its root. + +## Scripted use + +Every prompt can be answered with a flag, and `--yes` accepts all inferred defaults: + +```shell +blueye bundle-model yolov8n.onnx --yes \ + --name "YOLOv8n (COCO)" \ + --tracking byte_track \ + --runtime-device tensorrt-dla0 --runtime-hz 10 --runtime-enabled \ + --output yolov8n_package.zip +``` + +Use `--dry-run` to print the generated `model_meta.json` without writing anything, and +`--labels labels.txt` (one class name per line) when the model does not embed its class +names. + +## Supported model types + +| Output format | Model family | +| -------------- | ----------------------------------------------- | +| `yolov2_grid` | YOLOv2 / TinyYOLOv2 (grid + anchors) | +| `yolov5_flat` | YOLOv5 ONNX export | +| `yolov8_flat` | YOLOv8 / YOLO11 ONNX export | +| `yolov8_seg` | YOLOv8/v11 segmentation | +| `yolo_e2e` | End-to-end YOLO with NMS in the model (YOLO26) | +| `yolo_e2e_seg` | End-to-end YOLO segmentation | +| `ssd_multi` | SSD (multi-output, e.g. TensorFlow exports) | +| `detr` | DETR transformer detectors | +| `ostrack` | OSTrack single-object tracker | +| `mixformerv2` | MixFormerV2 single-object tracker | + +The model must take float32 image input; models with a clearly unsupported structure +(image classifiers, float16 inputs, non-image inputs) are rejected with an explanation. + +## Deploying + +Unzip the package into a directory on the drone (for example under +`/videos/cv-models/`) and it can be launched by the onboard vision pipeline: + +```shell +unzip yolov8n_package.zip -d /videos/cv-models/yolov8n_package +``` diff --git a/docs/reference/blueye/sdk/cli.md b/docs/reference/blueye/sdk/cli.md new file mode 100644 index 00000000..9c72230f --- /dev/null +++ b/docs/reference/blueye/sdk/cli.md @@ -0,0 +1,7 @@ +::: blueye.sdk.cli.meta + +::: blueye.sdk.cli.heuristics + +::: blueye.sdk.cli.introspect + +::: blueye.sdk.cli.bundle diff --git a/mkdocs.yml b/mkdocs.yml index e5a60641..1f7b1e91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,12 +108,14 @@ nav: - "Visualize live sensor data": "foxglove-bridge.md" - "Forwarding positioning to NMEA": "nmea-publisher.md" - "Mission Planning": "mission-planning.md" + - "Bundling CV models": "bundling-cv-models.md" - "Odometer forwarding": odometer-to-831l.md - "Updating from v1 to v2": "migrating-to-v2.md" - "HTTP API": "http-api.md" - Reference: - blueye.sdk.battery: "reference/blueye/sdk/battery.md" - blueye.sdk.camera: "reference/blueye/sdk/camera.md" + - blueye.sdk.cli: "reference/blueye/sdk/cli.md" - blueye.sdk.connection: "reference/blueye/sdk/connection.md" - blueye.sdk.constants: "reference/blueye/sdk/constants.md" - blueye.sdk.drone: "reference/blueye/sdk/drone.md" diff --git a/pyproject.toml b/pyproject.toml index 4feea225..6f839fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,17 @@ dependencies = [ "proto-plus>=1.22.2,<2", ] +[project.scripts] +blueye = "blueye.sdk.cli:main" + [project.optional-dependencies] +# Dependencies for the `blueye` command line interface (e.g. `blueye bundle-model`). Install +# with: pip install "blueye.sdk[cli]" +cli = [ + "onnx>=1.16,<2", + "rich>=13,<15", + "questionary>=2.0,<3", +] # These are dependencies that are not necessary for the core functionality of the SDK, but are # necessary for some of the examples. examples = [ @@ -42,6 +52,10 @@ Repository = "https://github.com/blueye-robotics/blueye.sdk" [dependency-groups] dev = [ + # The CLI extra, repeated here so the test suite can exercise the CLI modules. + "onnx>=1.16,<2", + "rich>=13,<15", + "questionary>=2.0,<3", "pytest~=8.3", "pytest-mock~=3.11", "mike~=2.1", diff --git a/tests/test_cli_bundle.py b/tests/test_cli_bundle.py new file mode 100644 index 00000000..40ad4141 --- /dev/null +++ b/tests/test_cli_bundle.py @@ -0,0 +1,77 @@ +import json +import zipfile + +import pytest + +from blueye.sdk.cli.bundle import BundleError, bundle_size, write_bundle + +META = {"format_version": 1, "model_file": "model.onnx", "labels": ["x"]} + + +@pytest.fixture +def onnx_file(tmp_path): + path = tmp_path / "exported_yolo.onnx" + path.write_bytes(b"onnx-bytes" * 1000) + return path + + +def test_zip_contains_exactly_root_level_files(onnx_file, tmp_path): + output = tmp_path / "out.zip" + write_bundle(META, onnx_file, [], output) + + with zipfile.ZipFile(output) as archive: + assert sorted(archive.namelist()) == ["model.onnx", "model_meta.json"] + assert archive.read("model.onnx") == onnx_file.read_bytes() + assert json.loads(archive.read("model_meta.json")) == META + + +def test_external_data_included_under_exact_name(onnx_file, tmp_path): + (tmp_path / "model.onnx_data").write_bytes(b"weights" * 100) + output = tmp_path / "out.zip" + write_bundle(META, onnx_file, ["model.onnx_data"], output) + + with zipfile.ZipFile(output) as archive: + assert "model.onnx_data" in archive.namelist() + + +def test_missing_external_data_raises(onnx_file, tmp_path): + with pytest.raises(BundleError, match="not found"): + write_bundle(META, onnx_file, ["model.onnx_data"], tmp_path / "out.zip") + + +def test_external_data_with_path_separator_raises(onnx_file, tmp_path): + with pytest.raises(BundleError, match="path separator"): + write_bundle(META, onnx_file, ["weights/model.onnx_data"], tmp_path / "out.zip") + + +def test_external_data_name_collision_raises(onnx_file, tmp_path): + with pytest.raises(BundleError, match="reserved"): + write_bundle(META, onnx_file, ["model_meta.json"], tmp_path / "out.zip") + + +def test_no_partial_file_left_behind(onnx_file, tmp_path): + output = tmp_path / "out.zip" + write_bundle(META, onnx_file, [], output) + assert output.exists() + assert not (tmp_path / "out.zip.part").exists() + + +def test_partial_cleaned_up_on_failure(onnx_file, tmp_path): + output = tmp_path / "out.zip" + with pytest.raises(BundleError): + write_bundle(META, onnx_file, ["missing_file"], output) + assert not output.exists() + assert not (tmp_path / "out.zip.part").exists() + + +def test_progress_reports_all_bytes(onnx_file, tmp_path): + (tmp_path / "model.onnx_data").write_bytes(b"w" * 4096) + seen = [] + write_bundle( + META, + onnx_file, + ["model.onnx_data"], + tmp_path / "out.zip", + progress=seen.append, + ) + assert sum(seen) == bundle_size(onnx_file, ["model.onnx_data"]) diff --git a/tests/test_cli_heuristics.py b/tests/test_cli_heuristics.py new file mode 100644 index 00000000..9192d553 --- /dev/null +++ b/tests/test_cli_heuristics.py @@ -0,0 +1,262 @@ +from pathlib import Path + +import pytest + +from blueye.sdk.cli.heuristics import ( + UnsupportedModelError, + assess_dla_fitness, + infer, + parse_ultralytics_metadata, +) +from blueye.sdk.cli.introspect import FLOAT16, FLOAT32, ModelInfo, TensorSpec + + +def make_info(inputs, outputs, metadata=None, op_histogram=None): + return ModelInfo( + path=Path("model.onnx"), + inputs=tuple(inputs), + outputs=tuple(outputs), + metadata=metadata or {}, + op_histogram=op_histogram or {"Conv": 10}, + ) + + +def image_input(name="images", height=640, width=640, channels=3, dtype=FLOAT32): + return TensorSpec(name=name, dtype=dtype, dims=(1, channels, height, width)) + + +def output(name, *dims): + return TensorSpec(name=name, dtype=FLOAT32, dims=tuple(dims)) + + +class TestDetectionFormats: + def test_yolov8_flat(self): + config = infer(make_info([image_input()], [output("output0", 1, 84, 8400)])) + assert config.output_format == "yolov8_flat" + assert config.kind == "detection" + assert config.confidence == "high" + assert config.num_classes == 80 + assert config.input_width == 640 + assert config.input_height == 640 + + def test_yolov5_flat(self): + config = infer(make_info([image_input()], [output("output", 1, 25200, 85)])) + assert config.output_format == "yolov5_flat" + assert config.num_classes == 80 + + def test_yolo_e2e(self): + config = infer(make_info([image_input()], [output("output0", 1, 300, 6)])) + assert config.output_format == "yolo_e2e" + assert config.confidence == "high" + + def test_yolov8_seg(self): + config = infer( + make_info( + [image_input()], + [output("output0", 1, 116, 8400), output("output1", 1, 32, 160, 160)], + ) + ) + assert config.output_format == "yolov8_seg" + assert config.num_classes == 80 + + def test_yolo_e2e_seg(self): + config = infer( + make_info( + [image_input()], + [output("output0", 1, 300, 38), output("output1", 1, 32, 160, 160)], + ) + ) + assert config.output_format == "yolo_e2e_seg" + + def test_detr(self): + config = infer( + make_info( + [image_input(height=800, width=800)], + [output("logits", 1, 100, 92), output("boxes", 1, 100, 4)], + ) + ) + assert config.output_format == "detr" + assert config.num_classes == 91 # 92 logits including the no-object class. + + def test_ssd_multi(self): + config = infer( + make_info( + [image_input()], + [ + output("boxes", 1, 100, 4), + output("classes", 1, 100), + output("scores", 1, 100), + output("count", 1), + ], + ) + ) + assert config.output_format == "ssd_multi" + assert config.confidence == "low" + + def test_yolov2_grid(self): + # TinyYOLOv2's real output shape: 5 anchors * (5 + 20 classes) = 125. + config = infer( + make_info([image_input(height=416, width=416)], [output("grid", 1, 125, 13, 13)]) + ) + assert config.output_format == "yolov2_grid" + assert config.grid_size == 13 + assert config.confidence == "low" # anchors are never inferable + + def test_ambiguous_is_unknown(self): + config = infer(make_info([image_input()], [output("weird", 1, 2, 3, 4, 5)])) + assert config.kind == "unknown" + assert config.output_format is None + + +class TestSotFormats: + def test_two_inputs_is_ostrack(self): + config = infer( + make_info( + [ + image_input("template", 128, 128), + image_input("search", 256, 256), + ], + [ + output("score_map", 1, 1, 16, 16), + output("size_map", 1, 2, 16, 16), + output("offset_map", 1, 2, 16, 16), + ], + ) + ) + assert config.kind == "sot" + assert config.output_format == "ostrack" + assert config.template_size == 128 + assert config.search_size == 256 + assert config.labels == ["tracked"] + + def test_three_inputs_is_mixformerv2(self): + config = infer( + make_info( + [ + image_input("template", 112, 112), + image_input("online_template", 112, 112), + image_input("search", 224, 224), + ], + [output("boxes", 1, 4), output("scores", 1, 1)], + ) + ) + assert config.output_format == "mixformerv2" + assert config.template_size == 112 + assert config.search_size == 224 + + +class TestUnsupportedModels: + def test_classifier_2d_rejected(self): + with pytest.raises(UnsupportedModelError, match="classifier"): + infer(make_info([image_input(height=224, width=224)], [output("probs", 1, 1000)])) + + def test_classifier_4d_squeezed_rejected(self): + with pytest.raises(UnsupportedModelError, match="classifier"): + infer(make_info([image_input(height=224, width=224)], [output("probs", 1, 1000, 1, 1)])) + + def test_no_image_input_rejected(self): + nlp_input = TensorSpec(name="input_ids", dtype=7, dims=(1, 128)) + with pytest.raises(UnsupportedModelError, match="no image-like input"): + infer(make_info([nlp_input], [output("logits", 1, 128, 768)])) + + def test_fp16_input_rejected(self): + with pytest.raises(UnsupportedModelError, match="float16"): + infer(make_info([image_input(dtype=FLOAT16)], [output("output0", 1, 84, 8400)])) + + def test_too_many_image_inputs_rejected(self): + inputs = [image_input(f"in{i}", 128, 128) for i in range(4)] + with pytest.raises(UnsupportedModelError, match="4 image inputs"): + infer(make_info(inputs, [output("out", 1, 4)])) + + +class TestMetadata: + def test_names_dict_parsed(self): + parsed = parse_ultralytics_metadata({"names": "{0: 'person', 1: 'bicycle'}"}) + assert parsed["labels"] == ["person", "bicycle"] + + def test_imgsz_parsed(self): + parsed = parse_ultralytics_metadata({"imgsz": "[640, 480]"}) + assert parsed["imgsz"] == (640, 480) + + def test_garbage_metadata_ignored(self): + parsed = parse_ultralytics_metadata({"names": "not a dict {", "imgsz": "nope"}) + assert "labels" not in parsed + assert "imgsz" not in parsed + + def test_labels_flow_into_config(self): + names = "{" + ", ".join(f"{i}: 'class{i}'" for i in range(80)) + "}" + config = infer( + make_info([image_input()], [output("output0", 1, 84, 8400)], metadata={"names": names}) + ) + assert config.labels is not None + assert len(config.labels) == 80 + + def test_label_count_mismatch_warns(self): + config = infer( + make_info( + [image_input()], + [output("output0", 1, 84, 8400)], + metadata={"names": "{0: 'only-one'}"}, + ) + ) + assert any("warning" in note for note in config.notes) + + +class TestDynamicDims: + def test_dynamic_batch_accepted(self): + spec = TensorSpec(name="images", dtype=FLOAT32, dims=("batch", 3, 640, 640)) + config = infer(make_info([spec], [output("output0", 1, 84, 8400)])) + assert config.output_format == "yolov8_flat" + + def test_dynamic_hw_leaves_size_unset(self): + spec = TensorSpec(name="images", dtype=FLOAT32, dims=(1, 3, "height", "width")) + config = infer(make_info([spec], [output("output0", 1, 84, 8400)])) + assert config.input_width is None + assert config.input_height is None + + def test_imgsz_metadata_fills_dynamic_size(self): + spec = TensorSpec(name="images", dtype=FLOAT32, dims=(1, 3, "height", "width")) + config = infer( + make_info([spec], [output("output0", 1, 84, 8400)], metadata={"imgsz": "[640, 640]"}) + ) + assert config.input_height == 640 + assert config.input_width == 640 + + +class TestDlaFitness: + def test_conv_heavy_model_is_good_fit(self): + info = make_info( + [image_input()], + [output("output0", 1, 84, 8400)], + op_histogram={"Conv": 60, "Relu": 58, "MaxPool": 4, "Concat": 10, "Add": 8}, + ) + assessment = assess_dla_fitness(info) + assert assessment.good_fit + assert "convolution" in assessment.reason + + def test_nms_in_graph_is_poor_fit(self): + info = make_info( + [image_input()], + [output("output0", 1, 300, 6)], + op_histogram={"Conv": 60, "NonMaxSuppression": 1, "TopK": 1}, + ) + assessment = assess_dla_fitness(info) + assert not assessment.good_fit + assert "NonMaxSuppression" in assessment.reason + + def test_transformer_is_poor_fit(self): + info = make_info( + [image_input()], + [output("logits", 1, 100, 92), output("boxes", 1, 100, 4)], + op_histogram={"MatMul": 120, "Softmax": 24, "Conv": 4}, + ) + assessment = assess_dla_fitness(info) + assert not assessment.good_fit + assert "transformer" in assessment.reason + + def test_no_conv_is_poor_fit(self): + info = make_info( + [image_input()], [output("out", 1, 300, 6)], op_histogram={"Gemm": 3, "Relu": 2} + ) + assessment = assess_dla_fitness(info) + assert not assessment.good_fit diff --git a/tests/test_cli_introspect.py b/tests/test_cli_introspect.py new file mode 100644 index 00000000..37ec0200 --- /dev/null +++ b/tests/test_cli_introspect.py @@ -0,0 +1,134 @@ +from pathlib import Path + +import onnx +import onnx.helper +import pytest + +from blueye.sdk.cli.introspect import ( + FLOAT16, + FLOAT32, + IntrospectionError, + check_model, + load_model_info, +) + + +def make_model( + tmp_path: Path, + inputs=(("images", onnx.TensorProto.FLOAT, (1, 3, 640, 640)),), + outputs=(("output0", onnx.TensorProto.FLOAT, (1, 84, 8400)),), + metadata: dict | None = None, + filename: str = "model.onnx", +) -> Path: + """Write a tiny structurally-valid ONNX file (Identity chain, no weights).""" + graph_inputs = [ + onnx.helper.make_tensor_value_info(name, dtype, list(dims)) for name, dtype, dims in inputs + ] + graph_outputs = [ + onnx.helper.make_tensor_value_info(name, dtype, list(dims)) for name, dtype, dims in outputs + ] + # One Identity node per output keeps the graph checkable without real compute. + nodes = [ + onnx.helper.make_node("Identity", [graph_inputs[0].name], [out.name]) + for out in graph_outputs + ] + graph = onnx.helper.make_graph(nodes, "test_graph", graph_inputs, graph_outputs) + model = onnx.helper.make_model(graph, producer_name="blueye-sdk-tests") + if metadata: + onnx.helper.set_model_props(model, metadata) + path = tmp_path / filename + onnx.save(model, str(path)) + return path + + +def test_shapes_and_dtypes_extracted(tmp_path): + path = make_model(tmp_path) + info = load_model_info(path) + assert [spec.name for spec in info.inputs] == ["images"] + assert info.inputs[0].dtype == FLOAT32 + assert info.inputs[0].dims == (1, 3, 640, 640) + assert info.outputs[0].dims == (1, 84, 8400) + assert info.op_histogram == {"Identity": 1} + + +def test_dim_param_becomes_str(tmp_path): + path = make_model( + tmp_path, inputs=(("images", onnx.TensorProto.FLOAT, ("batch", 3, 640, 640)),) + ) + info = load_model_info(path) + assert info.inputs[0].dims[0] == "batch" + + +def test_fp16_input_detected(tmp_path): + path = make_model(tmp_path, inputs=(("images", onnx.TensorProto.FLOAT16, (1, 3, 640, 640)),)) + info = load_model_info(path) + assert info.inputs[0].dtype == FLOAT16 + + +def test_metadata_props_round_trip(tmp_path): + path = make_model(tmp_path, metadata={"names": "{0: 'fish'}", "task": "detect"}) + info = load_model_info(path) + assert info.metadata["names"] == "{0: 'fish'}" + assert info.metadata["task"] == "detect" + + +def test_initializer_not_reported_as_input(tmp_path): + weight = onnx.helper.make_tensor("weight", onnx.TensorProto.FLOAT, (1,), [1.0]) + images = onnx.helper.make_tensor_value_info("images", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + out = onnx.helper.make_tensor_value_info("out", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + node = onnx.helper.make_node("Mul", ["images", "weight"], ["out"]) + graph = onnx.helper.make_graph([node], "g", [images], [out]) + # Also list the initializer as a graph input (legacy exporter style). + graph.input.append(onnx.helper.make_tensor_value_info("weight", onnx.TensorProto.FLOAT, [1])) + graph.initializer.append(weight) + path = tmp_path / "model.onnx" + onnx.save(onnx.helper.make_model(graph), str(path)) + + info = load_model_info(path) + assert [spec.name for spec in info.inputs if spec.name == "weight"] == [] + + +def test_external_data_locations_collected(tmp_path): + weight = onnx.helper.make_tensor("weight", onnx.TensorProto.FLOAT, (1,), [1.0]) + weight.data_location = onnx.TensorProto.EXTERNAL + del weight.float_data[:] + entry = weight.external_data.add() + entry.key = "location" + entry.value = "model.onnx_data" + images = onnx.helper.make_tensor_value_info("images", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + out = onnx.helper.make_tensor_value_info("out", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + node = onnx.helper.make_node("Mul", ["images", "weight"], ["out"]) + graph = onnx.helper.make_graph([node], "g", [images], [out], initializer=[weight]) + path = tmp_path / "model.onnx" + onnx.save(onnx.helper.make_model(graph), str(path)) + + info = load_model_info(path) + assert info.external_data_files == ("model.onnx_data",) + + +def test_missing_file_raises(tmp_path): + with pytest.raises(IntrospectionError, match="No such file"): + load_model_info(tmp_path / "nope.onnx") + + +def test_garbage_file_raises(tmp_path): + path = tmp_path / "junk.onnx" + path.write_bytes(b"this is not protobuf") + with pytest.raises(IntrospectionError, match="Not a valid ONNX model"): + load_model_info(path) + + +def test_check_model_passes_on_valid_model(tmp_path): + path = make_model(tmp_path) + assert check_model(path) is None + + +def test_check_model_reports_broken_model(tmp_path): + # An Identity node referencing a missing input fails the checker. + images = onnx.helper.make_tensor_value_info("images", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + out = onnx.helper.make_tensor_value_info("out", onnx.TensorProto.FLOAT, [1, 3, 8, 8]) + node = onnx.helper.make_node("Identity", ["does_not_exist"], ["out"]) + graph = onnx.helper.make_graph([node], "g", [images], [out]) + path = tmp_path / "model.onnx" + onnx.save(onnx.helper.make_model(graph), str(path)) + assert check_model(path) is not None diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 00000000..3fd4c470 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,225 @@ +import json +import zipfile + +import onnx +import onnx.helper +import pytest + +from blueye.sdk.cli import main as cli_main_module +from blueye.sdk.cli.main import CliError, main + + +class FakePrompter: + """Prompter that records questions and answers from a canned dict.""" + + def __init__(self, answers=None): + self.answers = answers or {} + self.questions = [] + + def _answer(self, question, default): + self.questions.append(question) + for fragment, answer in self.answers.items(): + if fragment in question: + return answer + return default + + def select(self, question, choices, default, flag): + answer = self._answer(question, default) + if answer is None: + raise CliError(f"no answer for: {question}") + return answer + + def text(self, question, default, flag): + return self._answer(question, default) + + def confirm(self, question, default, flag): + return self._answer(question, default) + + def path(self, question, default, flag): + answer = self._answer(question, default) + if answer is None: + raise CliError(f"no answer for: {question}") + return answer + + +@pytest.fixture +def fake_prompter(mocker): + prompter = FakePrompter() + mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=prompter) + mocker.patch("blueye.sdk.cli.prompts.NonInteractivePrompter", return_value=prompter) + return prompter + + +@pytest.fixture +def yolov8_model(tmp_path): + """A tiny model whose shapes and metadata mimic an Ultralytics YOLOv8 export.""" + images = onnx.helper.make_tensor_value_info("images", onnx.TensorProto.FLOAT, [1, 3, 640, 640]) + output0 = onnx.helper.make_tensor_value_info("output0", onnx.TensorProto.FLOAT, [1, 84, 8400]) + node = onnx.helper.make_node("Identity", ["images"], ["output0"]) + graph = onnx.helper.make_graph([node], "g", [images], [output0]) + model = onnx.helper.make_model(graph) + names = "{" + ", ".join(f"{i}: 'class{i}'" for i in range(80)) + "}" + onnx.helper.set_model_props(model, {"names": names, "imgsz": "[640, 640]", "task": "detect"}) + path = tmp_path / "yolov8n.onnx" + onnx.save(model, str(path)) + return path + + +def test_help_exits_zero(): + with pytest.raises(SystemExit) as excinfo: + main(["--help"]) + assert excinfo.value.code == 0 + + +def test_no_command_prints_help(capsys): + assert main([]) == 0 + assert "bundle-model" in capsys.readouterr().out + + +def test_missing_deps_prints_guidance(mocker, capsys): + mocker.patch("blueye.sdk.cli.deps.missing_cli_deps", return_value=["onnx"]) + exit_code = main(["bundle-model", "whatever.onnx"]) + assert exit_code == 2 + output = capsys.readouterr().out + assert "blueye.sdk[cli]" in output + assert "onnx" in output + + +def test_bundle_end_to_end(yolov8_model, fake_prompter, tmp_path): + output = tmp_path / "bundle.zip" + exit_code = main( + ["bundle-model", str(yolov8_model), "--yes", "--name", "Test YOLO", "-o", str(output)] + ) + assert exit_code == 0 + with zipfile.ZipFile(output) as archive: + assert sorted(archive.namelist()) == ["model.onnx", "model_meta.json"] + meta = json.loads(archive.read("model_meta.json")) + assert meta["name"] == "Test YOLO" + assert meta["detection"]["output_format"] == "yolov8_flat" + assert meta["detection"]["num_classes"] == 80 + assert meta["detection"]["input_width"] == 640 + assert len(meta["labels"]) == 80 + assert meta["runtime"]["enabled"] is False + + +def test_dry_run_writes_nothing(yolov8_model, fake_prompter, tmp_path, capsys): + output = tmp_path / "bundle.zip" + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "--dry-run", "-o", str(output)]) + assert exit_code == 0 + assert not output.exists() + assert "yolov8_flat" in capsys.readouterr().out + + +def test_existing_output_without_force_fails(yolov8_model, tmp_path, mocker): + from blueye.sdk.cli.prompts import NonInteractivePrompter + + output = tmp_path / "bundle.zip" + output.write_bytes(b"existing") + # Real NonInteractivePrompter: confirm() returns its default, which is False when + # --yes was not passed (non-TTY path). + mocker.patch("sys.stdin.isatty", return_value=False) + exit_code = main(["bundle-model", str(yolov8_model), "-o", str(output)]) + assert exit_code == 1 + assert output.read_bytes() == b"existing" + + +def test_force_overwrites(yolov8_model, fake_prompter, tmp_path): + output = tmp_path / "bundle.zip" + output.write_bytes(b"existing") + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "--force", "-o", str(output)]) + assert exit_code == 0 + assert zipfile.is_zipfile(output) + + +def test_unsupported_classifier_errors_cleanly(tmp_path, fake_prompter, capsys): + images = onnx.helper.make_tensor_value_info("images", onnx.TensorProto.FLOAT, [1, 3, 224, 224]) + probs = onnx.helper.make_tensor_value_info("probs", onnx.TensorProto.FLOAT, [1, 1000]) + node = onnx.helper.make_node("Identity", ["images"], ["probs"]) + graph = onnx.helper.make_graph([node], "g", [images], [probs]) + path = tmp_path / "classifier.onnx" + onnx.save(onnx.helper.make_model(graph), str(path)) + + exit_code = main(["bundle-model", str(path), "--yes"]) + assert exit_code == 1 + assert "classifier" in capsys.readouterr().out + + +def test_not_an_onnx_file_errors_cleanly(tmp_path, fake_prompter, capsys): + path = tmp_path / "junk.onnx" + path.write_bytes(b"garbage") + exit_code = main(["bundle-model", str(path), "--yes"]) + assert exit_code == 1 + assert "Not a valid ONNX model" in capsys.readouterr().out + + +def test_runtime_flags_flow_into_meta(yolov8_model, fake_prompter, tmp_path): + output = tmp_path / "bundle.zip" + exit_code = main( + [ + "bundle-model", + str(yolov8_model), + "--yes", + "-o", + str(output), + "--runtime-device", + "tensorrt-dla1", + "--runtime-hz", + "10", + "--runtime-enabled", + "--tracking", + "byte_track", + ] + ) + assert exit_code == 0 + with zipfile.ZipFile(output) as archive: + meta = json.loads(archive.read("model_meta.json")) + assert meta["runtime"] == {"enabled": True, "device": "tensorrt-dla1", "hz": 10.0} + assert meta["tracking"]["algorithm"] == "byte_track" + + +def test_runtime_hz_max_is_unlimited(yolov8_model, fake_prompter, tmp_path): + output = tmp_path / "bundle.zip" + main( + [ + "bundle-model", + str(yolov8_model), + "--yes", + "-o", + str(output), + "--runtime-device", + "cuda", + "--runtime-hz", + "max", + ] + ) + with zipfile.ZipFile(output) as archive: + meta = json.loads(archive.read("model_meta.json")) + assert "hz" not in meta["runtime"] + + +def test_labels_file_flag(yolov8_model, fake_prompter, tmp_path): + labels_file = tmp_path / "labels.txt" + labels_file.write_text("\n".join(f"label{i}" for i in range(80))) + output = tmp_path / "bundle.zip" + exit_code = main( + [ + "bundle-model", + str(yolov8_model), + "--yes", + "-o", + str(output), + "--labels", + str(labels_file), + ] + ) + assert exit_code == 0 + with zipfile.ZipFile(output) as archive: + meta = json.loads(archive.read("model_meta.json")) + assert meta["labels"][0] == "label0" + + +def test_cli_module_importable_without_optional_deps(mocker): + """The dependency gate must run before any optional import.""" + # Simulate the extra being missing; parsing + gate must still work. + mocker.patch("blueye.sdk.cli.deps.missing_cli_deps", return_value=["rich", "questionary"]) + assert main(["bundle-model", "x.onnx"]) == 2 diff --git a/tests/test_cli_meta.py b/tests/test_cli_meta.py new file mode 100644 index 00000000..560a40d4 --- /dev/null +++ b/tests/test_cli_meta.py @@ -0,0 +1,167 @@ +from blueye.sdk.cli.meta import ( + IMAGENET_MEAN, + IMAGENET_STD, + SCALE_1_OVER_255, + MetaOptions, + build_meta, + default_preprocessing, + validate_meta, +) + + +def detection_options(**overrides) -> MetaOptions: + options = MetaOptions( + name="Test model", + output_format="yolov8_flat", + kind="detection", + num_classes=2, + labels=["cat", "dog"], + input_width=640, + input_height=640, + ) + for key, value in overrides.items(): + setattr(options, key, value) + return options + + +class TestBuildMeta: + def test_yolov8_flat_matches_reference_package_shape(self): + meta = build_meta(detection_options(num_classes=80, labels=[f"c{i}" for i in range(80)])) + assert meta["format_version"] == 1 + assert meta["model_file"] == "model.onnx" + assert meta["preprocessing"] == { + "color_order": "rgb", + "normalize_scale": SCALE_1_OVER_255, + } + assert meta["detection"] == { + "output_format": "yolov8_flat", + "num_classes": 80, + "input_width": 640, + "input_height": 640, + "confidence_threshold": 0.3, + "nms_threshold": 0.45, + } + assert "tracking" not in meta + assert "runtime" not in meta + assert len(meta["labels"]) == 80 + + def test_yolov2_grid_includes_anchors_and_grid(self): + meta = build_meta( + detection_options( + output_format="yolov2_grid", + anchors=[[1.08, 1.19], [3.42, 4.41]], + grid_size=13, + ) + ) + assert meta["detection"]["anchors"] == [[1.08, 1.19], [3.42, 4.41]] + assert meta["detection"]["grid_size"] == 13 + + def test_byte_track_block_gets_spec_defaults(self): + meta = build_meta(detection_options(tracking_algorithm="byte_track")) + assert meta["tracking"] == { + "algorithm": "byte_track", + "max_age": 50, + "min_hits": 1, + "iou_threshold": 0.2, + "high_threshold": 0.5, + "low_threshold": 0.1, + } + + def test_runtime_block(self): + meta = build_meta( + detection_options(runtime_device="tensorrt-dla0", runtime_hz=10.0, runtime_enabled=True) + ) + assert meta["runtime"] == {"enabled": True, "device": "tensorrt-dla0", "hz": 10.0} + + def test_runtime_hz_omitted_for_unlimited(self): + meta = build_meta(detection_options(runtime_device="tensorrt", runtime_hz=None)) + assert "hz" not in meta["runtime"] + + def test_sot_gets_per_format_defaults(self): + options = MetaOptions( + name="OSTrack", + output_format="ostrack", + kind="sot", + template_size=128, + search_size=256, + ) + meta = build_meta(options) + assert meta["sot"]["output_format"] == "ostrack" + assert meta["sot"]["hann_window"] is True + assert meta["sot"]["search_crop_factor"] == 4.0 + assert meta["labels"] == ["tracked"] + assert "detection" not in meta + + options.output_format = "mixformerv2" + meta = build_meta(options) + assert meta["sot"]["hann_window"] is False + assert meta["sot"]["search_crop_factor"] == 4.5 + assert meta["sot"]["template_update_interval"] == 15 + + def test_one_indexed_classes_only_when_set(self): + assert "one_indexed_classes" not in build_meta(detection_options())["detection"] + meta = build_meta(detection_options(output_format="ssd_multi", one_indexed_classes=True)) + assert meta["detection"]["one_indexed_classes"] is True + + +class TestDefaultPreprocessing: + def test_raw_pixel_formats(self): + assert default_preprocessing("yolov2_grid") == (1.0, [], []) + assert default_preprocessing("ssd_multi") == (1.0, [], []) + + def test_imagenet_formats(self): + for output_format in ("detr", "ostrack", "mixformerv2"): + scale, mean, std = default_preprocessing(output_format) + assert scale == SCALE_1_OVER_255 + assert mean == IMAGENET_MEAN + assert std == IMAGENET_STD + + def test_default_scale_only(self): + assert default_preprocessing("yolov8_flat") == (SCALE_1_OVER_255, [], []) + + +class TestValidateMeta: + def test_valid_detection_meta_passes(self): + assert validate_meta(build_meta(detection_options())) == [] + + def test_valid_sot_meta_passes(self): + options = MetaOptions( + name="t", output_format="ostrack", kind="sot", template_size=128, search_size=256 + ) + assert validate_meta(build_meta(options)) == [] + + def test_bad_format_version(self): + meta = build_meta(detection_options()) + meta["format_version"] = 2 + assert any("format_version" in error for error in validate_meta(meta)) + + def test_empty_model_file(self): + meta = build_meta(detection_options()) + meta["model_file"] = "" + assert any("model_file" in error for error in validate_meta(meta)) + + def test_neither_detection_nor_sot(self): + meta = build_meta(detection_options()) + del meta["detection"] + assert any("detection" in error and "sot" in error for error in validate_meta(meta)) + + def test_label_count_mismatch(self): + meta = build_meta(detection_options()) + meta["labels"] = ["only-one"] + assert any("labels" in error for error in validate_meta(meta)) + + def test_yolov2_grid_requires_anchors(self): + meta = build_meta(detection_options(output_format="yolov2_grid", grid_size=13)) + assert any("anchors" in error for error in validate_meta(meta)) + + def test_input_size_required_formats(self): + options = detection_options(input_width=None, input_height=None) + meta = build_meta(options) + assert any("input_width" in error for error in validate_meta(meta)) + + def test_sot_sizes_must_be_positive(self): + options = MetaOptions( + name="t", output_format="ostrack", kind="sot", template_size=0, search_size=256 + ) + meta = build_meta(options) + assert any("template_size" in error for error in validate_meta(meta)) diff --git a/tests/test_cli_prompts.py b/tests/test_cli_prompts.py new file mode 100644 index 00000000..58be1ab7 --- /dev/null +++ b/tests/test_cli_prompts.py @@ -0,0 +1,65 @@ +import pytest + +from blueye.sdk.cli.main import CliError +from blueye.sdk.cli.prompts import NonInteractivePrompter + + +class TestNonInteractivePrompter: + def test_select_returns_default(self): + prompter = NonInteractivePrompter() + assert prompter.select("Format?", ["a", "b"], "b", "--format") == "b" + + def test_select_without_default_names_the_flag(self): + prompter = NonInteractivePrompter() + with pytest.raises(CliError, match=r"--format"): + prompter.select("Format?", ["a", "b"], None, "--format") + + def test_text_returns_default(self): + prompter = NonInteractivePrompter() + assert prompter.text("Name?", "model", "--name") == "model" + + def test_text_without_default_names_the_flag(self): + prompter = NonInteractivePrompter() + with pytest.raises(CliError, match=r"--anchors"): + prompter.text("Anchors?", "", "--anchors") + + def test_confirm_returns_default(self): + prompter = NonInteractivePrompter() + assert prompter.confirm("Continue?", True, "--strict") is True + assert prompter.confirm("Overwrite?", False, "--force") is False + + def test_path_without_default_names_the_flag(self): + prompter = NonInteractivePrompter() + with pytest.raises(CliError, match=r"--labels"): + prompter.path("Labels file?", None, "--labels") + + +class TestDepsGuidance: + def test_missing_deps_detection(self, mocker): + from blueye.sdk.cli import deps + + find_spec = mocker.patch("importlib.util.find_spec") + find_spec.side_effect = lambda name: None if name == "onnx" else object() + assert deps.missing_cli_deps() == ["onnx"] + + def test_guidance_prefers_uv_when_available(self, mocker, capsys): + from blueye.sdk.cli import deps + + mocker.patch("shutil.which", return_value="/usr/local/bin/uv") + deps.print_install_guidance(["onnx"]) + assert "uv pip install" in capsys.readouterr().out + + def test_guidance_falls_back_to_pip(self, mocker, capsys): + from blueye.sdk.cli import deps + + mocker.patch("shutil.which", return_value=None) + deps.print_install_guidance(["onnx"]) + assert "python -m pip install" in capsys.readouterr().out + + def test_guidance_mentions_powershell_on_windows(self, mocker, capsys): + from blueye.sdk.cli import deps + + mocker.patch("shutil.which", return_value=None) + mocker.patch("sys.platform", "win32") + deps.print_install_guidance(["onnx"]) + assert "PowerShell" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index b490def7..69ff57f9 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10, <4" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -131,6 +132,11 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "onnx" }, + { name = "questionary" }, + { name = "rich" }, +] examples = [ { name = "asciimatics" }, { name = "foxglove-websocket" }, @@ -154,12 +160,15 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "neoteroi-mkdocs" }, + { name = "onnx" }, { name = "pre-commit" }, { name = "pymdown-extensions" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "questionary" }, { name = "requests-mock" }, + { name = "rich" }, ] [package.metadata] @@ -170,17 +179,20 @@ requires-dist = [ { name = "inputs", marker = "extra == 'examples'", specifier = ">=0.5,<0.6" }, { name = "matplotlib", marker = "extra == 'examples'", specifier = "~=3.10" }, { name = "mcap-protobuf-support", marker = "extra == 'examples'", specifier = ">=0.5.3,<0.6" }, + { name = "onnx", marker = "extra == 'cli'", specifier = ">=1.16,<2" }, { name = "packaging", specifier = ">=24.2" }, { name = "pandas", marker = "extra == 'examples'", specifier = "~=2.2" }, { name = "proto-plus", specifier = ">=1.22.2,<2" }, { name = "pyserial", marker = "extra == 'examples'", specifier = "~=3.5" }, { name = "python-dateutil", specifier = ">=2.8.2,<3" }, { name = "pyzmq", specifier = "~=26.0" }, + { name = "questionary", marker = "extra == 'cli'", specifier = ">=2.0,<3" }, { name = "requests", specifier = ">=2.22.0,<3" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=13,<15" }, { name = "tabulate", specifier = ">=0.9,<0.10" }, { name = "webdavclient3", marker = "extra == 'examples'", specifier = ">=3.14.6,<4" }, ] -provides-extras = ["examples"] +provides-extras = ["cli", "examples"] [package.metadata.requires-dev] dev = [ @@ -194,12 +206,15 @@ dev = [ { name = "mkdocs-material", specifier = "~=9.5" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.30.1,<0.31" }, { name = "neoteroi-mkdocs", specifier = ">=1.2.0,<2" }, + { name = "onnx", specifier = ">=1.16,<2" }, { name = "pre-commit", specifier = "~=4.0" }, { name = "pymdown-extensions", specifier = "~=10.14" }, { name = "pytest", specifier = "~=8.3" }, { name = "pytest-cov", specifier = "~=6.0" }, { name = "pytest-mock", specifier = "~=3.11" }, + { name = "questionary", specifier = ">=2.0,<3" }, { name = "requests-mock", specifier = "~=1.11" }, + { name = "rich", specifier = ">=13,<15" }, ] [[package]] @@ -487,7 +502,8 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1639,6 +1655,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/dd/a24ee3de56954bfafb6ede7cd63c2413bb842cc48eb45e41c43a05a33074/mkdocstrings_python-1.16.12-py3-none-any.whl", hash = "sha256:22ded3a63b3d823d57457a70ff9860d5a4de9e8b1e482876fc9baabaf6f5f374", size = 124287, upload-time = "2025-06-03T12:52:47.819Z" }, ] +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/3a/c5b855752a70267ff729c349e650263adb3c206c29d28cc8ea7ace30a1d5/ml_dtypes-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b95e97e470fe60ed493fd9ae3911d8da4ebac16bd21f87ffa2b7c588bf22ea2c", size = 679735, upload-time = "2025-11-17T22:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/41/79/7433f30ee04bd4faa303844048f55e1eb939131c8e5195a00a96a0939b64/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4b801ebe0b477be666696bda493a9be8356f1f0057a57f1e35cd26928823e5a", size = 5051883, upload-time = "2025-11-17T22:31:33.658Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/8938e8830b0ee2e167fc75a094dea766a1152bde46752cd9bfc57ee78a82/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388d399a2152dd79a3f0456a952284a99ee5c93d3e2f8dfe25977511e0515270", size = 5030369, upload-time = "2025-11-17T22:31:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/51886727bd16e2f47587997b802dd56398692ce8c6c03c2e5bb32ecafe26/ml_dtypes-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:4ff7f3e7ca2972e7de850e7b8fcbb355304271e2933dd90814c1cb847414d6e2", size = 210738, upload-time = "2025-11-17T22:31:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1744,7 +1806,8 @@ name = "numpy" version = "2.3.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } @@ -1824,6 +1887,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, ] +[[package]] +name = "onnx" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/8ea73a64b368b75fe339771a20a02bc61ea1f551484c9e3d9d0bfbd0450f/onnx-1.22.0.tar.gz", hash = "sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0", size = 12024721, upload-time = "2026-06-15T12:50:05.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/04/471f234e2716c83f17a26e1b50cd64c39428373e91dd018aafb3d499c108/onnx-1.22.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:6d0ffffd63a4ecc21ddaeddd5bf02099cb701aa4243f2de00122726869065ca4", size = 20167110, upload-time = "2026-06-15T12:48:59.152Z" }, + { url = "https://files.pythonhosted.org/packages/99/40/540a2fe3c49ce1709ff2015de20d9a351264fb442f8998f92cf0ba7e279e/onnx-1.22.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33ce94119bbb7f05d9caea4ea7549f5185a54369f6bbc9f70171bd5ee6935bbc", size = 18892738, upload-time = "2026-06-15T12:49:02.139Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/f41d5b89c38fb2ec410ab23c24fa110af786093b140644f7f953e436743b/onnx-1.22.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87a3077958f66f9a26dec10077ac28326d9cec2cbe1f0b040947243449754573", size = 19110354, upload-time = "2026-06-15T12:49:05.031Z" }, + { url = "https://files.pythonhosted.org/packages/11/8e/9f41d132855e93c2808cdd4afab1b5af67bd5e82e4a4fa9248006e4df87e/onnx-1.22.0-cp310-cp310-win32.whl", hash = "sha256:8a5eccce2d5fc6c5046928a9aa7cdd9750ea4a586f8de341d3d40d820c35fdec", size = 17083595, upload-time = "2026-06-15T12:49:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/e8/52/86caff81786a5428485795c79175ae2b12a630795bcb267b84e5f9e98450/onnx-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:5c1c0408a9d4b4df33851672e5fc7590b96301ee123396d608f9ab6f045ab06b", size = 17215270, upload-time = "2026-06-15T12:49:11.483Z" }, + { url = "https://files.pythonhosted.org/packages/0c/55/30825c02c92a0380ce84c3feeeec95d329fa77548ba58cb10ad4bbfd83c6/onnx-1.22.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:2d8f229a553fa440fe623ed7b36fca5e7762da3af871c3f8f8ce451df73e2914", size = 20167891, upload-time = "2026-06-15T12:49:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/4b/24/cd4ab52ecaf41c3fbed674772ccbfe39041cb257b8471a47a37e48bff3f8/onnx-1.22.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a89a7cb9ba13d78f009bdec448ec82a98972589734f157022a2bff7a5973a6", size = 18892720, upload-time = "2026-06-15T12:49:16.904Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/c9d9d56ceadb1c0a90a7cbec5a0510520ab6538938944fa84548e4b5b054/onnx-1.22.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d0a2bdb15eb2b3cb65c438f3423d9620d14fdce32f92380e6bb1b2e09568ef5", size = 19110720, upload-time = "2026-06-15T12:49:19.812Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/e43e5a68d9cadde55df75310027f87127333a77e5ddcea14c73e96a10cac/onnx-1.22.0-cp311-cp311-win32.whl", hash = "sha256:239958534464612fbcb6ed23d5228aaa925b39b8773f58726809ffdccb4edd1c", size = 17083746, upload-time = "2026-06-15T12:49:22.935Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/cc0a9f2cf4522e42829d089927b4b75924d32f50dca237482e7b741df003/onnx-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:8561a2c00041c07e08db0c228593b5b4694100398685f348532af7dbb84189da", size = 17215684, upload-time = "2026-06-15T12:49:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/0f049f9eaa06c8383060c5f0a338e3a6caac8822e6e326c9162f05abf95a/onnx-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:8907b9b9389893bc0dc6314cc00ee1e3a69844e48d689eacc6a0340411a7da58", size = 17210398, upload-time = "2026-06-15T12:49:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6a/481561f1093834376ed493e4ca42a73e5be0d50031f2969c86593bdc7c96/onnx-1.22.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103", size = 20167081, upload-time = "2026-06-15T12:49:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/84/55/b34fc2aa30aa54b4a775402d24c4082242c720283a274fe976ac8eb94480/onnx-1.22.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16", size = 18889249, upload-time = "2026-06-15T12:49:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/a6/bd32357e6cc1ecb473afd78193d7231724f284435d2db25696ecfaaa1503/onnx-1.22.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b", size = 19106514, upload-time = "2026-06-15T12:49:37.424Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9d/3af461ac6c714b8b369cb71499659932f4f12cfb066250b62f7567c3d530/onnx-1.22.0-cp312-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c", size = 16966387, upload-time = "2026-06-15T12:49:40.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/68195b5e5a53e333faf2660f5352ee43738d0e42fc5216cc6b1871a9fbfb/onnx-1.22.0-cp312-abi3-win32.whl", hash = "sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016", size = 17081568, upload-time = "2026-06-15T12:49:43.398Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl", hash = "sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a", size = 17213290, upload-time = "2026-06-15T12:49:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" }, + { url = "https://files.pythonhosted.org/packages/f3/13/47323b97846387848efb1044ded11bb94b83526f3d1fbdb37c6480d4520f/onnx-1.22.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72", size = 20176465, upload-time = "2026-06-15T12:49:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/13/0c/d3b8a7e7eee123938586c608bb9894b5723f2342b9450c0eec59fbec7099/onnx-1.22.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c21a0e59fd967a95b358e4a6e756d1f1eec2d304a83480f329f66e30d2bf0223", size = 18894028, upload-time = "2026-06-15T12:49:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8a/da2a97ab46fe6e0cd9beb3ac14603a22f5be492f9ca347faf8233a07bb33/onnx-1.22.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2632406b8f523ef2e2873c363f90b20a3d88c0fbcfac757d3addffccf8f452c2", size = 19110420, upload-time = "2026-06-15T12:49:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/ce984063017518307ebfaa545782fc400e593dc2d7fdf4f23ce4be1ed197/onnx-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3a39fc4643867aecb33417fdddb11e308ee79d2d4a584b9d50cc7aec2091b13", size = 17237547, upload-time = "2026-06-15T12:50:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -2045,6 +2146,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "proto-plus" version = "1.26.1" @@ -2405,6 +2518,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/9c/d8073bd898eb896e94c679abe82e47506e2b750eb261cf6010ced869797c/pyzmq-26.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a222ad02fbe80166b0526c038776e8042cd4e5f0dec1489a006a1df47e9040e0", size = 555371, upload-time = "2025-04-04T12:05:20.702Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "requests" version = "2.32.5" From 1f6a3bc269d8e455ec92c4fd27f95101b4bc2318 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Wed, 8 Jul 2026 15:20:12 +0200 Subject: [PATCH 02/17] feat: default bundled packages to autolaunch on the drone The runtime.enabled confirm now defaults to true (and --yes runs write enabled: true), so a bundled model runs on the drone out of the box. --runtime-enabled becomes a --runtime-enabled/--no-runtime-enabled pair for explicit control in scripted runs. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/bundle_model.py | 17 ++++++++++++----- docs/bundling-cv-models.md | 3 ++- tests/test_cli_main.py | 14 +++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/blueye/sdk/cli/bundle_model.py b/blueye/sdk/cli/bundle_model.py index d4d70a68..626e6aa2 100644 --- a/blueye/sdk/cli/bundle_model.py +++ b/blueye/sdk/cli/bundle_model.py @@ -81,8 +81,10 @@ def add_parser(subparsers) -> None: parser.add_argument("--runtime-hz", help='Maximum inference rate in Hz, or "max" for unlimited') parser.add_argument( "--runtime-enabled", - action="store_true", - help="Autolaunch this package on the drone (runtime.enabled=true)", + action=argparse.BooleanOptionalAction, + default=None, + help="Autolaunch this package on the drone (default: enabled; use " + "--no-runtime-enabled to bundle it disabled)", ) parser.add_argument("--template-size", type=int, help="SOT template crop size (px)") parser.add_argument("--search-size", type=int, help="SOT search region size (px)") @@ -214,9 +216,14 @@ def _resolve_runtime(args, dla, prompter): else: hz = float(answer) - enabled = args.runtime_enabled or prompter.confirm( - "Autolaunch this package on the drone (runtime.enabled)?", False, "--runtime-enabled" - ) + if args.runtime_enabled is not None: + enabled = args.runtime_enabled + else: + enabled = prompter.confirm( + "Autolaunch this package on the drone (runtime.enabled)?", + True, + "--runtime-enabled/--no-runtime-enabled", + ) return device, hz, enabled diff --git a/docs/bundling-cv-models.md b/docs/bundling-cv-models.md index 743c53ec..41be5b5c 100644 --- a/docs/bundling-cv-models.md +++ b/docs/bundling-cv-models.md @@ -49,7 +49,8 @@ name, tracking algorithm, and the runtime configuration for the drone: GPU for other work. Models with layers the DLA cannot run (NMS-in-graph, transformers) get `tensorrt` recommended instead. You can always pick any device. - **Inference rate** — maximum rate in Hz, defaulting to unlimited. -- **Autolaunch** — whether the drone should start this model automatically. +- **Autolaunch** — whether the drone should start this model automatically. Defaults + to enabled; pass `--no-runtime-enabled` to bundle the package disabled. The result is a zip with `model.onnx` and `model_meta.json` at its root. diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 3fd4c470..8ed187f0 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -99,7 +99,8 @@ def test_bundle_end_to_end(yolov8_model, fake_prompter, tmp_path): assert meta["detection"]["num_classes"] == 80 assert meta["detection"]["input_width"] == 640 assert len(meta["labels"]) == 80 - assert meta["runtime"]["enabled"] is False + # Packages default to autolaunching on the drone. + assert meta["runtime"]["enabled"] is True def test_dry_run_writes_nothing(yolov8_model, fake_prompter, tmp_path, capsys): @@ -177,6 +178,17 @@ def test_runtime_flags_flow_into_meta(yolov8_model, fake_prompter, tmp_path): assert meta["tracking"]["algorithm"] == "byte_track" +def test_no_runtime_enabled_flag_disables_autolaunch(yolov8_model, fake_prompter, tmp_path): + output = tmp_path / "bundle.zip" + exit_code = main( + ["bundle-model", str(yolov8_model), "--yes", "-o", str(output), "--no-runtime-enabled"] + ) + assert exit_code == 0 + with zipfile.ZipFile(output) as archive: + meta = json.loads(archive.read("model_meta.json")) + assert meta["runtime"]["enabled"] is False + + def test_runtime_hz_max_is_unlimited(yolov8_model, fake_prompter, tmp_path): output = tmp_path / "bundle.zip" main( From 18b38e5b6ced249b3fc497de4a58511e47e981bd Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Wed, 8 Jul 2026 15:34:03 +0200 Subject: [PATCH 03/17] fix: widen pyzmq to <28 so Python 3.14 installs a prebuilt wheel CI fails on windows-latest + Python 3.14 (on master too, verified with a canary run of master HEAD): pyzmq 26.4.0 has no cp314 Windows wheel, so uv compiles it from source, and the current runner image breaks that build (FindVcvars cannot locate the MSVC scripts). pyzmq 27.x ships a cp312-abi3 Windows wheel that covers 3.12+, so widening the constraint to >=26,<28 and locking 27.1.0 removes the source build entirely. The SDK only uses basic zmq APIs (Context, PUB/SUB, Poller), which are unchanged in 27. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 4 +- uv.lock | 132 ++++++++++++++++++++++++------------------------- 2 files changed, 69 insertions(+), 67 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f839fe6..c07e6e35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,9 @@ dependencies = [ "packaging>=24.2", "blueye.protocol>=3.3.0,<4", "python-dateutil>=2.8.2,<3", - "pyzmq~=26.0", + # 27.x ships an abi3 Windows wheel that covers Python 3.14+; 26.x tops out at + # cp313 and has to be compiled from source on newer interpreters. + "pyzmq>=26,<28", "proto-plus>=1.22.2,<2", ] diff --git a/uv.lock b/uv.lock index 69ff57f9..f016dd35 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ requires-dist = [ { name = "proto-plus", specifier = ">=1.22.2,<2" }, { name = "pyserial", marker = "extra == 'examples'", specifier = "~=3.5" }, { name = "python-dateutil", specifier = ">=2.8.2,<3" }, - { name = "pyzmq", specifier = "~=26.0" }, + { name = "pyzmq", specifier = ">=26,<28" }, { name = "questionary", marker = "extra == 'cli'", specifier = ">=2.0,<3" }, { name = "requests", specifier = ">=2.22.0,<3" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13,<15" }, @@ -2447,75 +2447,75 @@ wheels = [ [[package]] name = "pyzmq" -version = "26.4.0" +version = "27.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293, upload-time = "2025-04-04T12:05:44.049Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/b8/af1d814ffc3ff9730f9a970cbf216b6f078e5d251a25ef5201d7bc32a37c/pyzmq-26.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918", size = 1339238, upload-time = "2025-04-04T12:03:07.022Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e4/5aafed4886c264f2ea6064601ad39c5fc4e9b6539c6ebe598a859832eeee/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315", size = 672848, upload-time = "2025-04-04T12:03:08.591Z" }, - { url = "https://files.pythonhosted.org/packages/79/39/026bf49c721cb42f1ef3ae0ee3d348212a7621d2adb739ba97599b6e4d50/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b", size = 911299, upload-time = "2025-04-04T12:03:10Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/b41f936a9403b8f92325c823c0f264c6102a0687a99c820f1aaeb99c1def/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4", size = 867920, upload-time = "2025-04-04T12:03:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3e/2de5928cdadc2105e7c8f890cc5f404136b41ce5b6eae5902167f1d5641c/pyzmq-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f", size = 862514, upload-time = "2025-04-04T12:03:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/ce/57/109569514dd32e05a61d4382bc88980c95bfd2f02e58fea47ec0ccd96de1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5", size = 1204494, upload-time = "2025-04-04T12:03:14.795Z" }, - { url = "https://files.pythonhosted.org/packages/aa/02/dc51068ff2ca70350d1151833643a598625feac7b632372d229ceb4de3e1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a", size = 1514525, upload-time = "2025-04-04T12:03:16.246Z" }, - { url = "https://files.pythonhosted.org/packages/48/2a/a7d81873fff0645eb60afaec2b7c78a85a377af8f1d911aff045d8955bc7/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b", size = 1414659, upload-time = "2025-04-04T12:03:17.652Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ea/813af9c42ae21845c1ccfe495bd29c067622a621e85d7cda6bc437de8101/pyzmq-26.4.0-cp310-cp310-win32.whl", hash = "sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980", size = 580348, upload-time = "2025-04-04T12:03:19.384Z" }, - { url = "https://files.pythonhosted.org/packages/20/68/318666a89a565252c81d3fed7f3b4c54bd80fd55c6095988dfa2cd04a62b/pyzmq-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b", size = 643838, upload-time = "2025-04-04T12:03:20.795Z" }, - { url = "https://files.pythonhosted.org/packages/91/f8/fb1a15b5f4ecd3e588bfde40c17d32ed84b735195b5c7d1d7ce88301a16f/pyzmq-26.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5", size = 559565, upload-time = "2025-04-04T12:03:22.676Z" }, - { url = "https://files.pythonhosted.org/packages/32/6d/234e3b0aa82fd0290b1896e9992f56bdddf1f97266110be54d0177a9d2d9/pyzmq-26.4.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:bfcf82644c9b45ddd7cd2a041f3ff8dce4a0904429b74d73a439e8cab1bd9e54", size = 1339723, upload-time = "2025-04-04T12:03:24.358Z" }, - { url = "https://files.pythonhosted.org/packages/4f/11/6d561efe29ad83f7149a7cd48e498e539ed09019c6cd7ecc73f4cc725028/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9bcae3979b2654d5289d3490742378b2f3ce804b0b5fd42036074e2bf35b030", size = 672645, upload-time = "2025-04-04T12:03:25.693Z" }, - { url = "https://files.pythonhosted.org/packages/19/fd/81bfe3e23f418644660bad1a90f0d22f0b3eebe33dd65a79385530bceb3d/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccdff8ac4246b6fb60dcf3982dfaeeff5dd04f36051fe0632748fc0aa0679c01", size = 910133, upload-time = "2025-04-04T12:03:27.625Z" }, - { url = "https://files.pythonhosted.org/packages/97/68/321b9c775595ea3df832a9516252b653fe32818db66fdc8fa31c9b9fce37/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4550af385b442dc2d55ab7717837812799d3674cb12f9a3aa897611839c18e9e", size = 867428, upload-time = "2025-04-04T12:03:29.004Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/159cbf2055ef36aa2aa297e01b24523176e5b48ead283c23a94179fb2ba2/pyzmq-26.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f7ffe9db1187a253fca95191854b3fda24696f086e8789d1d449308a34b88", size = 862409, upload-time = "2025-04-04T12:03:31.032Z" }, - { url = "https://files.pythonhosted.org/packages/05/1c/45fb8db7be5a7d0cadea1070a9cbded5199a2d578de2208197e592f219bd/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3709c9ff7ba61589b7372923fd82b99a81932b592a5c7f1a24147c91da9a68d6", size = 1205007, upload-time = "2025-04-04T12:03:32.687Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fa/658c7f583af6498b463f2fa600f34e298e1b330886f82f1feba0dc2dd6c3/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f8f3c30fb2d26ae5ce36b59768ba60fb72507ea9efc72f8f69fa088450cff1df", size = 1514599, upload-time = "2025-04-04T12:03:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d7/44d641522353ce0a2bbd150379cb5ec32f7120944e6bfba4846586945658/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:382a4a48c8080e273427fc692037e3f7d2851959ffe40864f2db32646eeb3cef", size = 1414546, upload-time = "2025-04-04T12:03:35.478Z" }, - { url = "https://files.pythonhosted.org/packages/72/76/c8ed7263218b3d1e9bce07b9058502024188bd52cc0b0a267a9513b431fc/pyzmq-26.4.0-cp311-cp311-win32.whl", hash = "sha256:d56aad0517d4c09e3b4f15adebba8f6372c5102c27742a5bdbfc74a7dceb8fca", size = 579247, upload-time = "2025-04-04T12:03:36.846Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d0/2d9abfa2571a0b1a67c0ada79a8aa1ba1cce57992d80f771abcdf99bb32c/pyzmq-26.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:963977ac8baed7058c1e126014f3fe58b3773f45c78cce7af5c26c09b6823896", size = 644727, upload-time = "2025-04-04T12:03:38.578Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d1/c8ad82393be6ccedfc3c9f3adb07f8f3976e3c4802640fe3f71441941e70/pyzmq-26.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0c8e8cadc81e44cc5088fcd53b9b3b4ce9344815f6c4a03aec653509296fae3", size = 559942, upload-time = "2025-04-04T12:03:40.143Z" }, - { url = "https://files.pythonhosted.org/packages/10/44/a778555ebfdf6c7fc00816aad12d185d10a74d975800341b1bc36bad1187/pyzmq-26.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5227cb8da4b6f68acfd48d20c588197fd67745c278827d5238c707daf579227b", size = 1341586, upload-time = "2025-04-04T12:03:41.954Z" }, - { url = "https://files.pythonhosted.org/packages/9c/4f/f3a58dc69ac757e5103be3bd41fb78721a5e17da7cc617ddb56d973a365c/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1c07a7fa7f7ba86554a2b1bef198c9fed570c08ee062fd2fd6a4dcacd45f905", size = 665880, upload-time = "2025-04-04T12:03:43.45Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/50230bcfb3ae5cb98bee683b6edeba1919f2565d7cc1851d3c38e2260795/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae775fa83f52f52de73183f7ef5395186f7105d5ed65b1ae65ba27cb1260de2b", size = 902216, upload-time = "2025-04-04T12:03:45.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/59/56bbdc5689be5e13727491ad2ba5efd7cd564365750514f9bc8f212eef82/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c760d0226ebd52f1e6b644a9e839b5db1e107a23f2fcd46ec0569a4fdd4e63", size = 859814, upload-time = "2025-04-04T12:03:47.188Z" }, - { url = "https://files.pythonhosted.org/packages/81/b1/57db58cfc8af592ce94f40649bd1804369c05b2190e4cbc0a2dad572baeb/pyzmq-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ef8c6ecc1d520debc147173eaa3765d53f06cd8dbe7bd377064cdbc53ab456f5", size = 855889, upload-time = "2025-04-04T12:03:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/e8/92/47542e629cbac8f221c230a6d0f38dd3d9cff9f6f589ed45fdf572ffd726/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3150ef4084e163dec29ae667b10d96aad309b668fac6810c9e8c27cf543d6e0b", size = 1197153, upload-time = "2025-04-04T12:03:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/07/e5/b10a979d1d565d54410afc87499b16c96b4a181af46e7645ab4831b1088c/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4448c9e55bf8329fa1dcedd32f661bf611214fa70c8e02fee4347bc589d39a84", size = 1507352, upload-time = "2025-04-04T12:03:52.473Z" }, - { url = "https://files.pythonhosted.org/packages/ab/58/5a23db84507ab9c01c04b1232a7a763be66e992aa2e66498521bbbc72a71/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e07dde3647afb084d985310d067a3efa6efad0621ee10826f2cb2f9a31b89d2f", size = 1406834, upload-time = "2025-04-04T12:03:54Z" }, - { url = "https://files.pythonhosted.org/packages/22/74/aaa837b331580c13b79ac39396601fb361454ee184ca85e8861914769b99/pyzmq-26.4.0-cp312-cp312-win32.whl", hash = "sha256:ba034a32ecf9af72adfa5ee383ad0fd4f4e38cdb62b13624278ef768fe5b5b44", size = 577992, upload-time = "2025-04-04T12:03:55.815Z" }, - { url = "https://files.pythonhosted.org/packages/30/0f/55f8c02c182856743b82dde46b2dc3e314edda7f1098c12a8227eeda0833/pyzmq-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:056a97aab4064f526ecb32f4343917a4022a5d9efb6b9df990ff72e1879e40be", size = 640466, upload-time = "2025-04-04T12:03:57.231Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/073779afc3ef6f830b8de95026ef20b2d1ec22d0324d767748d806e57379/pyzmq-26.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f23c750e485ce1eb639dbd576d27d168595908aa2d60b149e2d9e34c9df40e0", size = 556342, upload-time = "2025-04-04T12:03:59.218Z" }, - { url = "https://files.pythonhosted.org/packages/d7/20/fb2c92542488db70f833b92893769a569458311a76474bda89dc4264bd18/pyzmq-26.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:c43fac689880f5174d6fc864857d1247fe5cfa22b09ed058a344ca92bf5301e3", size = 1339484, upload-time = "2025-04-04T12:04:00.671Z" }, - { url = "https://files.pythonhosted.org/packages/58/29/2f06b9cabda3a6ea2c10f43e67ded3e47fc25c54822e2506dfb8325155d4/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:902aca7eba477657c5fb81c808318460328758e8367ecdd1964b6330c73cae43", size = 666106, upload-time = "2025-04-04T12:04:02.366Z" }, - { url = "https://files.pythonhosted.org/packages/77/e4/dcf62bd29e5e190bd21bfccaa4f3386e01bf40d948c239239c2f1e726729/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5e48a830bfd152fe17fbdeaf99ac5271aa4122521bf0d275b6b24e52ef35eb6", size = 902056, upload-time = "2025-04-04T12:04:03.919Z" }, - { url = "https://files.pythonhosted.org/packages/1a/cf/b36b3d7aea236087d20189bec1a87eeb2b66009731d7055e5c65f845cdba/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31be2b6de98c824c06f5574331f805707c667dc8f60cb18580b7de078479891e", size = 860148, upload-time = "2025-04-04T12:04:05.581Z" }, - { url = "https://files.pythonhosted.org/packages/18/a6/f048826bc87528c208e90604c3bf573801e54bd91e390cbd2dfa860e82dc/pyzmq-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6332452034be001bbf3206ac59c0d2a7713de5f25bb38b06519fc6967b7cf771", size = 855983, upload-time = "2025-04-04T12:04:07.096Z" }, - { url = "https://files.pythonhosted.org/packages/0a/27/454d34ab6a1d9772a36add22f17f6b85baf7c16e14325fa29e7202ca8ee8/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:da8c0f5dd352136853e6a09b1b986ee5278dfddfebd30515e16eae425c872b30", size = 1197274, upload-time = "2025-04-04T12:04:08.523Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/7abfeab6b83ad38aa34cbd57c6fc29752c391e3954fd12848bd8d2ec0df6/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f4ccc1a0a2c9806dda2a2dd118a3b7b681e448f3bb354056cad44a65169f6d86", size = 1507120, upload-time = "2025-04-04T12:04:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/13/ff/bc8d21dbb9bc8705126e875438a1969c4f77e03fc8565d6901c7933a3d01/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c0b5fceadbab461578daf8d1dcc918ebe7ddd2952f748cf30c7cf2de5d51101", size = 1406738, upload-time = "2025-04-04T12:04:12.509Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5d/d4cd85b24de71d84d81229e3bbb13392b2698432cf8fdcea5afda253d587/pyzmq-26.4.0-cp313-cp313-win32.whl", hash = "sha256:28e2b0ff5ba4b3dd11062d905682bad33385cfa3cc03e81abd7f0822263e6637", size = 577826, upload-time = "2025-04-04T12:04:14.289Z" }, - { url = "https://files.pythonhosted.org/packages/c6/6c/f289c1789d7bb6e5a3b3bef7b2a55089b8561d17132be7d960d3ff33b14e/pyzmq-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:23ecc9d241004c10e8b4f49d12ac064cd7000e1643343944a10df98e57bc544b", size = 640406, upload-time = "2025-04-04T12:04:15.757Z" }, - { url = "https://files.pythonhosted.org/packages/b3/99/676b8851cb955eb5236a0c1e9ec679ea5ede092bf8bf2c8a68d7e965cac3/pyzmq-26.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:1edb0385c7f025045d6e0f759d4d3afe43c17a3d898914ec6582e6f464203c08", size = 556216, upload-time = "2025-04-04T12:04:17.212Z" }, - { url = "https://files.pythonhosted.org/packages/65/c2/1fac340de9d7df71efc59d9c50fc7a635a77b103392d1842898dd023afcb/pyzmq-26.4.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:93a29e882b2ba1db86ba5dd5e88e18e0ac6b627026c5cfbec9983422011b82d4", size = 1333769, upload-time = "2025-04-04T12:04:18.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c7/6c03637e8d742c3b00bec4f5e4cd9d1c01b2f3694c6f140742e93ca637ed/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45684f276f57110bb89e4300c00f1233ca631f08f5f42528a5c408a79efc4a", size = 658826, upload-time = "2025-04-04T12:04:20.405Z" }, - { url = "https://files.pythonhosted.org/packages/a5/97/a8dca65913c0f78e0545af2bb5078aebfc142ca7d91cdaffa1fbc73e5dbd/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72073e75260cb301aad4258ad6150fa7f57c719b3f498cb91e31df16784d89b", size = 891650, upload-time = "2025-04-04T12:04:22.413Z" }, - { url = "https://files.pythonhosted.org/packages/7d/7e/f63af1031eb060bf02d033732b910fe48548dcfdbe9c785e9f74a6cc6ae4/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be37e24b13026cfedd233bcbbccd8c0bcd2fdd186216094d095f60076201538d", size = 849776, upload-time = "2025-04-04T12:04:23.959Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/1a009ce582802a895c0d5fe9413f029c940a0a8ee828657a3bb0acffd88b/pyzmq-26.4.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:237b283044934d26f1eeff4075f751b05d2f3ed42a257fc44386d00df6a270cf", size = 842516, upload-time = "2025-04-04T12:04:25.449Z" }, - { url = "https://files.pythonhosted.org/packages/6e/bc/f88b0bad0f7a7f500547d71e99f10336f2314e525d4ebf576a1ea4a1d903/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b30f862f6768b17040929a68432c8a8be77780317f45a353cb17e423127d250c", size = 1189183, upload-time = "2025-04-04T12:04:27.035Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8c/db446a3dd9cf894406dec2e61eeffaa3c07c3abb783deaebb9812c4af6a5/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c80fcd3504232f13617c6ab501124d373e4895424e65de8b72042333316f64a8", size = 1495501, upload-time = "2025-04-04T12:04:28.833Z" }, - { url = "https://files.pythonhosted.org/packages/05/4c/bf3cad0d64c3214ac881299c4562b815f05d503bccc513e3fd4fdc6f67e4/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:26a2a7451606b87f67cdeca2c2789d86f605da08b4bd616b1a9981605ca3a364", size = 1395540, upload-time = "2025-04-04T12:04:30.562Z" }, - { url = "https://files.pythonhosted.org/packages/47/03/96004704a84095f493be8d2b476641f5c967b269390173f85488a53c1c13/pyzmq-26.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba", size = 834408, upload-time = "2025-04-04T12:05:04.569Z" }, - { url = "https://files.pythonhosted.org/packages/e4/7f/68d8f3034a20505db7551cb2260248be28ca66d537a1ac9a257913d778e4/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b", size = 569580, upload-time = "2025-04-04T12:05:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a6/2b0d6801ec33f2b2a19dd8d02e0a1e8701000fec72926e6787363567d30c/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94", size = 798250, upload-time = "2025-04-04T12:05:07.88Z" }, - { url = "https://files.pythonhosted.org/packages/96/2a/0322b3437de977dcac8a755d6d7ce6ec5238de78e2e2d9353730b297cf12/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a", size = 756758, upload-time = "2025-04-04T12:05:09.483Z" }, - { url = "https://files.pythonhosted.org/packages/c2/33/43704f066369416d65549ccee366cc19153911bec0154da7c6b41fca7e78/pyzmq-26.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb", size = 555371, upload-time = "2025-04-04T12:05:11.062Z" }, - { url = "https://files.pythonhosted.org/packages/04/52/a70fcd5592715702248306d8e1729c10742c2eac44529984413b05c68658/pyzmq-26.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4478b14cb54a805088299c25a79f27eaf530564a7a4f72bf432a040042b554eb", size = 834405, upload-time = "2025-04-04T12:05:13.3Z" }, - { url = "https://files.pythonhosted.org/packages/25/f9/1a03f1accff16b3af1a6fa22cbf7ced074776abbf688b2e9cb4629700c62/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a28ac29c60e4ba84b5f58605ace8ad495414a724fe7aceb7cf06cd0598d04e1", size = 569578, upload-time = "2025-04-04T12:05:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/76/0c/3a633acd762aa6655fcb71fa841907eae0ab1e8582ff494b137266de341d/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43b03c1ceea27c6520124f4fb2ba9c647409b9abdf9a62388117148a90419494", size = 798248, upload-time = "2025-04-04T12:05:17.376Z" }, - { url = "https://files.pythonhosted.org/packages/cd/cc/6c99c84aa60ac1cc56747bed6be8ce6305b9b861d7475772e7a25ce019d3/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7731abd23a782851426d4e37deb2057bf9410848a4459b5ede4fe89342e687a9", size = 756757, upload-time = "2025-04-04T12:05:19.19Z" }, - { url = "https://files.pythonhosted.org/packages/13/9c/d8073bd898eb896e94c679abe82e47506e2b750eb261cf6010ced869797c/pyzmq-26.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a222ad02fbe80166b0526c038776e8042cd4e5f0dec1489a006a1df47e9040e0", size = 555371, upload-time = "2025-04-04T12:05:20.702Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] [[package]] From a0cd273dcc0a9940cc24da18fa22973ed26e0032 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Thu, 9 Jul 2026 11:12:14 +0200 Subject: [PATCH 04/17] feat: extensible CLI scaffolding with third-party tool discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the `blueye` CLI so commands are pluggable rather than hard-coded, and adds a discovery mechanism for user-provided tools. First-party commands: each lives in its own package under blueye/sdk/cli/commands// exposing a CommandSpec (name, help, required optional deps, add_parser, run); main.py iterates the registry and gates dependencies per command instead of globally. CliError moves to cli/errors.py (removing the prompts->main cycle hazard); bundle-model's introspect/heuristics/meta/bundle modules move into its command package. The CommandSpec contract is designed to double as the payload for future pip-installable plugins (blueye.cli entry-point group, documented as future work). Third-party tools: single-file Python scripts with PEP 723 inline metadata extended by a [tool.blueye] table (name, description, optional min-sdk-version). Discovery scans a per-user tools directory (BLUEYE_CLI_TOOLS_DIR, defaulting per OS), parses only the metadata (never executes code to list), shows tools in `blueye --help`, and dispatches `blueye args...` as a subprocess with verbatim args and propagated exit code — via `uv run` when the script declares PEP 723 dependencies and uv is available (isolated deps for free). Tool names are intercepted before argparse because a REMAINDER positional cannot forward leading-dash arguments. Built-ins always win name collisions. TOML parsing tiers tomllib -> tomli (added to the [cli] extra for 3.10) -> a minimal regex fallback so a stdlib-only 3.10 install can still discover tools. New `blueye tools` built-in (runs with zero extras — it is the bootstrap surface): list, validate (per-check report), install (validate + copy as .py, --force to replace), uninstall (by name, incl. hand-copied files), and dir. Verified in a no-extras venv: --help with the tools epilog, tools list, tool dispatch with leading-dash args, and the bundle-model dependency gate (exit 2) all work stdlib-only. Docs: new "Extending the blueye CLI" page (third-party authoring guide with a worked example, per-OS directory table, first-party command recipe, future entry-points note) + mkdocs nav and updated reference page. +59 tests (metadata parser incl. forced-fallback path, directory resolution per platform, discovery/shadowing, subprocess dispatch and uv preference, tools subcommands, per-command gating), 369 total. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/__init__.py | 57 +++++ .../sdk/cli/commands/bundle_model/__init__.py | 14 ++ .../cli/{ => commands/bundle_model}/bundle.py | 0 .../bundle_model/command.py} | 16 +- .../{ => commands/bundle_model}/heuristics.py | 0 .../{ => commands/bundle_model}/introspect.py | 0 .../cli/{ => commands/bundle_model}/meta.py | 0 blueye/sdk/cli/commands/tools/__init__.py | 14 ++ blueye/sdk/cli/commands/tools/command.py | 224 ++++++++++++++++++ blueye/sdk/cli/deps.py | 16 +- blueye/sdk/cli/errors.py | 11 + blueye/sdk/cli/external/__init__.py | 11 + blueye/sdk/cli/external/discovery.py | 122 ++++++++++ blueye/sdk/cli/external/execution.py | 75 ++++++ blueye/sdk/cli/external/metadata.py | 187 +++++++++++++++ blueye/sdk/cli/main.py | 64 +++-- blueye/sdk/cli/prompts.py | 2 +- docs/extending-the-cli.md | 117 +++++++++ docs/reference/blueye/sdk/cli.md | 16 +- mkdocs.yml | 1 + pyproject.toml | 3 + tests/test_cli_bundle.py | 2 +- tests/test_cli_heuristics.py | 4 +- tests/test_cli_introspect.py | 2 +- tests/test_cli_main.py | 7 +- tests/test_cli_meta.py | 2 +- tests/test_cli_prompts.py | 4 +- tests/test_cli_tool_discovery.py | 116 +++++++++ tests/test_cli_tool_execution.py | 106 +++++++++ tests/test_cli_tool_metadata.py | 118 +++++++++ tests/test_cli_tools_command.py | 141 +++++++++++ uv.lock | 4 + 32 files changed, 1396 insertions(+), 60 deletions(-) create mode 100644 blueye/sdk/cli/commands/__init__.py create mode 100644 blueye/sdk/cli/commands/bundle_model/__init__.py rename blueye/sdk/cli/{ => commands/bundle_model}/bundle.py (100%) rename blueye/sdk/cli/{bundle_model.py => commands/bundle_model/command.py} (98%) rename blueye/sdk/cli/{ => commands/bundle_model}/heuristics.py (100%) rename blueye/sdk/cli/{ => commands/bundle_model}/introspect.py (100%) rename blueye/sdk/cli/{ => commands/bundle_model}/meta.py (100%) create mode 100644 blueye/sdk/cli/commands/tools/__init__.py create mode 100644 blueye/sdk/cli/commands/tools/command.py create mode 100644 blueye/sdk/cli/errors.py create mode 100644 blueye/sdk/cli/external/__init__.py create mode 100644 blueye/sdk/cli/external/discovery.py create mode 100644 blueye/sdk/cli/external/execution.py create mode 100644 blueye/sdk/cli/external/metadata.py create mode 100644 docs/extending-the-cli.md create mode 100644 tests/test_cli_tool_discovery.py create mode 100644 tests/test_cli_tool_execution.py create mode 100644 tests/test_cli_tool_metadata.py create mode 100644 tests/test_cli_tools_command.py diff --git a/blueye/sdk/cli/commands/__init__.py b/blueye/sdk/cli/commands/__init__.py new file mode 100644 index 00000000..f0dab97e --- /dev/null +++ b/blueye/sdk/cli/commands/__init__.py @@ -0,0 +1,57 @@ +"""First-party command registry for the `blueye` CLI. + +Every built-in command lives in its own package under ``blueye/sdk/cli/commands/`` and +exposes a module-level ``COMMAND: CommandSpec``. Registering a new command means adding +it to :func:`all_commands` — nothing else in the CLI changes. + +Invariants command packages must uphold: + +- The package (and everything it imports at module level) must be importable with zero + optional extras installed; heavy imports (onnx, rich, questionary, ...) belong inside + ``run``. This keeps ``blueye --help`` working before the ``[cli]`` extra is installed. +- ``add_parser`` uses only argparse. +- User-facing failures raise :class:`blueye.sdk.cli.errors.CliError`; ``main`` turns + them into a clean message and exit code 1. + +The CommandSpec contract is also the intended payload for future pip-installable +plugins (a ``blueye.cli`` entry-point group): an external distribution would expose the +same object, and the registry would grow a second discovery source. +""" + +from __future__ import annotations + +import argparse +import logging +from dataclasses import dataclass +from typing import Callable + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CommandSpec: + """One first-party `blueye` subcommand. + + Attributes: + name: The subcommand name (e.g. "bundle-model"). + help: One-line description shown in ``blueye --help``. + requires: Import names of optional dependencies that must be installed before + ``run`` executes; ``main`` gates on these and prints install guidance. + add_parser: Registers the subcommand's arguments on the root subparsers + (argparse only, no optional imports). + run: Executes the command and returns the process exit code. + """ + + name: str + help: str + requires: tuple[str, ...] + add_parser: Callable[[argparse._SubParsersAction], None] + run: Callable[[argparse.Namespace], int] + + +def all_commands() -> tuple[CommandSpec, ...]: + """Return every built-in command, in the order shown in ``blueye --help``.""" + from .bundle_model import COMMAND as bundle_model_command + from .tools import COMMAND as tools_command + + return (bundle_model_command, tools_command) diff --git a/blueye/sdk/cli/commands/bundle_model/__init__.py b/blueye/sdk/cli/commands/bundle_model/__init__.py new file mode 100644 index 00000000..1b1152f9 --- /dev/null +++ b/blueye/sdk/cli/commands/bundle_model/__init__.py @@ -0,0 +1,14 @@ +"""The `blueye bundle-model` command: bundle an ONNX model into a BlueyeCV package.""" + +from __future__ import annotations + +from .. import CommandSpec +from .command import add_parser, run + +COMMAND = CommandSpec( + name="bundle-model", + help="Bundle an ONNX model into a BlueyeCV model-package zip", + requires=("onnx", "rich", "questionary"), + add_parser=add_parser, + run=run, +) diff --git a/blueye/sdk/cli/bundle.py b/blueye/sdk/cli/commands/bundle_model/bundle.py similarity index 100% rename from blueye/sdk/cli/bundle.py rename to blueye/sdk/cli/commands/bundle_model/bundle.py diff --git a/blueye/sdk/cli/bundle_model.py b/blueye/sdk/cli/commands/bundle_model/command.py similarity index 98% rename from blueye/sdk/cli/bundle_model.py rename to blueye/sdk/cli/commands/bundle_model/command.py index 626e6aa2..97e5aeb9 100644 --- a/blueye/sdk/cli/bundle_model.py +++ b/blueye/sdk/cli/commands/bundle_model/command.py @@ -14,6 +14,8 @@ import sys from pathlib import Path +from ...errors import CliError + logger = logging.getLogger(__name__) _DEVICES = ("cpu", "cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1", "coreml") @@ -110,15 +112,11 @@ def _sanitize_name(name: str) -> str: def _parse_input_size(value: str): match = re.fullmatch(r"(\d+)[xX](\d+)", value.strip()) if not match: - from .main import CliError - raise CliError(f'--input-size must look like "640x640", got "{value}"') return int(match.group(1)), int(match.group(2)) def _parse_anchors(value: str) -> list[list[float]]: - from .main import CliError - anchors = [] for pair in value.replace(";", " ").split(): parts = pair.split(",") @@ -129,8 +127,6 @@ def _parse_anchors(value: str) -> list[list[float]]: def _parse_float_list(value: str, flag: str) -> list[float]: - from .main import CliError - try: return [float(part) for part in value.split(",") if part.strip()] except ValueError as error: @@ -138,8 +134,6 @@ def _parse_float_list(value: str, flag: str) -> list[float]: def _read_labels_file(path: Path) -> list[str]: - from .main import CliError - if not path.is_file(): raise CliError(f"Labels file not found: {path}") labels = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()] @@ -148,8 +142,6 @@ def _read_labels_file(path: Path) -> list[str]: def _resolve_labels(args, config, prompter, console) -> list[str]: """Resolve the labels list from flags, embedded metadata, or prompts.""" - from .main import CliError - if args.labels: labels = _read_labels_file(Path(args.labels)) elif config.labels and config.kind != "sot": @@ -229,8 +221,8 @@ def _resolve_runtime(args, dla, prompter): def run(args: argparse.Namespace) -> int: """Run the bundle-model subcommand. Returns the process exit code.""" - from . import bundle, heuristics, introspect, meta, prompts, ui - from .main import CliError + from ... import prompts, ui + from . import bundle, heuristics, introspect, meta console = ui.make_console(quiet=args.quiet) interactive = not args.yes and sys.stdin.isatty() and sys.stdout.isatty() diff --git a/blueye/sdk/cli/heuristics.py b/blueye/sdk/cli/commands/bundle_model/heuristics.py similarity index 100% rename from blueye/sdk/cli/heuristics.py rename to blueye/sdk/cli/commands/bundle_model/heuristics.py diff --git a/blueye/sdk/cli/introspect.py b/blueye/sdk/cli/commands/bundle_model/introspect.py similarity index 100% rename from blueye/sdk/cli/introspect.py rename to blueye/sdk/cli/commands/bundle_model/introspect.py diff --git a/blueye/sdk/cli/meta.py b/blueye/sdk/cli/commands/bundle_model/meta.py similarity index 100% rename from blueye/sdk/cli/meta.py rename to blueye/sdk/cli/commands/bundle_model/meta.py diff --git a/blueye/sdk/cli/commands/tools/__init__.py b/blueye/sdk/cli/commands/tools/__init__.py new file mode 100644 index 00000000..2ec9d9e4 --- /dev/null +++ b/blueye/sdk/cli/commands/tools/__init__.py @@ -0,0 +1,14 @@ +"""The `blueye tools` command: manage third-party CLI tools.""" + +from __future__ import annotations + +from .. import CommandSpec +from .command import add_parser, run + +COMMAND = CommandSpec( + name="tools", + help="List, validate, install, and uninstall third-party CLI tools", + requires=(), # The bootstrap surface must run with zero optional extras. + add_parser=add_parser, + run=run, +) diff --git a/blueye/sdk/cli/commands/tools/command.py b/blueye/sdk/cli/commands/tools/command.py new file mode 100644 index 00000000..49eb5fa6 --- /dev/null +++ b/blueye/sdk/cli/commands/tools/command.py @@ -0,0 +1,224 @@ +"""Implementation of the `blueye tools` subcommands. + +Standard library only — this command is the bootstrap surface for the third-party tool +mechanism, so it must work before (or without) the ``[cli]`` extra. +""" + +from __future__ import annotations + +import argparse +import logging +import shutil +import sys +from pathlib import Path + +from ...errors import CliError +from ...external import discovery, metadata + +logger = logging.getLogger(__name__) + + +def add_parser(subparsers) -> None: + """Register the ``tools`` subcommand and its sub-subcommands.""" + parser = subparsers.add_parser( + "tools", + help="List, validate, install, and uninstall third-party CLI tools", + description=( + "Manage third-party blueye tools: single-file Python scripts with PEP 723 " + "inline metadata, discovered from the tools directory and run as " + "`blueye ...`. See the 'Extending the blueye CLI' documentation " + "for how to write one." + ), + ) + tools_subparsers = parser.add_subparsers(dest="tools_command", metavar="ACTION") + + tools_subparsers.add_parser("list", help="List built-in commands and discovered tools") + + validate = tools_subparsers.add_parser("validate", help="Validate a tool script's metadata") + validate.add_argument("script", help="Path to the tool script (.py)") + + install = tools_subparsers.add_parser( + "install", help="Validate a tool script and copy it into the tools directory" + ) + install.add_argument("script", help="Path to the tool script (.py)") + install.add_argument( + "--force", action="store_true", help="Replace an already-installed tool of the same name" + ) + + uninstall = tools_subparsers.add_parser( + "uninstall", help="Remove an installed tool from the tools directory" + ) + uninstall.add_argument("name", help="The tool name (as shown by `blueye tools list`)") + + tools_subparsers.add_parser("dir", help="Print the resolved tools directory") + + +def run(args: argparse.Namespace) -> int: + """Dispatch the tools sub-subcommand.""" + action = getattr(args, "tools_command", None) + if action == "list": + return _run_list() + if action == "validate": + return _run_validate(Path(args.script).expanduser()) + if action == "install": + return _run_install(Path(args.script).expanduser(), force=args.force) + if action == "uninstall": + return _run_uninstall(args.name) + if action == "dir": + return _run_dir() + print("Usage: blueye tools {list,validate,install,uninstall,dir}", file=sys.stderr) + return 1 + + +def _builtin_names() -> frozenset[str]: + from .. import all_commands + + return frozenset(spec.name for spec in all_commands()) + + +def _run_list() -> int: + """Print built-in commands and every scanned tool, valid or not.""" + from .. import all_commands + + directory = discovery.tools_dir() + print(f"Tools directory: {directory} ({discovery.tools_dir_source()})") + print() + + rows: list[tuple[str, str, str]] = [] + for spec in all_commands(): + rows.append((spec.name, "built-in", spec.help)) + for tool in discovery.scan_tools_dir(_builtin_names()): + if tool.metadata is None: + rows.append((tool.path.name, tool.path.name, f"(invalid metadata: {tool.error})")) + elif tool.shadowed_by is not None: + rows.append((tool.metadata.name, tool.path.name, f"(shadowed by {tool.shadowed_by})")) + else: + rows.append((tool.metadata.name, tool.path.name, tool.metadata.description)) + + name_width = max(len(row[0]) for row in rows) + source_width = max(len(row[1]) for row in rows) + print(f"{'NAME'.ljust(name_width)} {'SOURCE'.ljust(source_width)} DESCRIPTION") + for name, source, description in rows: + print(f"{name.ljust(name_width)} {source.ljust(source_width)} {description}") + return 0 + + +def _run_validate(script: Path) -> int: + """Run every metadata check on a script, printing one line per check.""" + failures = 0 + + def check(label: str, ok: bool, detail: str = "") -> None: + nonlocal failures + status = "ok" if ok else "error" + suffix = f": {detail}" if detail else "" + print(f"{status}: {label}{suffix}") + if not ok: + failures += 1 + + if not script.is_file(): + print(f"error: no such file: {script}") + return 1 + + source = script.read_text(encoding="utf-8", errors="replace") + try: + block = metadata.extract_script_block(source) + except metadata.MetadataError as error: + check("PEP 723 script block", False, str(error)) + return 1 + check("PEP 723 script block", block is not None, "" if block else "no block found") + if block is None: + return 1 + + try: + parsed = metadata.parse_tool_metadata(source) + except metadata.MetadataError as error: + check("[tool.blueye] metadata", False, str(error)) + return 1 + check("[tool.blueye] metadata", True) + check(f"tool name '{parsed.name}'", True) + check("description", True) + if parsed.parsed_with_fallback: + print( + "note: parsed with the limited fallback parser (install the [cli] extra " + "for full TOML support on Python 3.10)" + ) + + collision = parsed.name in _builtin_names() + check( + "no collision with a built-in command", + not collision, + f"'{parsed.name}' is a built-in command" if collision else "", + ) + if parsed.min_sdk_version is not None: + well_formed = all(part.isdigit() for part in parsed.min_sdk_version.split(".")) + check( + f"min-sdk-version '{parsed.min_sdk_version}'", + well_formed, + "" if well_formed else "must be dotted integers (e.g. 2.7.0)", + ) + if parsed.has_dependencies: + print("note: script declares dependencies; it will run via `uv run` when available") + + return 0 if failures == 0 else 1 + + +def _run_install(script: Path, force: bool) -> int: + """Validate a script and copy it into the tools directory as .py.""" + if not script.is_file(): + raise CliError(f"No such file: {script}") + try: + parsed = metadata.parse_tool_metadata(script.read_text(encoding="utf-8")) + except metadata.MetadataError as error: + raise CliError( + f"{script.name} is not a valid blueye tool: {error}. " + "Run `blueye tools validate` for details." + ) from error + if parsed.name in _builtin_names(): + raise CliError(f"'{parsed.name}' collides with a built-in command — rename the tool.") + + directory = discovery.tools_dir() + destination = directory / f"{parsed.name}.py" + if destination.exists() and not force: + raise CliError(f"{destination} already exists (use --force to replace it).") + + directory.mkdir(parents=True, exist_ok=True) + shutil.copy2(script, destination) + print(f"Installed '{parsed.name}' -> {destination}") + print(f"Run it with: blueye {parsed.name}") + return 0 + + +def _run_uninstall(name: str) -> int: + """Remove an installed tool by name.""" + directory = discovery.tools_dir() + target = directory / f"{name}.py" + if not target.is_file(): + # Hand-copied files may have a file name that differs from the tool name. + target = None + for tool in discovery.scan_tools_dir(): + if tool.metadata is not None and tool.metadata.name == name: + target = tool.path + break + if target is None: + installed = sorted( + tool.metadata.name + for tool in discovery.scan_tools_dir() + if tool.metadata is not None + ) + listing = ", ".join(installed) if installed else "none installed" + raise CliError(f"No tool named '{name}' ({listing}).") + + target.unlink() + print(f"Uninstalled '{name}' ({target})") + return 0 + + +def _run_dir() -> int: + """Print the resolved tools directory (stdout is script-friendly).""" + directory = discovery.tools_dir() + print(directory) + annotation = discovery.tools_dir_source() + if not directory.exists(): + annotation += "; does not exist yet" + print(f"({annotation})", file=sys.stderr) + return 0 diff --git a/blueye/sdk/cli/deps.py b/blueye/sdk/cli/deps.py index 4d64a64f..f13bd77f 100644 --- a/blueye/sdk/cli/deps.py +++ b/blueye/sdk/cli/deps.py @@ -12,24 +12,24 @@ import logging import shutil import sys +from typing import Iterable logger = logging.getLogger(__name__) -#: Distributions required by the CLI, in import-name form. -CLI_DEPENDENCIES = ("onnx", "rich", "questionary") - #: Newest CPython minor version the `onnx` project publishes prebuilt wheels for. Kept #: conservative; only used to print a hint, never to block. _NEWEST_PYTHON_WITH_ONNX_WHEELS = (3, 13) -def missing_cli_deps() -> list[str]: - """Return the CLI dependencies that are not importable in this environment. +def missing(names: Iterable[str]) -> list[str]: + """Return the import names in `names` with no importable module. - Returns: - The subset of :data:`CLI_DEPENDENCIES` for which no importable module was found. + Used by `main` to gate each command's declared optional dependencies (its + CommandSpec.requires) before running it. All currently gateable dependencies ship + in the `[cli]` extra; if a future command needs a different extra, the guidance + below needs a name-to-extra map. """ - return [name for name in CLI_DEPENDENCIES if importlib.util.find_spec(name) is None] + return [name for name in names if importlib.util.find_spec(name) is None] def _install_command() -> str: diff --git a/blueye/sdk/cli/errors.py b/blueye/sdk/cli/errors.py new file mode 100644 index 00000000..89205af3 --- /dev/null +++ b/blueye/sdk/cli/errors.py @@ -0,0 +1,11 @@ +"""Shared CLI error types. + +Standard library only, and imports nothing from the package — every CLI module may +depend on this one without creating an import cycle. +""" + +from __future__ import annotations + + +class CliError(Exception): + """A user-facing CLI error: printed as a message, never as a traceback.""" diff --git a/blueye/sdk/cli/external/__init__.py b/blueye/sdk/cli/external/__init__.py new file mode 100644 index 00000000..9ec54c2d --- /dev/null +++ b/blueye/sdk/cli/external/__init__.py @@ -0,0 +1,11 @@ +"""Third-party tool support for the `blueye` CLI. + +Tools are single-file Python scripts carrying PEP 723 inline metadata (a +``# /// script`` comment block) extended with a ``[tool.blueye]`` table. They live in +a per-user tools directory (see :func:`blueye.sdk.cli.external.discovery.tools_dir`), +are discovered by parsing only their metadata (never executing code), and run as +subprocesses. + +Everything in this package is standard-library only, so discovery works before the +optional ``[cli]`` extra is installed. +""" diff --git a/blueye/sdk/cli/external/discovery.py b/blueye/sdk/cli/external/discovery.py new file mode 100644 index 00000000..4d4b9ec6 --- /dev/null +++ b/blueye/sdk/cli/external/discovery.py @@ -0,0 +1,122 @@ +"""Tools-directory resolution and third-party tool discovery.""" + +from __future__ import annotations + +import logging +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +from .metadata import MetadataError, ToolMetadata, parse_tool_metadata + +logger = logging.getLogger(__name__) + +#: Environment variable overriding the tools directory. +TOOLS_DIR_ENV = "BLUEYE_CLI_TOOLS_DIR" + + +def tools_dir() -> Path: + """Resolve the third-party tools directory. + + Precedence: the :data:`TOOLS_DIR_ENV` environment variable, then the platform's + conventional per-user data location. The directory is not created here — only + ``blueye tools install`` creates it. + """ + override = os.environ.get(TOOLS_DIR_ENV) + if override: + return Path(override).expanduser() + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + elif sys.platform.startswith("win"): + appdata = os.environ.get("APPDATA") + base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming" + else: + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share" + return base / "blueye" / "cli-tools" + + +def tools_dir_source() -> str: + """Describe where the resolved tools directory came from (for display).""" + if os.environ.get(TOOLS_DIR_ENV): + return f"from {TOOLS_DIR_ENV}" + return "platform default" + + +@dataclass(frozen=True) +class DiscoveredTool: + """One script found in the tools directory. + + Attributes: + path: The script file. + metadata: The parsed metadata, or None when parsing failed. + error: The parse-failure reason when metadata is None. + shadowed_by: Set when the tool's name is unusable: "built-in command" or the + file name of an earlier tool that claimed the same name. + """ + + path: Path + metadata: ToolMetadata | None = None + error: str | None = None + shadowed_by: str | None = None + + +def scan_tools_dir(builtin_names: frozenset[str] = frozenset()) -> list[DiscoveredTool]: + """Scan the tools directory and parse every candidate script's metadata. + + Returns every ``*.py`` file (non-recursive, sorted by file name) as a + DiscoveredTool, including invalid and shadowed entries — `blueye tools list` shows + them all. Never raises for a missing or empty directory. + + Args: + builtin_names: First-party command names; tools with a colliding name are + marked shadowed (built-ins always win). + """ + directory = tools_dir() + if not directory.is_dir(): + return [] + + tools: list[DiscoveredTool] = [] + claimed: dict[str, str] = {} + for path in sorted(directory.glob("*.py")): + if not path.is_file(): + continue + try: + parsed = parse_tool_metadata(path.read_text(encoding="utf-8")) + except (MetadataError, OSError, UnicodeDecodeError) as error: + logger.debug("Skipping tool %s: %s", path, error) + tools.append(DiscoveredTool(path=path, error=str(error))) + continue + + shadowed_by = None + if parsed.name in builtin_names: + shadowed_by = "built-in command" + elif parsed.name in claimed: + shadowed_by = claimed[parsed.name] + else: + claimed[parsed.name] = path.name + tools.append(DiscoveredTool(path=path, metadata=parsed, shadowed_by=shadowed_by)) + return tools + + +def discover_tools(builtin_names: frozenset[str] = frozenset()) -> dict[str, DiscoveredTool]: + """Return the runnable tools, keyed by name (valid metadata, not shadowed).""" + return { + tool.metadata.name: tool + for tool in scan_tools_dir(builtin_names) + if tool.metadata is not None and tool.shadowed_by is None + } + + +def format_tools_epilog(tools: dict[str, DiscoveredTool]) -> str | None: + """Build the `blueye --help` section listing discovered tools (None when empty).""" + if not tools: + return None + width = max(len(name) for name in tools) + lines = [f"external tools (from {tools_dir()}):"] + for name in sorted(tools): + lines.append(f" {name.ljust(width)} {tools[name].metadata.description}") + lines.append("") + lines.append("Run `blueye tools --help` to manage external tools.") + return "\n".join(lines) diff --git a/blueye/sdk/cli/external/execution.py b/blueye/sdk/cli/external/execution.py new file mode 100644 index 00000000..c93b484f --- /dev/null +++ b/blueye/sdk/cli/external/execution.py @@ -0,0 +1,75 @@ +"""Subprocess execution of third-party tools.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import sys + +from .discovery import DiscoveredTool + +logger = logging.getLogger(__name__) + + +def _sdk_version() -> str | None: + """The installed blueye.sdk version, or None when it cannot be determined.""" + try: + from importlib.metadata import version + + return version("blueye.sdk") + except Exception: # pragma: no cover - depends on installation metadata + return None + + +def _version_tuple(text: str) -> tuple[int, ...] | None: + """Parse a dotted version into an int tuple; None for non-numeric parts.""" + try: + return tuple(int(part) for part in text.split(".")) + except ValueError: + return None + + +def _warn_min_sdk_version(tool: DiscoveredTool) -> None: + """Warn (never block) when the tool requests a newer SDK than installed.""" + wanted = tool.metadata.min_sdk_version + if not wanted: + return + installed = _sdk_version() + wanted_tuple = _version_tuple(wanted) + installed_tuple = _version_tuple(installed) if installed else None + if wanted_tuple and installed_tuple and installed_tuple < wanted_tuple: + print( + f"warning: {tool.metadata.name} requests blueye.sdk >= {wanted} " + f"(installed: {installed}); it may not work correctly.", + file=sys.stderr, + ) + + +def run_tool(tool: DiscoveredTool, args: list[str]) -> int: + """Run a discovered tool as a subprocess and return its exit code. + + Scripts that declare PEP 723 `dependencies` are run with ``uv run`` when uv is + available, giving them an isolated environment with those dependencies; otherwise + the current interpreter runs the script directly (its dependencies may already be + importable here). + + Args: + tool: The tool to run (must have valid metadata). + args: Arguments passed through to the script verbatim. + """ + _warn_min_sdk_version(tool) + + if tool.metadata.has_dependencies and shutil.which("uv") is not None: + command = ["uv", "run", str(tool.path), *args] + else: + if tool.metadata.has_dependencies: + logger.debug( + "Tool %s declares dependencies but uv is not available; running with " + "the current interpreter.", + tool.metadata.name, + ) + command = [sys.executable, str(tool.path), *args] + + logger.debug("Running external tool: %s", command) + return subprocess.run(command, check=False).returncode diff --git a/blueye/sdk/cli/external/metadata.py b/blueye/sdk/cli/external/metadata.py new file mode 100644 index 00000000..7c0c5ba8 --- /dev/null +++ b/blueye/sdk/cli/external/metadata.py @@ -0,0 +1,187 @@ +"""PEP 723 inline-metadata parsing for third-party `blueye` tools. + +A tool script declares itself with the standard PEP 723 block, extended with a +``[tool.blueye]`` table:: + + # /// script + # requires-python = ">=3.10" + # dependencies = ["pandas"] + # + # [tool.blueye] + # name = "export-logs" + # description = "Export dive logs to CSV" + # min-sdk-version = "2.7.0" + # /// + +TOML parsing is tiered: :mod:`tomllib` (Python 3.11+), then :mod:`tomli` when +installed (shipped in the ``[cli]`` extra for Python 3.10), then a minimal regex +fallback so a stdlib-only Python 3.10 environment can still discover tools. The +fallback only understands single-line double-quoted string values inside +``[tool.blueye]`` (arrays and multi-line strings need tomli) — enough for the keys the +CLI reads. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +#: The reference regex from PEP 723 for locating inline metadata blocks. +_PEP723_BLOCK = re.compile( + r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)*)^# ///$" +) + +#: Fallback extraction of the [tool.blueye] section body (up to the next section). +_TOOL_SECTION = re.compile(r"(?ms)^\[tool\.blueye\]\s*$(?P.*?)(?=^\[|\Z)") + +#: Fallback extraction of simple `key = "value"` pairs. +_STRING_KEY = re.compile(r'(?m)^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$') + +#: Presence check for a top-level PEP 723 `dependencies` key (the value itself is +#: never needed — `uv run` re-parses the block). +_HAS_DEPS = re.compile(r"(?m)^dependencies\s*=") + +#: Tool names must be usable as `blueye `: lowercase, digits, hyphens, starting +#: with a letter, at most 32 characters. +NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,31}$") + + +class MetadataError(Exception): + """The script's inline metadata is missing or invalid; the message says why.""" + + +@dataclass(frozen=True) +class ToolMetadata: + """The metadata the CLI reads from a tool script. + + Attributes: + name: The subcommand name the tool is invoked as (``blueye ``). + description: One-line description shown in listings and ``blueye --help``. + min_sdk_version: Optional minimum blueye.sdk version; mismatches warn at + dispatch time but never block execution. + has_dependencies: True when the PEP 723 block declares `dependencies` — + execution then prefers ``uv run`` so the script gets an isolated + environment. + parsed_with_fallback: True when the regex fallback (not a real TOML parser) + produced this metadata. + """ + + name: str + description: str + min_sdk_version: str | None = None + has_dependencies: bool = False + parsed_with_fallback: bool = False + + +def _load_toml() -> object | None: + """Return a TOML parser module (tomllib or tomli), or None when unavailable.""" + try: + import tomllib + + return tomllib + except ModuleNotFoundError: + try: + import tomli + + return tomli + except ModuleNotFoundError: + return None + + +def extract_script_block(source: str) -> str | None: + """Extract the un-commented TOML text of the PEP 723 ``script`` block. + + Args: + source: The tool script's full source text. + + Returns: + The TOML text, or None when no ``script`` block is present. + + Raises: + MetadataError: When more than one ``script`` block exists (invalid per + PEP 723). + """ + blocks = [match for match in _PEP723_BLOCK.finditer(source) if match.group("type") == "script"] + if not blocks: + return None + if len(blocks) > 1: + raise MetadataError("multiple '# /// script' blocks (PEP 723 allows exactly one)") + content = blocks[0].group("content") + lines = [] + for line in content.splitlines(): + lines.append(line[2:] if line.startswith("# ") else line[1:]) + return "\n".join(lines) + "\n" + + +def _parse_with_toml(toml_module, block: str) -> dict | None: + """Parse the block with a real TOML parser; None on syntax errors.""" + try: + return toml_module.loads(block) + except Exception as error: + raise MetadataError(f"invalid TOML in the script block: {error}") from error + + +def _parse_with_fallback(block: str) -> tuple[dict, bool]: + """Extract [tool.blueye] string keys and dependency presence via regex.""" + section = _TOOL_SECTION.search(block) + table = dict(_STRING_KEY.findall(section.group("body"))) if section else {} + return table, bool(_HAS_DEPS.search(block)) + + +def parse_tool_metadata(source: str) -> ToolMetadata: + """Parse a tool script's source into its ToolMetadata. + + Args: + source: The tool script's full source text. + + Returns: + The parsed metadata. + + Raises: + MetadataError: When the block is absent, unparseable, or missing/violating + the required ``[tool.blueye]`` keys. + """ + block = extract_script_block(source) + if block is None: + raise MetadataError("no '# /// script' metadata block found") + + toml_module = _load_toml() + if toml_module is not None: + data = _parse_with_toml(toml_module, block) + table = data.get("tool", {}).get("blueye", {}) + if not isinstance(table, dict): + table = {} + has_dependencies = "dependencies" in data + used_fallback = False + else: + table, has_dependencies = _parse_with_fallback(block) + used_fallback = True + + if not table: + raise MetadataError("no [tool.blueye] table in the script block") + name = table.get("name") + description = table.get("description") + if not name or not isinstance(name, str): + raise MetadataError("[tool.blueye] is missing the required 'name' key") + if not NAME_PATTERN.fullmatch(name): + raise MetadataError( + f"tool name '{name}' is invalid (lowercase letters, digits, and hyphens; " + "must start with a letter; at most 32 characters)" + ) + if not description or not isinstance(description, str): + raise MetadataError("[tool.blueye] is missing the required 'description' key") + + min_sdk_version = table.get("min-sdk-version") + if min_sdk_version is not None and not isinstance(min_sdk_version, str): + raise MetadataError("[tool.blueye] 'min-sdk-version' must be a string") + + return ToolMetadata( + name=name, + description=description, + min_sdk_version=min_sdk_version, + has_dependencies=has_dependencies, + parsed_with_fallback=used_fallback, + ) diff --git a/blueye/sdk/cli/main.py b/blueye/sdk/cli/main.py index 9092a400..ec24fc22 100644 --- a/blueye/sdk/cli/main.py +++ b/blueye/sdk/cli/main.py @@ -2,7 +2,9 @@ Only the standard library may be imported at module level: the umbrella command and `--help` must work (and print install guidance) when the optional `[cli]` extra is not -installed. Subcommand implementations are imported lazily after the dependency gate. +installed. First-party commands come from the registry in +:mod:`blueye.sdk.cli.commands`; third-party tools are discovered from the tools +directory (see :mod:`blueye.sdk.cli.external`) and dispatched before argparse runs. """ from __future__ import annotations @@ -12,29 +14,27 @@ import sys from . import deps +from .commands import CommandSpec, all_commands -logger = logging.getLogger(__name__) - +# Back-compat re-export; the canonical location is blueye.sdk.cli.errors. +from .errors import CliError # noqa: F401 -class CliError(Exception): - """A user-facing CLI error: printed as a message, never as a traceback.""" +logger = logging.getLogger(__name__) -def _build_parser() -> argparse.ArgumentParser: - """Build the root `blueye` parser with all subcommands registered.""" +def _build_parser( + commands: dict[str, CommandSpec], tools_epilog: str | None +) -> argparse.ArgumentParser: + """Build the root `blueye` parser with all first-party commands registered.""" parser = argparse.ArgumentParser( prog="blueye", description="Command line tools for Blueye underwater drones.", + epilog=tools_epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") - - # Subcommand argument definitions live in their own modules, but only argument - # *definitions* — anything importing optional dependencies stays behind the gate in - # main(). bundle_model.add_parser only uses argparse. - from . import bundle_model - - bundle_model.add_parser(subparsers) - + for spec in commands.values(): + spec.add_parser(subparsers) return parser @@ -46,33 +46,47 @@ def main(argv: list[str] | None = None) -> int: Returns: The process exit code (0 success, 1 user-facing error, 2 missing dependencies, - 130 interrupted). + 130 interrupted; third-party tools propagate their own exit code). """ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s") + argv = list(sys.argv[1:] if argv is None else argv) - parser = _build_parser() + commands = {spec.name: spec for spec in all_commands()} + + from .external import discovery, execution + + tools = discovery.discover_tools(frozenset(commands)) + + # Third-party tools bypass argparse entirely: their arguments belong to the tool, + # and argparse's REMAINDER cannot forward leading-dash arguments from a subparser. + # Builtins always win a name collision (checked first). Invocation is strictly + # `blueye args...` — root-level flags before the tool name are not + # supported. + if argv and not argv[0].startswith("-") and argv[0] not in commands and argv[0] in tools: + try: + return execution.run_tool(tools[argv[0]], argv[1:]) + except KeyboardInterrupt: + print("\nCancelled.", file=sys.stderr) + return 130 + + parser = _build_parser(commands, discovery.format_tools_epilog(tools)) args = parser.parse_args(argv) if args.command is None: parser.print_help() return 0 - missing = deps.missing_cli_deps() + spec = commands[args.command] + missing = deps.missing(spec.requires) if missing: deps.print_install_guidance(missing) return 2 try: - if args.command == "bundle-model": - from . import bundle_model - - return bundle_model.run(args) + return spec.run(args) except CliError as error: print(f"Error: {error}", file=sys.stderr) return 1 except KeyboardInterrupt: print("\nCancelled.", file=sys.stderr) return 130 - - parser.print_help() - return 0 diff --git a/blueye/sdk/cli/prompts.py b/blueye/sdk/cli/prompts.py index 265ffbfb..7715f36f 100644 --- a/blueye/sdk/cli/prompts.py +++ b/blueye/sdk/cli/prompts.py @@ -12,7 +12,7 @@ import questionary -from .main import CliError +from .errors import CliError logger = logging.getLogger(__name__) diff --git a/docs/extending-the-cli.md b/docs/extending-the-cli.md new file mode 100644 index 00000000..2503a13b --- /dev/null +++ b/docs/extending-the-cli.md @@ -0,0 +1,117 @@ +# Extending the blueye CLI + +The `blueye` command is built to grow. There are two ways to add commands: + +- **Built-in commands** live in the SDK itself, under `blueye/sdk/cli/commands/`, and + ship with every release — this is how `bundle-model` and `tools` are implemented. +- **Third-party tools** are single-file Python scripts that anyone can drop into a + per-user tools directory. The CLI discovers them automatically, lists them in + `blueye --help`, and runs them as `blueye ...` — no SDK changes needed. + +## Writing a third-party tool + +A tool is a normal Python script carrying [PEP 723](https://peps.python.org/pep-0723/) +inline metadata, extended with a `[tool.blueye]` table: + +```python +# /// script +# requires-python = ">=3.10" +# dependencies = ["pandas"] +# +# [tool.blueye] +# name = "export-logs" +# description = "Export dive logs to CSV" +# min-sdk-version = "2.7.0" +# /// +import sys + +import pandas as pd + + +def main() -> int: + print(f"exporting with args: {sys.argv[1:]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +The `[tool.blueye]` keys: + +| Key | Required | Meaning | +| ----------------- | -------- | -------------------------------------------------------------- | +| `name` | yes | The subcommand name (`blueye export-logs`). Lowercase letters, digits, and hyphens; must start with a letter; at most 32 characters. | +| `description` | yes | One line shown in `blueye --help` and `blueye tools list`. | +| `min-sdk-version` | no | Minimum blueye.sdk version; a mismatch prints a warning but never blocks. | + +Arguments after the tool name are passed to the script verbatim, and its exit code +becomes the CLI's exit code. Invocation is strictly `blueye args...`. + +**Dependencies**: when the script declares PEP 723 `dependencies` and +[uv](https://docs.astral.sh/uv/) is installed, the CLI runs it with `uv run`, giving +the script an isolated environment with those dependencies — your tool can use pandas +without pandas ever being installed next to the SDK. Without uv, the script runs with +the current interpreter and must find its dependencies there. + +## Installing and managing tools + +```shell +blueye tools validate my_script.py # check the metadata before installing +blueye tools install my_script.py # copy it into the tools directory +blueye tools list # built-ins + installed tools +blueye tools uninstall export-logs +blueye tools dir # print the resolved tools directory +``` + +Discovery scans the tools directory on every invocation and parses **only the +metadata block — tool code is never executed during discovery** or listing. + +The directory is resolved from the `BLUEYE_CLI_TOOLS_DIR` environment variable when +set, otherwise from the platform default: + +| Platform | Default tools directory | +| -------- | ---------------------------------------------------- | +| macOS | `~/Library/Application Support/blueye/cli-tools` | +| Linux | `$XDG_DATA_HOME/blueye/cli-tools` (or `~/.local/share/blueye/cli-tools`) | +| Windows | `%APPDATA%\blueye\cli-tools` | + +Name collisions always resolve in favor of built-in commands; `blueye tools list` +shows shadowed or invalid tools with the reason. + +## Adding a built-in command (SDK contributors) + +Each built-in command is a self-contained package under `blueye/sdk/cli/commands/` +exposing a `COMMAND` spec: + +```python +# blueye/sdk/cli/commands/my_command/__init__.py +from .. import CommandSpec +from .command import add_parser, run + +COMMAND = CommandSpec( + name="my-command", + help="One line shown in `blueye --help`", + requires=("rich",), # optional deps gated before run(); () if stdlib-only + add_parser=add_parser, # argparse-only argument definitions + run=run, # returns the exit code; heavy imports go inside +) +``` + +Register it in `all_commands()` in `blueye/sdk/cli/commands/__init__.py` — that is the +only central change. The invariants: + +- The command package must be importable with **zero optional extras** installed + (`blueye --help` runs before the `[cli]` extra exists). Import onnx/rich/questionary + inside `run`, never at module level. +- Declare optional imports in `requires`; the CLI prints install guidance and exits + with code 2 when they are missing. +- Raise `blueye.sdk.cli.errors.CliError` for user-facing failures — it is printed as a + clean message, never a traceback. + +## Future work: pip-installable plugins + +A third route is planned but not yet implemented: packages registering a +`CommandSpec` under a `blueye.cli` [entry-point group](https://packaging.python.org/en/latest/specifications/entry-points/), +so `pip install blueye-tool-x` would add a subcommand. The `CommandSpec` contract +above is designed to be that plugin interface unchanged. diff --git a/docs/reference/blueye/sdk/cli.md b/docs/reference/blueye/sdk/cli.md index 9c72230f..c5e42f6e 100644 --- a/docs/reference/blueye/sdk/cli.md +++ b/docs/reference/blueye/sdk/cli.md @@ -1,7 +1,15 @@ -::: blueye.sdk.cli.meta +::: blueye.sdk.cli.commands -::: blueye.sdk.cli.heuristics +::: blueye.sdk.cli.commands.bundle_model.meta -::: blueye.sdk.cli.introspect +::: blueye.sdk.cli.commands.bundle_model.heuristics -::: blueye.sdk.cli.bundle +::: blueye.sdk.cli.commands.bundle_model.introspect + +::: blueye.sdk.cli.commands.bundle_model.bundle + +::: blueye.sdk.cli.external.metadata + +::: blueye.sdk.cli.external.discovery + +::: blueye.sdk.cli.external.execution diff --git a/mkdocs.yml b/mkdocs.yml index 1f7b1e91..e644727f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - "Forwarding positioning to NMEA": "nmea-publisher.md" - "Mission Planning": "mission-planning.md" - "Bundling CV models": "bundling-cv-models.md" + - "Extending the blueye CLI": "extending-the-cli.md" - "Odometer forwarding": odometer-to-831l.md - "Updating from v1 to v2": "migrating-to-v2.md" - "HTTP API": "http-api.md" diff --git a/pyproject.toml b/pyproject.toml index c07e6e35..e5173fec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ cli = [ "onnx>=1.16,<2", "rich>=13,<15", "questionary>=2.0,<3", + # TOML parser for reading third-party tools' PEP 723 metadata; stdlib from 3.11. + "tomli>=2,<3; python_version < '3.11'", ] # These are dependencies that are not necessary for the core functionality of the SDK, but are # necessary for some of the examples. @@ -58,6 +60,7 @@ dev = [ "onnx>=1.16,<2", "rich>=13,<15", "questionary>=2.0,<3", + "tomli>=2,<3; python_version < '3.11'", "pytest~=8.3", "pytest-mock~=3.11", "mike~=2.1", diff --git a/tests/test_cli_bundle.py b/tests/test_cli_bundle.py index 40ad4141..a9e7e3b7 100644 --- a/tests/test_cli_bundle.py +++ b/tests/test_cli_bundle.py @@ -3,7 +3,7 @@ import pytest -from blueye.sdk.cli.bundle import BundleError, bundle_size, write_bundle +from blueye.sdk.cli.commands.bundle_model.bundle import BundleError, bundle_size, write_bundle META = {"format_version": 1, "model_file": "model.onnx", "labels": ["x"]} diff --git a/tests/test_cli_heuristics.py b/tests/test_cli_heuristics.py index 9192d553..0c53304b 100644 --- a/tests/test_cli_heuristics.py +++ b/tests/test_cli_heuristics.py @@ -2,13 +2,13 @@ import pytest -from blueye.sdk.cli.heuristics import ( +from blueye.sdk.cli.commands.bundle_model.heuristics import ( UnsupportedModelError, assess_dla_fitness, infer, parse_ultralytics_metadata, ) -from blueye.sdk.cli.introspect import FLOAT16, FLOAT32, ModelInfo, TensorSpec +from blueye.sdk.cli.commands.bundle_model.introspect import FLOAT16, FLOAT32, ModelInfo, TensorSpec def make_info(inputs, outputs, metadata=None, op_histogram=None): diff --git a/tests/test_cli_introspect.py b/tests/test_cli_introspect.py index 37ec0200..6b2a3a5a 100644 --- a/tests/test_cli_introspect.py +++ b/tests/test_cli_introspect.py @@ -4,7 +4,7 @@ import onnx.helper import pytest -from blueye.sdk.cli.introspect import ( +from blueye.sdk.cli.commands.bundle_model.introspect import ( FLOAT16, FLOAT32, IntrospectionError, diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 8ed187f0..437d87ed 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -6,7 +6,8 @@ import pytest from blueye.sdk.cli import main as cli_main_module -from blueye.sdk.cli.main import CliError, main +from blueye.sdk.cli.errors import CliError +from blueye.sdk.cli.main import main class FakePrompter: @@ -77,7 +78,7 @@ def test_no_command_prints_help(capsys): def test_missing_deps_prints_guidance(mocker, capsys): - mocker.patch("blueye.sdk.cli.deps.missing_cli_deps", return_value=["onnx"]) + mocker.patch("blueye.sdk.cli.deps.missing", return_value=["onnx"]) exit_code = main(["bundle-model", "whatever.onnx"]) assert exit_code == 2 output = capsys.readouterr().out @@ -233,5 +234,5 @@ def test_labels_file_flag(yolov8_model, fake_prompter, tmp_path): def test_cli_module_importable_without_optional_deps(mocker): """The dependency gate must run before any optional import.""" # Simulate the extra being missing; parsing + gate must still work. - mocker.patch("blueye.sdk.cli.deps.missing_cli_deps", return_value=["rich", "questionary"]) + mocker.patch("blueye.sdk.cli.deps.missing", return_value=["rich", "questionary"]) assert main(["bundle-model", "x.onnx"]) == 2 diff --git a/tests/test_cli_meta.py b/tests/test_cli_meta.py index 560a40d4..c389042f 100644 --- a/tests/test_cli_meta.py +++ b/tests/test_cli_meta.py @@ -1,4 +1,4 @@ -from blueye.sdk.cli.meta import ( +from blueye.sdk.cli.commands.bundle_model.meta import ( IMAGENET_MEAN, IMAGENET_STD, SCALE_1_OVER_255, diff --git a/tests/test_cli_prompts.py b/tests/test_cli_prompts.py index 58be1ab7..e317f400 100644 --- a/tests/test_cli_prompts.py +++ b/tests/test_cli_prompts.py @@ -1,6 +1,6 @@ import pytest -from blueye.sdk.cli.main import CliError +from blueye.sdk.cli.errors import CliError from blueye.sdk.cli.prompts import NonInteractivePrompter @@ -40,7 +40,7 @@ def test_missing_deps_detection(self, mocker): find_spec = mocker.patch("importlib.util.find_spec") find_spec.side_effect = lambda name: None if name == "onnx" else object() - assert deps.missing_cli_deps() == ["onnx"] + assert deps.missing(("onnx", "rich", "questionary")) == ["onnx"] def test_guidance_prefers_uv_when_available(self, mocker, capsys): from blueye.sdk.cli import deps diff --git a/tests/test_cli_tool_discovery.py b/tests/test_cli_tool_discovery.py new file mode 100644 index 00000000..0018e262 --- /dev/null +++ b/tests/test_cli_tool_discovery.py @@ -0,0 +1,116 @@ +from pathlib import Path + +import pytest + +from blueye.sdk.cli.external import discovery + + +def tool_source(name: str, description: str = "A tool") -> str: + return ( + "# /// script\n" + "# [tool.blueye]\n" + f'# name = "{name}"\n' + f'# description = "{description}"\n' + "# ///\n" + "print('hi')\n" + ) + + +@pytest.fixture +def tools_dir(tmp_path, monkeypatch): + directory = tmp_path / "cli-tools" + directory.mkdir() + monkeypatch.setenv(discovery.TOOLS_DIR_ENV, str(directory)) + return directory + + +class TestToolsDirResolution: + def test_env_var_wins(self, monkeypatch, tmp_path): + monkeypatch.setenv(discovery.TOOLS_DIR_ENV, str(tmp_path / "custom")) + assert discovery.tools_dir() == tmp_path / "custom" + assert discovery.TOOLS_DIR_ENV in discovery.tools_dir_source() + + def test_macos_default(self, monkeypatch): + monkeypatch.delenv(discovery.TOOLS_DIR_ENV, raising=False) + monkeypatch.setattr("sys.platform", "darwin") + expected = Path.home() / "Library" / "Application Support" / "blueye" / "cli-tools" + assert discovery.tools_dir() == expected + + def test_linux_xdg_default(self, monkeypatch, tmp_path): + monkeypatch.delenv(discovery.TOOLS_DIR_ENV, raising=False) + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert discovery.tools_dir() == tmp_path / "xdg" / "blueye" / "cli-tools" + + def test_linux_without_xdg(self, monkeypatch): + monkeypatch.delenv(discovery.TOOLS_DIR_ENV, raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr("sys.platform", "linux") + expected = Path.home() / ".local" / "share" / "blueye" / "cli-tools" + assert discovery.tools_dir() == expected + + def test_windows_appdata(self, monkeypatch, tmp_path): + monkeypatch.delenv(discovery.TOOLS_DIR_ENV, raising=False) + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData")) + assert discovery.tools_dir() == tmp_path / "AppData" / "blueye" / "cli-tools" + + +class TestScanning: + def test_missing_dir_is_empty(self, monkeypatch, tmp_path): + monkeypatch.setenv(discovery.TOOLS_DIR_ENV, str(tmp_path / "nope")) + assert discovery.scan_tools_dir() == [] + assert discovery.discover_tools() == {} + + def test_valid_tool_discovered(self, tools_dir): + (tools_dir / "export.py").write_text(tool_source("export-logs")) + tools = discovery.discover_tools() + assert set(tools) == {"export-logs"} + assert tools["export-logs"].path.name == "export.py" + + def test_invalid_tool_excluded_but_scanned(self, tools_dir): + (tools_dir / "broken.py").write_text("print('no metadata')\n") + assert discovery.discover_tools() == {} + scanned = discovery.scan_tools_dir() + assert len(scanned) == 1 + assert scanned[0].metadata is None + assert "script" in scanned[0].error + + def test_non_py_files_ignored(self, tools_dir): + (tools_dir / "readme.txt").write_text("hello") + (tools_dir / "tool.sh").write_text("#!/bin/sh") + assert discovery.scan_tools_dir() == [] + + def test_duplicate_names_first_file_wins(self, tools_dir): + (tools_dir / "a.py").write_text(tool_source("dupe")) + (tools_dir / "b.py").write_text(tool_source("dupe")) + tools = discovery.discover_tools() + assert tools["dupe"].path.name == "a.py" + shadowed = [tool for tool in discovery.scan_tools_dir() if tool.shadowed_by] + assert len(shadowed) == 1 + assert shadowed[0].shadowed_by == "a.py" + + def test_builtin_collision_shadowed(self, tools_dir): + (tools_dir / "sneaky.py").write_text(tool_source("tools")) + assert discovery.discover_tools(frozenset({"tools"})) == {} + scanned = discovery.scan_tools_dir(frozenset({"tools"})) + assert scanned[0].shadowed_by == "built-in command" + + def test_symlinked_script_followed(self, tools_dir, tmp_path): + real = tmp_path / "real_tool.py" + real.write_text(tool_source("linked")) + (tools_dir / "linked.py").symlink_to(real) + assert "linked" in discovery.discover_tools() + + +class TestEpilog: + def test_empty_tools_no_epilog(self): + assert discovery.format_tools_epilog({}) is None + + def test_epilog_lists_tools(self, tools_dir): + (tools_dir / "a.py").write_text(tool_source("a-tool", "Does A")) + (tools_dir / "b.py").write_text(tool_source("b-tool", "Does B")) + epilog = discovery.format_tools_epilog(discovery.discover_tools()) + assert "a-tool" in epilog and "Does A" in epilog + assert "b-tool" in epilog and "Does B" in epilog + assert str(discovery.tools_dir()) in epilog diff --git a/tests/test_cli_tool_execution.py b/tests/test_cli_tool_execution.py new file mode 100644 index 00000000..552ef7c8 --- /dev/null +++ b/tests/test_cli_tool_execution.py @@ -0,0 +1,106 @@ +import json +import sys + +import pytest + +from blueye.sdk.cli.external import discovery, execution +from blueye.sdk.cli.main import main + + +@pytest.fixture +def tools_dir(tmp_path, monkeypatch): + directory = tmp_path / "cli-tools" + directory.mkdir() + monkeypatch.setenv(discovery.TOOLS_DIR_ENV, str(directory)) + return directory + + +def write_tool(tools_dir, name, body, dependencies=False): + deps_line = '# dependencies = ["nonexistent-package"]\n' if dependencies else "" + (tools_dir / f"{name}.py").write_text( + "# /// script\n" + + deps_line + + "#\n# [tool.blueye]\n" + + f'# name = "{name}"\n' + + f'# description = "Test tool {name}"\n' + + "# ///\n" + + body + ) + + +class TestDispatch: + def test_args_passed_verbatim(self, tools_dir, tmp_path): + """The REMAINDER regression guard: leading-dash args reach the tool.""" + out_file = tmp_path / "argv.json" + write_tool( + tools_dir, + "dump-args", + f"import json, sys\nopen({str(out_file)!r}, 'w').write(json.dumps(sys.argv[1:]))\n", + ) + exit_code = main(["dump-args", "--flag", "x", "-v", "positional"]) + assert exit_code == 0 + assert json.loads(out_file.read_text()) == ["--flag", "x", "-v", "positional"] + + def test_exit_code_propagated(self, tools_dir): + write_tool(tools_dir, "fail-tool", "import sys\nsys.exit(7)\n") + assert main(["fail-tool"]) == 7 + + def test_builtin_wins_name_collision(self, tools_dir, tmp_path): + marker = tmp_path / "ran.txt" + write_tool(tools_dir, "tools", f"open({str(marker)!r}, 'w').write('ran')\n") + # `blueye tools list` must run the built-in, not the tool. + assert main(["tools", "list"]) == 0 + assert not marker.exists() + + def test_unknown_name_still_errors(self, tools_dir, capsys): + with pytest.raises(SystemExit) as excinfo: + main(["no-such-command"]) + assert excinfo.value.code == 2 # argparse's invalid-choice exit + + def test_tool_shown_in_help_epilog(self, tools_dir, capsys): + write_tool(tools_dir, "depth-log", "pass\n") + main([]) + out = capsys.readouterr().out + assert "external tools" in out + assert "depth-log" in out + assert "Test tool depth-log" in out + + +class TestUvPreference: + def _tool(self, tools_dir, dependencies): + write_tool(tools_dir, "uv-tool", "pass\n", dependencies=dependencies) + return discovery.discover_tools()["uv-tool"] + + def test_uv_used_for_scripts_with_dependencies(self, tools_dir, mocker): + tool = self._tool(tools_dir, dependencies=True) + mocker.patch("shutil.which", return_value="/usr/bin/uv") + run = mocker.patch("subprocess.run", return_value=mocker.Mock(returncode=0)) + assert execution.run_tool(tool, ["-x"]) == 0 + assert run.call_args[0][0] == ["uv", "run", str(tool.path), "-x"] + + def test_interpreter_used_without_uv(self, tools_dir, mocker): + tool = self._tool(tools_dir, dependencies=True) + mocker.patch("shutil.which", return_value=None) + run = mocker.patch("subprocess.run", return_value=mocker.Mock(returncode=0)) + execution.run_tool(tool, []) + assert run.call_args[0][0] == [sys.executable, str(tool.path)] + + def test_interpreter_used_without_dependencies(self, tools_dir, mocker): + tool = self._tool(tools_dir, dependencies=False) + mocker.patch("shutil.which", return_value="/usr/bin/uv") + run = mocker.patch("subprocess.run", return_value=mocker.Mock(returncode=0)) + execution.run_tool(tool, []) + assert run.call_args[0][0] == [sys.executable, str(tool.path)] + + +class TestMinSdkVersionWarning: + def test_newer_requirement_warns_but_runs(self, tools_dir, mocker, capsys): + (tools_dir / "future.py").write_text( + "# /// script\n# [tool.blueye]\n" + '# name = "future-tool"\n# description = "Needs the future"\n' + '# min-sdk-version = "999.0.0"\n# ///\npass\n' + ) + tool = discovery.discover_tools()["future-tool"] + mocker.patch("subprocess.run", return_value=mocker.Mock(returncode=0)) + assert execution.run_tool(tool, []) == 0 + assert "999.0.0" in capsys.readouterr().err diff --git a/tests/test_cli_tool_metadata.py b/tests/test_cli_tool_metadata.py new file mode 100644 index 00000000..07b8f758 --- /dev/null +++ b/tests/test_cli_tool_metadata.py @@ -0,0 +1,118 @@ +import pytest + +from blueye.sdk.cli.external import metadata +from blueye.sdk.cli.external.metadata import ( + MetadataError, + extract_script_block, + parse_tool_metadata, +) + +VALID_SCRIPT = """\ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pandas"] +# +# [tool.blueye] +# name = "export-logs" +# description = "Export dive logs to CSV" +# min-sdk-version = "2.7.0" +# /// +import sys +""" + +NO_DEPS_SCRIPT = """\ +# /// script +# [tool.blueye] +# name = "net-check" +# description = "Ping the drone" +# /// +""" + + +class TestExtractScriptBlock: + def test_block_extracted_and_uncommented(self): + block = extract_script_block(VALID_SCRIPT) + assert 'name = "export-logs"' in block + assert "# " not in block.splitlines()[0] + + def test_no_block_returns_none(self): + assert extract_script_block("import sys\n") is None + + def test_bare_hash_lines_handled(self): + script = NO_DEPS_SCRIPT.replace("# [tool.blueye]", "#\n# [tool.blueye]") + assert "[tool.blueye]" in extract_script_block(script) + + def test_multiple_script_blocks_rejected(self): + with pytest.raises(MetadataError, match="multiple"): + extract_script_block(NO_DEPS_SCRIPT + "\n" + NO_DEPS_SCRIPT) + + def test_other_block_types_ignored(self): + script = NO_DEPS_SCRIPT.replace("# /// script", "# /// other", 1) + assert extract_script_block(script) is None + + +class TestParseToolMetadata: + def test_full_toml_path(self): + parsed = parse_tool_metadata(VALID_SCRIPT) + assert parsed.name == "export-logs" + assert parsed.description == "Export dive logs to CSV" + assert parsed.min_sdk_version == "2.7.0" + assert parsed.has_dependencies is True + assert parsed.parsed_with_fallback is False + + def test_no_dependencies_detected(self): + assert parse_tool_metadata(NO_DEPS_SCRIPT).has_dependencies is False + + def test_missing_block_raises(self): + with pytest.raises(MetadataError, match="no '# /// script'"): + parse_tool_metadata("print('hi')\n") + + def test_missing_tool_blueye_table_raises(self): + script = '# /// script\n# requires-python = ">=3.10"\n# ///\n' + with pytest.raises(MetadataError, match=r"\[tool\.blueye\]"): + parse_tool_metadata(script) + + def test_missing_name_raises(self): + script = NO_DEPS_SCRIPT.replace('# name = "net-check"\n', "") + with pytest.raises(MetadataError, match="'name'"): + parse_tool_metadata(script) + + def test_missing_description_raises(self): + script = NO_DEPS_SCRIPT.replace('# description = "Ping the drone"\n', "") + with pytest.raises(MetadataError, match="'description'"): + parse_tool_metadata(script) + + @pytest.mark.parametrize( + "bad_name", ["UPPER", "1starts-with-digit", "-leading-dash", "has space", "a" * 33] + ) + def test_invalid_names_rejected(self, bad_name): + script = NO_DEPS_SCRIPT.replace("net-check", bad_name) + with pytest.raises(MetadataError, match="invalid"): + parse_tool_metadata(script) + + def test_invalid_toml_raises(self): + script = NO_DEPS_SCRIPT.replace('"Ping the drone"', '"unclosed') + with pytest.raises(MetadataError): + parse_tool_metadata(script) + + +class TestFallbackParser: + @pytest.fixture(autouse=True) + def force_fallback(self, mocker): + mocker.patch.object(metadata, "_load_toml", return_value=None) + + def test_fallback_parses_string_keys(self): + parsed = parse_tool_metadata(VALID_SCRIPT) + assert parsed.name == "export-logs" + assert parsed.description == "Export dive logs to CSV" + assert parsed.min_sdk_version == "2.7.0" + assert parsed.parsed_with_fallback is True + + def test_fallback_detects_dependencies(self): + assert parse_tool_metadata(VALID_SCRIPT).has_dependencies is True + assert parse_tool_metadata(NO_DEPS_SCRIPT).has_dependencies is False + + def test_fallback_missing_table_raises(self): + script = '# /// script\n# requires-python = ">=3.10"\n# ///\n' + with pytest.raises(MetadataError, match=r"\[tool\.blueye\]"): + parse_tool_metadata(script) diff --git a/tests/test_cli_tools_command.py b/tests/test_cli_tools_command.py new file mode 100644 index 00000000..bbfc86da --- /dev/null +++ b/tests/test_cli_tools_command.py @@ -0,0 +1,141 @@ +import pytest + +from blueye.sdk.cli.external import discovery +from blueye.sdk.cli.main import main + +VALID_TOOL = ( + "# /// script\n" + "# [tool.blueye]\n" + '# name = "depth-log"\n' + '# description = "Export depth telemetry"\n' + "# ///\n" + "print('hi')\n" +) + + +@pytest.fixture +def tools_dir(tmp_path, monkeypatch): + directory = tmp_path / "cli-tools" + monkeypatch.setenv(discovery.TOOLS_DIR_ENV, str(directory)) + return directory + + +@pytest.fixture +def tool_script(tmp_path): + script = tmp_path / "my_script.py" + script.write_text(VALID_TOOL) + return script + + +class TestList: + def test_lists_builtins_and_dir(self, tools_dir, capsys): + assert main(["tools", "list"]) == 0 + out = capsys.readouterr().out + assert "bundle-model" in out + assert "built-in" in out + assert str(tools_dir) in out + assert discovery.TOOLS_DIR_ENV in out + + def test_lists_installed_and_invalid_tools(self, tools_dir, capsys): + tools_dir.mkdir() + (tools_dir / "good.py").write_text(VALID_TOOL) + (tools_dir / "bad.py").write_text("no metadata\n") + assert main(["tools", "list"]) == 0 + out = capsys.readouterr().out + assert "depth-log" in out and "Export depth telemetry" in out + assert "bad.py" in out and "invalid metadata" in out + + +class TestValidate: + def test_valid_script_passes(self, tool_script, capsys): + assert main(["tools", "validate", str(tool_script)]) == 0 + out = capsys.readouterr().out + assert "ok: PEP 723 script block" in out + assert "error:" not in out + + def test_missing_metadata_fails(self, tmp_path, capsys): + script = tmp_path / "bad.py" + script.write_text("print('hi')\n") + assert main(["tools", "validate", str(script)]) == 1 + assert "error:" in capsys.readouterr().out + + def test_builtin_collision_fails(self, tmp_path, capsys): + script = tmp_path / "sneaky.py" + script.write_text(VALID_TOOL.replace("depth-log", "tools")) + assert main(["tools", "validate", str(script)]) == 1 + assert "built-in" in capsys.readouterr().out + + def test_missing_file(self, tmp_path, capsys): + assert main(["tools", "validate", str(tmp_path / "nope.py")]) == 1 + assert "no such file" in capsys.readouterr().out + + +class TestInstall: + def test_install_copies_under_tool_name(self, tools_dir, tool_script, capsys): + assert main(["tools", "install", str(tool_script)]) == 0 + assert (tools_dir / "depth-log.py").read_text() == VALID_TOOL + out = capsys.readouterr().out + assert "Installed 'depth-log'" in out + assert "blueye depth-log" in out + + def test_install_refuses_overwrite_without_force(self, tools_dir, tool_script, capsys): + assert main(["tools", "install", str(tool_script)]) == 0 + assert main(["tools", "install", str(tool_script)]) == 1 + assert "--force" in capsys.readouterr().err + + def test_install_force_overwrites(self, tools_dir, tool_script): + assert main(["tools", "install", str(tool_script)]) == 0 + tool_script.write_text(VALID_TOOL.replace("Export depth telemetry", "v2")) + assert main(["tools", "install", str(tool_script), "--force"]) == 0 + assert "v2" in (tools_dir / "depth-log.py").read_text() + + def test_install_rejects_invalid_script(self, tools_dir, tmp_path, capsys): + script = tmp_path / "bad.py" + script.write_text("nope\n") + assert main(["tools", "install", str(script)]) == 1 + assert "not a valid blueye tool" in capsys.readouterr().err + + def test_install_rejects_builtin_name(self, tools_dir, tmp_path, capsys): + script = tmp_path / "sneaky.py" + script.write_text(VALID_TOOL.replace("depth-log", "bundle-model")) + assert main(["tools", "install", str(script)]) == 1 + assert "built-in" in capsys.readouterr().err + + +class TestUninstall: + def test_uninstall_by_name(self, tools_dir, tool_script): + main(["tools", "install", str(tool_script)]) + assert main(["tools", "uninstall", "depth-log"]) == 0 + assert not (tools_dir / "depth-log.py").exists() + + def test_uninstall_hand_copied_file_by_metadata_name(self, tools_dir): + tools_dir.mkdir() + (tools_dir / "renamed_by_hand.py").write_text(VALID_TOOL) + assert main(["tools", "uninstall", "depth-log"]) == 0 + assert not (tools_dir / "renamed_by_hand.py").exists() + + def test_uninstall_unknown_lists_installed(self, tools_dir, tool_script, capsys): + main(["tools", "install", str(tool_script)]) + assert main(["tools", "uninstall", "nope"]) == 1 + err = capsys.readouterr().err + assert "depth-log" in err + + +class TestDir: + def test_prints_resolved_dir(self, tools_dir, capsys): + assert main(["tools", "dir"]) == 0 + captured = capsys.readouterr() + assert captured.out.strip() == str(tools_dir) + assert "does not exist yet" in captured.err + + +class TestPerCommandGate: + def test_tools_needs_no_extras(self, tools_dir, mocker): + """`tools` must run even when the [cli] extra is reported missing.""" + + def fake_missing(names): + return [name for name in ("onnx", "rich", "questionary") if name in names] + + mocker.patch("blueye.sdk.cli.deps.missing", side_effect=fake_missing) + assert main(["tools", "list"]) == 0 + assert main(["bundle-model", "x.onnx"]) == 2 diff --git a/uv.lock b/uv.lock index f016dd35..460fcb65 100644 --- a/uv.lock +++ b/uv.lock @@ -136,6 +136,7 @@ cli = [ { name = "onnx" }, { name = "questionary" }, { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] examples = [ { name = "asciimatics" }, @@ -169,6 +170,7 @@ dev = [ { name = "questionary" }, { name = "requests-mock" }, { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -190,6 +192,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.22.0,<3" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13,<15" }, { name = "tabulate", specifier = ">=0.9,<0.10" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'cli'", specifier = ">=2,<3" }, { name = "webdavclient3", marker = "extra == 'examples'", specifier = ">=3.14.6,<4" }, ] provides-extras = ["cli", "examples"] @@ -215,6 +218,7 @@ dev = [ { name = "questionary", specifier = ">=2.0,<3" }, { name = "requests-mock", specifier = "~=1.11" }, { name = "rich", specifier = ">=13,<15" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2,<3" }, ] [[package]] From 9171cb76a1ca670a95d47bb48b65282f614aaecc Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Thu, 9 Jul 2026 11:20:57 +0200 Subject: [PATCH 05/17] fix: address review feedback on bundle-model input handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven review findings, verified and fixed: - _parse_anchors and the --runtime-hz flag/custom-rate prompt let ValueError escape as a traceback for non-numeric input; they now raise CliError with a message naming the offending value. - bundle_size() ran before write_bundle()'s external-data validation, so a missing weight file surfaced as an uncaught FileNotFoundError; the checks moved into a shared _validate_external_files() that both entry points call. - build_meta() defaulted empty labels to ["tracked"] for every kind of package — a 1-class detection model without labels would validate with a nonsense label. Detection packages now keep an empty list so validation fails with the direct message; only SOT packages default. - Overwrite semantics: non-interactive runs (--yes or no TTY) now require an explicit --force to overwrite an existing zip (erroring with the flag named); the interactive confirm defaults to No. - The install guidance on Windows suggested single quotes but printed a double-quoted uv command; the quoting now matches the platform. +7 regression tests (376 total). Co-Authored-By: Claude Fable 5 --- .../sdk/cli/commands/bundle_model/bundle.py | 41 ++++++++++------ .../sdk/cli/commands/bundle_model/command.py | 30 +++++++++--- blueye/sdk/cli/commands/bundle_model/meta.py | 9 +++- blueye/sdk/cli/deps.py | 13 ++--- tests/test_cli_bundle.py | 7 +++ tests/test_cli_main.py | 48 +++++++++++++++++++ tests/test_cli_meta.py | 10 ++++ tests/test_cli_prompts.py | 9 ++++ 8 files changed, 136 insertions(+), 31 deletions(-) diff --git a/blueye/sdk/cli/commands/bundle_model/bundle.py b/blueye/sdk/cli/commands/bundle_model/bundle.py index ee830c96..0bc4609c 100644 --- a/blueye/sdk/cli/commands/bundle_model/bundle.py +++ b/blueye/sdk/cli/commands/bundle_model/bundle.py @@ -50,13 +50,38 @@ def bundle_size(onnx_path: Path, external_files: list[str]) -> int: Args: onnx_path: Path to the .onnx file. external_files: External-data file names living next to the .onnx. + + Raises: + BundleError: When an external file is missing or otherwise unusable — sizing + runs before :func:`write_bundle`, so it validates too instead of leaking a + FileNotFoundError. """ + _validate_external_files(onnx_path, external_files) total = onnx_path.stat().st_size for name in external_files: total += (onnx_path.parent / name).stat().st_size return total +def _validate_external_files(onnx_path: Path, external_files: list[str]) -> None: + """Check every external-data reference is bundleable; raise BundleError if not.""" + for name in external_files: + if "/" in name or "\\" in name: + raise BundleError( + f"External data location '{name}' contains a path separator. Re-save the " + "model with all tensors in one file next to it, e.g.:\n" + " onnx.save(onnx.load(p), out, save_as_external_data=True,\n" + " all_tensors_to_one_file=True, location='model.onnx_data')" + ) + if name in (MODEL_FILE_NAME, META_FILE_NAME): + raise BundleError(f"External data file '{name}' collides with a reserved bundle name.") + if not (onnx_path.parent / name).is_file(): + raise BundleError( + f"The model references external data file '{name}', but it was not found " + f"next to {onnx_path.name}. Copy it into {onnx_path.parent} first." + ) + + def write_bundle( meta: dict, onnx_path: Path, @@ -82,21 +107,7 @@ def write_bundle( """ progress = progress or (lambda _byte_count: None) - for name in external_files: - if "/" in name or "\\" in name: - raise BundleError( - f"External data location '{name}' contains a path separator. Re-save the " - "model with all tensors in one file next to it, e.g.:\n" - " onnx.save(onnx.load(p), out, save_as_external_data=True,\n" - " all_tensors_to_one_file=True, location='model.onnx_data')" - ) - if name in (MODEL_FILE_NAME, META_FILE_NAME): - raise BundleError(f"External data file '{name}' collides with a reserved bundle name.") - if not (onnx_path.parent / name).is_file(): - raise BundleError( - f"The model references external data file '{name}', but it was not found " - f"next to {onnx_path.name}. Copy it into {onnx_path.parent} first." - ) + _validate_external_files(onnx_path, external_files) output_path.parent.mkdir(parents=True, exist_ok=True) partial_path = output_path.with_suffix(output_path.suffix + ".part") diff --git a/blueye/sdk/cli/commands/bundle_model/command.py b/blueye/sdk/cli/commands/bundle_model/command.py index 97e5aeb9..aa09c994 100644 --- a/blueye/sdk/cli/commands/bundle_model/command.py +++ b/blueye/sdk/cli/commands/bundle_model/command.py @@ -122,10 +122,22 @@ def _parse_anchors(value: str) -> list[list[float]]: parts = pair.split(",") if len(parts) != 2: raise CliError(f'--anchors pairs must be "w,h", got "{pair}"') - anchors.append([float(parts[0]), float(parts[1])]) + try: + anchors.append([float(parts[0]), float(parts[1])]) + except ValueError as error: + raise CliError(f'--anchors values must be numbers, got "{pair}"') from error return anchors +def _parse_hz(value: str, flag: str) -> float: + try: + return float(value) + except ValueError as error: + raise CliError( + f'{flag} must be a number (or "max" for unlimited), got "{value}"' + ) from error + + def _parse_float_list(value: str, flag: str) -> list[float]: try: return [float(part) for part in value.split(",") if part.strip()] @@ -196,7 +208,9 @@ def _resolve_runtime(args, dla, prompter): device = answer.replace(" (recommended)", "") if args.runtime_hz: - hz = None if args.runtime_hz.lower() == "max" else float(args.runtime_hz) + hz = ( + None if args.runtime_hz.lower() == "max" else _parse_hz(args.runtime_hz, "--runtime-hz") + ) else: answer = prompter.select( "Maximum inference rate?", list(_RATE_PRESETS), _RATE_PRESETS[0], "--runtime-hz" @@ -204,9 +218,9 @@ def _resolve_runtime(args, dla, prompter): if answer.startswith("max"): hz = None elif answer == "custom...": - hz = float(prompter.text("Rate in Hz:", "10", "--runtime-hz")) + hz = _parse_hz(prompter.text("Rate in Hz:", "10", "--runtime-hz"), "the rate") else: - hz = float(answer) + hz = _parse_hz(answer, "--runtime-hz") if args.runtime_enabled is not None: enabled = args.runtime_enabled @@ -386,10 +400,12 @@ def run(args: argparse.Namespace) -> int: output_path = Path( args.output or prompter.text("Output zip path:", default_output, "--output") ).expanduser() + # Overwriting is destructive: non-interactive runs (--yes / no TTY) must opt + # in explicitly with --force; interactively the confirm defaults to No. if output_path.exists() and not args.force: - if not prompter.confirm( - f"{output_path} exists — overwrite?", bool(args.yes), "--force" - ): + if not interactive: + raise CliError(f"{output_path} already exists (pass --force to overwrite it).") + if not prompter.confirm(f"{output_path} exists — overwrite?", False, "--force"): raise CliError(f"{output_path} already exists (use --force to overwrite).") external_files = list(info.external_data_files) diff --git a/blueye/sdk/cli/commands/bundle_model/meta.py b/blueye/sdk/cli/commands/bundle_model/meta.py index 26e9fe1c..c269bb95 100644 --- a/blueye/sdk/cli/commands/bundle_model/meta.py +++ b/blueye/sdk/cli/commands/bundle_model/meta.py @@ -186,7 +186,14 @@ def build_meta(options: MetaOptions) -> dict: runtime["hz"] = options.runtime_hz meta["runtime"] = runtime - meta["labels"] = options.labels if options.labels else ["tracked"] + # Only SOT packages default their label; a detection model without labels must + # fail validation loudly instead of shipping a nonsense ["tracked"] label list. + if options.labels: + meta["labels"] = options.labels + elif options.kind == "sot": + meta["labels"] = ["tracked"] + else: + meta["labels"] = [] return meta diff --git a/blueye/sdk/cli/deps.py b/blueye/sdk/cli/deps.py index f13bd77f..c510fdd6 100644 --- a/blueye/sdk/cli/deps.py +++ b/blueye/sdk/cli/deps.py @@ -35,14 +35,11 @@ def missing(names: Iterable[str]) -> list[str]: def _install_command() -> str: """Return the install command best matching the user's environment.""" package_spec = "blueye.sdk[cli]" - on_windows = sys.platform.startswith("win") - if shutil.which("uv") is not None: - return f'uv pip install "{package_spec}"' - if on_windows: - # cmd.exe needs no quotes; PowerShell treats brackets specially, so single quotes - # are the safe recommendation. - return f"python -m pip install '{package_spec}'" - return f'python -m pip install "{package_spec}"' + # cmd.exe needs no quotes but PowerShell treats brackets specially, so single + # quotes are the safe recommendation on Windows; POSIX shells prefer double quotes. + quote = "'" if sys.platform.startswith("win") else '"' + installer = "uv pip install" if shutil.which("uv") is not None else "python -m pip install" + return f"{installer} {quote}{package_spec}{quote}" def print_install_guidance(missing: list[str]) -> None: diff --git a/tests/test_cli_bundle.py b/tests/test_cli_bundle.py index a9e7e3b7..a2eed466 100644 --- a/tests/test_cli_bundle.py +++ b/tests/test_cli_bundle.py @@ -75,3 +75,10 @@ def test_progress_reports_all_bytes(onnx_file, tmp_path): progress=seen.append, ) assert sum(seen) == bundle_size(onnx_file, ["model.onnx_data"]) + + +def test_bundle_size_raises_for_missing_external_file(onnx_file): + # bundle_size runs before write_bundle's checks; it must not leak a + # FileNotFoundError past the BundleError handler. + with pytest.raises(BundleError, match="not found"): + bundle_size(onnx_file, ["model.onnx_data"]) diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 437d87ed..5fe42f10 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -236,3 +236,51 @@ def test_cli_module_importable_without_optional_deps(mocker): # Simulate the extra being missing; parsing + gate must still work. mocker.patch("blueye.sdk.cli.deps.missing", return_value=["rich", "questionary"]) assert main(["bundle-model", "x.onnx"]) == 2 + + +class TestReviewRegressions: + """Regression tests for the PR review findings: user input errors must exit + cleanly, never with a traceback.""" + + def test_bad_anchor_values_error_cleanly(self, yolov8_model, fake_prompter, capsys): + # --anchors is only consumed on the yolov2_grid path, so force the format. + exit_code = main( + [ + "bundle-model", + str(yolov8_model), + "--yes", + "--format", + "yolov2_grid", + "--grid-size", + "13", + "--anchors", + "1.0,abc", + "--dry-run", + ] + ) + assert exit_code == 1 + assert "numbers" in capsys.readouterr().err + + def test_bad_runtime_hz_flag_errors_cleanly(self, yolov8_model, fake_prompter, capsys): + exit_code = main( + ["bundle-model", str(yolov8_model), "--yes", "--runtime-hz", "fast", "--dry-run"] + ) + assert exit_code == 1 + assert "must be a number" in capsys.readouterr().err + + def test_bad_custom_rate_answer_errors_cleanly(self, yolov8_model, fake_prompter, capsys): + fake_prompter.answers["Maximum inference rate?"] = "custom..." + fake_prompter.answers["Rate in Hz"] = "warp-speed" + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "--dry-run"]) + assert exit_code == 1 + assert "must be a number" in capsys.readouterr().err + + def test_yes_without_force_does_not_overwrite( + self, yolov8_model, fake_prompter, tmp_path, capsys + ): + output = tmp_path / "bundle.zip" + output.write_bytes(b"existing") + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "-o", str(output)]) + assert exit_code == 1 + assert output.read_bytes() == b"existing" + assert "--force" in capsys.readouterr().err diff --git a/tests/test_cli_meta.py b/tests/test_cli_meta.py index c389042f..0d5710f0 100644 --- a/tests/test_cli_meta.py +++ b/tests/test_cli_meta.py @@ -165,3 +165,13 @@ def test_sot_sizes_must_be_positive(self): ) meta = build_meta(options) assert any("template_size" in error for error in validate_meta(meta)) + + +class TestDetectionLabelsNotDefaulted: + def test_detection_without_labels_fails_validation(self): + # A detection model must never silently receive the SOT ["tracked"] default: + # for a 1-class model that would VALIDATE with a nonsense label. + options = detection_options(num_classes=1, labels=[]) + meta = build_meta(options) + assert meta["labels"] == [] + assert any("labels" in error for error in validate_meta(meta)) diff --git a/tests/test_cli_prompts.py b/tests/test_cli_prompts.py index e317f400..40767f98 100644 --- a/tests/test_cli_prompts.py +++ b/tests/test_cli_prompts.py @@ -63,3 +63,12 @@ def test_guidance_mentions_powershell_on_windows(self, mocker, capsys): mocker.patch("sys.platform", "win32") deps.print_install_guidance(["onnx"]) assert "PowerShell" in capsys.readouterr().out + + def test_guidance_uses_single_quotes_with_uv_on_windows(self, mocker, capsys): + from blueye.sdk.cli import deps + + mocker.patch("shutil.which", return_value="C:\\uv.exe") + mocker.patch("sys.platform", "win32") + deps.print_install_guidance(["onnx"]) + out = capsys.readouterr().out + assert "uv pip install 'blueye.sdk[cli]'" in out From 64571bb52166a22ca66457790840c8347a156603 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 12:21:58 +0200 Subject: [PATCH 06/17] feat: drone.cv_models SDK feature, --push in bundle-model, and `blueye models` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the drone's CV-model management HTTP API (http:///api/cv-models, served by the onboard backend) in three layers: - SDK: new blueye/sdk/cv_models.py with drone.cv_models — list, upload (multipart 'file' field), delete, download, set_enabled, set_device, set_hz, warmup, rescan, returning CvModel dataclasses. Non-2xx responses raise HTTPError with the drone's text/plain reason included (raise_for_status would discard it). Plain HTTP, independent of the control connection: works on Drone(auto_connect=False) and takes no control of the drone. - bundle-model: new --push and --drone-ip (default 192.168.1.101) flags; interactive runs offer the upload after the zip is written (default No). An unreachable drone or a server rejection fails gracefully with the bundle kept on disk and the path in the message; exit code 1 so scripts notice. - New `blueye models` built-in (requires rich+questionary, no onnx — the per-command gate at work): list (table incl. runtime device/hz/enabled), enable/disable, set-device, set-hz (choices match the server's accepted values), warmup (600s, engine-build note), delete (confirm or --force), upload, download, rescan — and an interactive management loop when invoked bare on a terminal (pick model -> toggle/device/rate/warmup/delete, refreshed each round). Docs and help note that `enabled` is the autolaunch configuration; the API has no live running status. Docs: the bundling page's deploy section now covers --push, `blueye models`, the drone.cv_models snippet, and the Blunux Web App (Computer Vision tab) as the manual alternative; new blueye.sdk.cv_models reference page + nav entry. +42 tests (SDK client incl. multipart/error-body assertions, models subcommands, interactive loop via the prompter seam, push flow incl. graceful failure), 418 total. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/__init__.py | 3 +- .../sdk/cli/commands/bundle_model/command.py | 57 +++- blueye/sdk/cli/commands/models/__init__.py | 14 + blueye/sdk/cli/commands/models/command.py | 282 ++++++++++++++++++ blueye/sdk/cv_models.py | 245 +++++++++++++++ blueye/sdk/drone.py | 2 + docs/bundling-cv-models.md | 38 ++- docs/reference/blueye/sdk/cv_models.md | 1 + mkdocs.yml | 1 + tests/test_cli_main.py | 84 ++++++ tests/test_cli_models_command.py | 161 ++++++++++ tests/test_cv_models.py | 166 +++++++++++ 12 files changed, 1046 insertions(+), 8 deletions(-) create mode 100644 blueye/sdk/cli/commands/models/__init__.py create mode 100644 blueye/sdk/cli/commands/models/command.py create mode 100644 blueye/sdk/cv_models.py create mode 100644 docs/reference/blueye/sdk/cv_models.md create mode 100644 tests/test_cli_models_command.py create mode 100644 tests/test_cv_models.py diff --git a/blueye/sdk/cli/commands/__init__.py b/blueye/sdk/cli/commands/__init__.py index f0dab97e..48634e3e 100644 --- a/blueye/sdk/cli/commands/__init__.py +++ b/blueye/sdk/cli/commands/__init__.py @@ -52,6 +52,7 @@ class CommandSpec: def all_commands() -> tuple[CommandSpec, ...]: """Return every built-in command, in the order shown in ``blueye --help``.""" from .bundle_model import COMMAND as bundle_model_command + from .models import COMMAND as models_command from .tools import COMMAND as tools_command - return (bundle_model_command, tools_command) + return (bundle_model_command, models_command, tools_command) diff --git a/blueye/sdk/cli/commands/bundle_model/command.py b/blueye/sdk/cli/commands/bundle_model/command.py index aa09c994..d2a64eb0 100644 --- a/blueye/sdk/cli/commands/bundle_model/command.py +++ b/blueye/sdk/cli/commands/bundle_model/command.py @@ -93,6 +93,14 @@ def add_parser(subparsers) -> None: parser.add_argument( "-y", "--yes", action="store_true", help="Accept all inferred defaults, no prompts" ) + parser.add_argument( + "--push", action="store_true", help="Upload the bundle to the drone after writing it" + ) + parser.add_argument( + "--drone-ip", + default="192.168.1.101", + help="Drone address used by --push (default: %(default)s)", + ) parser.add_argument("--force", action="store_true", help="Overwrite an existing zip") parser.add_argument( "--dry-run", action="store_true", help="Print the generated model_meta.json and stop" @@ -233,6 +241,38 @@ def _resolve_runtime(args, dla, prompter): return device, hz, enabled +def _push_to_drone(console, zip_path: Path, ip: str) -> None: + """Upload the bundle to the drone, translating failures into CliErrors.""" + import requests + + from blueye.sdk import Drone + + drone = Drone(ip=ip, auto_connect=False) # HTTP only; takes no control of the drone. + try: + with console.status(f"[cyan]Uploading to the drone at {ip}..."): + model = drone.cv_models.upload(zip_path) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as error: + raise CliError( + f"Could not reach the drone at {ip} — is it connected? The bundle was written " + f"to {zip_path}; connect to the drone and re-run with --push, use " + "`blueye models upload`, or upload it in the drone's web UI." + ) from error + except requests.exceptions.HTTPError as error: + raise CliError( + f"The drone rejected the package: {error}. The bundle was written to {zip_path}." + ) from error + + state = "enabled" if model.enabled else "disabled" + console.print( + f"[green bold]Uploaded to the drone:[/green bold] '{model.name}' installed as " + f"'{model.directory}' (autolaunch {state})" + ) + console.print( + f"[dim]Tip: `blueye models warmup {model.directory}` pre-builds the inference " + "engine so the first launch is instant.[/dim]" + ) + + def run(args: argparse.Namespace) -> int: """Run the bundle-model subcommand. Returns the process exit code.""" from ... import prompts, ui @@ -425,10 +465,21 @@ def run(args: argparse.Namespace) -> int: console.print() console.print(f"[green bold]Bundle written:[/green bold] {output_path} ({size_mb:.1f} MB)") console.print(f"[dim]Contents: {contents}[/dim]") - console.print( - "[dim]Deploy: unzip into a directory on the drone and run " - "`be-cv --input `[/dim]" + + # Stage 6 — optionally push the package to the drone. + push = args.push or ( + interactive + and prompter.confirm( + f"Upload the package to the drone at {args.drone_ip} now?", False, "--push" + ) ) + if push: + _push_to_drone(console, output_path, args.drone_ip) + else: + console.print( + f"[dim]Deploy: re-run with --push (drone at {args.drone_ip}), use " + "`blueye models upload`, or upload the zip in the drone's web UI.[/dim]" + ) return 0 except (introspect.IntrospectionError, heuristics.UnsupportedModelError) as error: diff --git a/blueye/sdk/cli/commands/models/__init__.py b/blueye/sdk/cli/commands/models/__init__.py new file mode 100644 index 00000000..67c95aa9 --- /dev/null +++ b/blueye/sdk/cli/commands/models/__init__.py @@ -0,0 +1,14 @@ +"""The `blueye models` command: manage the CV models installed on the drone.""" + +from __future__ import annotations + +from .. import CommandSpec +from .command import add_parser, run + +COMMAND = CommandSpec( + name="models", + help="Manage the CV model packages installed on the drone", + requires=("rich", "questionary"), + add_parser=add_parser, + run=run, +) diff --git a/blueye/sdk/cli/commands/models/command.py b/blueye/sdk/cli/commands/models/command.py new file mode 100644 index 00000000..67707629 --- /dev/null +++ b/blueye/sdk/cli/commands/models/command.py @@ -0,0 +1,282 @@ +"""Implementation of the `blueye models` subcommands. + +Thin CLI wrappers over :class:`blueye.sdk.cv_models.CvModels`, plus an interactive +management loop when invoked bare on a terminal. Model identifiers are the package +directory slugs shown by `blueye models list`. The `enabled` state is the autolaunch +configuration — the drone's API does not expose a live "running" status. + +Argument definitions are stdlib-only; rich/questionary/blueye.sdk imports happen +inside `run` (after the dependency gate). +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +from ...errors import CliError + +logger = logging.getLogger(__name__) + +_DEVICES = ("cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1") +_HZ_CHOICES = (0, 5, 10, 15) + + +def add_parser(subparsers) -> None: + """Register the ``models`` subcommand and its sub-subcommands.""" + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--drone-ip", default="192.168.1.101", help="Drone address (default: %(default)s)" + ) + common.add_argument("--timeout", type=float, default=5.0, help="Request timeout in seconds") + + parser = subparsers.add_parser( + "models", + parents=[common], + help="Manage the CV model packages installed on the drone", + description=( + "List, configure, and manage the CV model packages on the drone. Run without " + "an action on a terminal for an interactive management session. Model names " + "are the directory slugs shown by `blueye models list`; the enabled state is " + "the autolaunch configuration." + ), + ) + actions = parser.add_subparsers(dest="models_command", metavar="ACTION") + + actions.add_parser("list", parents=[common], help="List the models on the drone") + + enable = actions.add_parser("enable", parents=[common], help="Enable a model's autolaunch") + enable.add_argument("name", help="The model's directory slug") + disable = actions.add_parser("disable", parents=[common], help="Disable a model's autolaunch") + disable.add_argument("name", help="The model's directory slug") + + set_device = actions.add_parser( + "set-device", parents=[common], help="Set the execution provider a model runs on" + ) + set_device.add_argument("name", help="The model's directory slug") + set_device.add_argument("device", choices=list(_DEVICES)) + + set_hz = actions.add_parser( + "set-hz", parents=[common], help="Set a model's maximum inference rate" + ) + set_hz.add_argument("name", help="The model's directory slug") + set_hz.add_argument( + "hz", type=int, choices=list(_HZ_CHOICES), help="Rate in Hz (0 = unlimited)" + ) + + warmup = actions.add_parser( + "warmup", + parents=[common], + help="Pre-build a model's inference engine (TensorRT builds take minutes)", + ) + warmup.add_argument("name", help="The model's directory slug") + + delete = actions.add_parser("delete", parents=[common], help="Delete a model from the drone") + delete.add_argument("name", help="The model's directory slug") + delete.add_argument("--force", action="store_true", help="Do not ask for confirmation") + + upload = actions.add_parser( + "upload", parents=[common], help="Upload a model package zip to the drone" + ) + upload.add_argument("package", help="Path to the package zip") + + download = actions.add_parser( + "download", parents=[common], help="Download a model package from the drone" + ) + download.add_argument("name", help="The model's directory slug") + download.add_argument("-o", "--output", help="Destination file or directory") + + actions.add_parser( + "rescan", parents=[common], help="Ask the drone's vision pipeline to rescan the packages" + ) + + +def _cv_models(args): + """Build the CvModels client for the requested drone (HTTP only, no control).""" + from blueye.sdk import Drone + + return Drone(ip=args.drone_ip, auto_connect=False).cv_models + + +def _friendly_errors(action): + """Run an action, translating transport/API failures into CliErrors.""" + import requests + + try: + return action() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as error: + raise CliError( + "Could not reach the drone — is it connected? (Use --drone-ip if it is not " + "at the default address.)" + ) from error + except requests.exceptions.HTTPError as error: + raise CliError(str(error)) from error + + +def _runtime_field(model, key: str, default: str = "-") -> str: + value = model.raw.get("runtime", {}).get(key) + return str(value) if value is not None else default + + +def _print_models_table(console, models) -> None: + from rich.table import Table + + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + for column in ("NAME", "DIRECTORY", "TYPE", "FORMAT", "SIZE", "ENABLED", "DEVICE", "HZ"): + table.add_column(column) + for model in models: + size_mb = model.size_bytes / (1024 * 1024) + hz = _runtime_field(model, "hz") + table.add_row( + model.name, + model.directory, + model.type, + model.output_format, + f"{size_mb:.1f} MB", + "[green]yes[/green]" if model.enabled else "[dim]no[/dim]", + _runtime_field(model, "device"), + "max" if hz == "0" else hz, + ) + console.print(table) + + +def _run_list(console, args) -> int: + models = _friendly_errors(lambda: _cv_models(args).list(timeout=args.timeout)) + if not models: + console.print("No CV models installed on the drone.") + return 0 + _print_models_table(console, models) + return 0 + + +def _run_interactive(console, args, prompter) -> int: + """Interactive management loop: pick a model, pick an action, repeat.""" + cv_models = _cv_models(args) + while True: + models = _friendly_errors(lambda: cv_models.list(timeout=args.timeout)) + if not models: + console.print("No CV models installed on the drone.") + return 0 + _print_models_table(console, models) + + by_label = {} + for model in models: + state = "enabled" if model.enabled else "disabled" + device = _runtime_field(model, "device") + by_label[f"{model.directory} ({state}, {device})"] = model + quit_label = "Quit" + choice = prompter.select("Select a model:", [*by_label, quit_label], quit_label, "ACTION") + if choice == quit_label: + return 0 + model = by_label[choice] + + toggle = f"{'Disable' if model.enabled else 'Enable'} autolaunch" + action = prompter.select( + f"Action for '{model.directory}':", + [toggle, "Set device", "Set rate", "Warm up", "Delete", "Back"], + "Back", + "ACTION", + ) + if action == toggle: + _friendly_errors( + lambda: cv_models.set_enabled( + model.directory, not model.enabled, timeout=args.timeout + ) + ) + elif action == "Set device": + device = prompter.select( + "Execution device:", list(_DEVICES), _runtime_field(model, "device"), "ACTION" + ) + _friendly_errors( + lambda: cv_models.set_device(model.directory, device, timeout=args.timeout) + ) + elif action == "Set rate": + rate = prompter.select( + "Maximum rate:", + [("max (0)" if hz == 0 else str(hz)) for hz in _HZ_CHOICES], + "max (0)", + "ACTION", + ) + hz = 0 if rate.startswith("max") else int(rate) + _friendly_errors(lambda: cv_models.set_hz(model.directory, hz, timeout=args.timeout)) + elif action == "Warm up": + with console.status( + f"[cyan]Warming up '{model.directory}' (TensorRT builds can take minutes)..." + ): + _friendly_errors(lambda: cv_models.warmup(model.directory)) + elif action == "Delete": + if prompter.confirm(f"Delete '{model.directory}' from the drone?", False, "--force"): + _friendly_errors(lambda: cv_models.delete(model.directory, timeout=args.timeout)) + + +def run(args: argparse.Namespace) -> int: + """Dispatch the models sub-subcommand.""" + from ... import prompts, ui + + console = ui.make_console() + action = getattr(args, "models_command", None) + + if action is None: + if sys.stdin.isatty() and sys.stdout.isatty(): + try: + return _run_interactive(console, args, prompts.QuestionaryPrompter()) + except prompts.PromptAborted: + console.print("[yellow]Cancelled.[/yellow]") + return 130 + return _run_list(console, args) + + if action == "list": + return _run_list(console, args) + + cv_models = _cv_models(args) + if action == "enable": + _friendly_errors(lambda: cv_models.set_enabled(args.name, True, timeout=args.timeout)) + console.print(f"Enabled autolaunch for '{args.name}'.") + elif action == "disable": + _friendly_errors(lambda: cv_models.set_enabled(args.name, False, timeout=args.timeout)) + console.print(f"Disabled autolaunch for '{args.name}'.") + elif action == "set-device": + _friendly_errors(lambda: cv_models.set_device(args.name, args.device, timeout=args.timeout)) + console.print(f"'{args.name}' now runs on {args.device}.") + elif action == "set-hz": + _friendly_errors(lambda: cv_models.set_hz(args.name, args.hz, timeout=args.timeout)) + rate = "unlimited" if args.hz == 0 else f"{args.hz} Hz" + console.print(f"'{args.name}' rate set to {rate}.") + elif action == "warmup": + with console.status( + f"[cyan]Warming up '{args.name}' (TensorRT builds can take minutes)..." + ): + _friendly_errors(lambda: cv_models.warmup(args.name)) + console.print(f"Warmup of '{args.name}' complete.") + elif action == "delete": + if not args.force: + from ... import prompts + + interactive = sys.stdin.isatty() and sys.stdout.isatty() + if not interactive: + raise CliError(f"Deleting '{args.name}' requires --force when not interactive.") + if not prompts.QuestionaryPrompter().confirm( + f"Delete '{args.name}' from the drone?", False, "--force" + ): + return 1 + _friendly_errors(lambda: cv_models.delete(args.name, timeout=args.timeout)) + console.print(f"Deleted '{args.name}' from the drone.") + elif action == "upload": + package = Path(args.package).expanduser() + if not package.is_file(): + raise CliError(f"No such file: {package}") + with console.status("[cyan]Uploading to the drone..."): + model = _friendly_errors(lambda: cv_models.upload(package)) + state = "enabled" if model.enabled else "disabled" + console.print(f"Uploaded '{model.name}' as '{model.directory}' (autolaunch {state}).") + elif action == "download": + output = Path(args.output).expanduser() if args.output else None + with console.status("[cyan]Downloading from the drone..."): + path = _friendly_errors(lambda: cv_models.download(args.name, output_path=output)) + console.print(f"Downloaded '{args.name}' to {path}.") + elif action == "rescan": + _friendly_errors(lambda: cv_models.rescan(timeout=args.timeout)) + console.print("Rescan triggered.") + return 0 diff --git a/blueye/sdk/cv_models.py b/blueye/sdk/cv_models.py new file mode 100644 index 00000000..53f5351c --- /dev/null +++ b/blueye/sdk/cv_models.py @@ -0,0 +1,245 @@ +"""Manage the computer vision model packages installed on the drone. + +Drones with an onboard GPU run CV model packages (an ONNX model plus a +`model_meta.json`, see the "Bundling CV models" documentation). This module wraps the +drone's HTTP API for managing those packages: listing, uploading, deleting, +downloading, configuring (autolaunch/device/rate), and pre-building inference engines. + +The API is plain HTTP and independent of the drone's control connection, so these +methods work on a `Drone(auto_connect=False)` instance as well — no control over the +drone is taken. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +import requests + +if TYPE_CHECKING: + import blueye.sdk + +logger = logging.getLogger(__name__) + +#: Execution providers accepted by the drone's `set_device` endpoint. +VALID_DEVICES = ("cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1") + +#: Inference rates (Hz) accepted by the drone's `set_hz` endpoint; 0 means unlimited. +VALID_HZ = (0, 5, 10, 15) + + +@dataclass(frozen=True) +class CvModel: + """One CV model package installed on the drone. + + Attributes: + name: The human-readable model name from its model_meta.json. + directory: The package's directory slug on the drone — this is the identifier + the other methods take as `name`. + type: "detection", "sot", or "unknown". + output_format: The decoder format (e.g. "yolov8_flat"). + size_bytes: Size of the model weights file. + labels: Class labels. + enabled: Whether the model autolaunches on the drone (runtime.enabled). + raw: The verbatim API entry, including the optional preprocessing/detection/ + sot/tracking/runtime blocks when present. + """ + + name: str + directory: str + type: str + output_format: str + size_bytes: int + labels: list[str] + enabled: bool + raw: dict = field(repr=False) + + @classmethod + def from_json(cls, entry: dict) -> CvModel: + """Build a CvModel from one entry of the drone's API response.""" + return cls( + name=entry.get("name", ""), + directory=entry.get("directory", ""), + type=entry.get("type", "unknown"), + output_format=entry.get("output_format", ""), + size_bytes=int(entry.get("size_bytes", 0)), + labels=list(entry.get("labels", [])), + enabled=bool(entry.get("enabled", False)), + raw=entry, + ) + + +class CvModels: + """CV model package management on the drone. + + Accessed through `drone.cv_models`, e.g.:: + + drone = blueye.sdk.Drone(auto_connect=False) + for model in drone.cv_models.list(): + print(model.name, model.enabled) + + All methods raise `requests.exceptions.ConnectionError`/`ConnectTimeout` when the + drone is unreachable, and `requests.exceptions.HTTPError` (with the drone's error + message included) when the drone rejects a request. + """ + + def __init__(self, parent_drone: "blueye.sdk.Drone"): + self._parent_drone = parent_drone + + @property + def _base_url(self) -> str: + return f"http://{self._parent_drone._ip}/api/cv-models" + + @staticmethod + def _check(response: requests.Response) -> requests.Response: + """Raise HTTPError for non-2xx responses, keeping the drone's reason. + + The drone's API returns its error reasons as text/plain bodies, which a bare + `raise_for_status()` would discard. + """ + if not response.ok: + detail = response.text.strip() + message = f"{response.status_code} error for {response.url}" + if detail: + message += f": {detail}" + raise requests.exceptions.HTTPError(message, response=response) + return response + + def list(self, timeout: float = 5) -> list[CvModel]: + """List the model packages installed on the drone. + + Args: + timeout: Request timeout in seconds. + + Returns: + One CvModel per installed package, sorted by directory name. + """ + response = self._check(requests.get(f"{self._base_url}/", timeout=timeout)) + return [CvModel.from_json(entry) for entry in response.json()] + + def upload(self, package: Path | str, timeout: float = 60) -> CvModel: + """Upload a model package zip to the drone. + + The drone validates the archive (it must contain a valid model_meta.json and + the model file it references) and installs it into a directory named after + the slugified model name — an existing package with the same slug is + replaced. + + Args: + package: Path to the package zip (e.g. produced by + `blueye bundle-model`). + timeout: Request timeout in seconds; model files can be large. + + Returns: + The installed model as reported by the drone. + """ + package = Path(package) + with package.open("rb") as file_handle: + response = requests.post( + f"{self._base_url}/upload", files={"file": file_handle}, timeout=timeout + ) + return CvModel.from_json(self._check(response).json()) + + def delete(self, name: str, timeout: float = 5) -> None: + """Delete a model package from the drone. + + Args: + name: The package's directory slug (see CvModel.directory). + timeout: Request timeout in seconds. + """ + self._check(requests.delete(f"{self._base_url}/{name}", timeout=timeout)) + + def download( + self, name: str, output_path: Path | str | None = None, timeout: float = 60 + ) -> Path: + """Download a model package from the drone as a zip. + + Args: + name: The package's directory slug. + output_path: Destination file or directory. Defaults to the filename the + drone suggests (or `.zip`) in the current directory. + timeout: Request timeout in seconds. + + Returns: + The path the zip was written to. + """ + response = self._check(requests.get(f"{self._base_url}/{name}/download", timeout=timeout)) + disposition = response.headers.get("Content-Disposition", "") + matches = re.findall('filename="(.+)"', disposition) + filename = matches[0] if matches else f"{name}.zip" + + if output_path is None: + output_path = Path(filename) + else: + output_path = Path(output_path) + if output_path.is_dir(): + output_path = output_path / filename + output_path.write_bytes(response.content) + return output_path + + def set_enabled(self, name: str, enabled: bool, timeout: float = 5) -> None: + """Enable or disable a model's autolaunch on the drone. + + Args: + name: The package's directory slug. + enabled: True to autolaunch the model, False to disable it. + timeout: Request timeout in seconds. + """ + self._check( + requests.patch( + f"{self._base_url}/{name}/enabled", json={"enabled": enabled}, timeout=timeout + ) + ) + + def set_device(self, name: str, device: str, timeout: float = 5) -> None: + """Set the execution provider a model runs on. + + Args: + name: The package's directory slug. + device: One of :data:`VALID_DEVICES` ("cuda", "tensorrt", + "tensorrt-dla0", "tensorrt-dla1"). + timeout: Request timeout in seconds. + """ + self._check( + requests.patch( + f"{self._base_url}/{name}/device", json={"device": device}, timeout=timeout + ) + ) + + def set_hz(self, name: str, hz: int, timeout: float = 5) -> None: + """Set a model's maximum inference rate. + + Args: + name: The package's directory slug. + hz: One of :data:`VALID_HZ` (0, 5, 10, 15); 0 means unlimited. + timeout: Request timeout in seconds. + """ + self._check(requests.patch(f"{self._base_url}/{name}/hz", json={"hz": hz}, timeout=timeout)) + + def warmup(self, name: str, timeout: float = 600) -> None: + """Pre-build the model's inference engine on the drone. + + For TensorRT devices this compiles the engine, which can take several + minutes — subsequent launches then start instantly. Only available on the + drone itself (the API answers 503 elsewhere). + + Args: + name: The package's directory slug. + timeout: Request timeout in seconds; engine builds are slow. + """ + self._check(requests.post(f"{self._base_url}/{name}/warmup", timeout=timeout)) + + def rescan(self, timeout: float = 5) -> None: + """Ask the drone's vision pipeline to rescan the installed packages. + + Uploads, deletions, and configuration changes trigger a rescan + automatically; this is only needed after out-of-band changes. + + Args: + timeout: Request timeout in seconds. + """ + self._check(requests.post(f"{self._base_url}/rescan", timeout=timeout)) diff --git a/blueye/sdk/drone.py b/blueye/sdk/drone.py index 5b0a9540..7a557e7c 100755 --- a/blueye/sdk/drone.py +++ b/blueye/sdk/drone.py @@ -27,6 +27,7 @@ SkidServo, device_to_peripheral, ) +from .cv_models import CvModels from .logs import LegacyLogs, Logs from .mission import Mission from .motion import Motion @@ -235,6 +236,7 @@ def __init__( self.battery = Battery(self) self.telemetry = Telemetry(self) self.mission = Mission(self) + self.cv_models = CvModels(self) self.connected = False self.client_id: int = None self.in_control: bool = False diff --git a/docs/bundling-cv-models.md b/docs/bundling-cv-models.md index 41be5b5c..4e68dc16 100644 --- a/docs/bundling-cv-models.md +++ b/docs/bundling-cv-models.md @@ -88,11 +88,41 @@ names. The model must take float32 image input; models with a clearly unsupported structure (image classifiers, float16 inputs, non-image inputs) are rejected with an explanation. -## Deploying +## Deploying to the drone -Unzip the package into a directory on the drone (for example under -`/videos/cv-models/`) and it can be launched by the onboard vision pipeline: +The easiest way is to push the package directly: interactive runs offer it after the +zip is written, and scripts pass `--push` (with `--drone-ip` if the drone is not at +the default `192.168.1.101`): ```shell -unzip yolov8n_package.zip -d /videos/cv-models/yolov8n_package +blueye bundle-model yolov8n.onnx --yes --push ``` + +If the drone cannot be reached, the command fails with a clear message — the zip is +still written and can be pushed later. + +Installed models are managed with the `blueye models` command (or interactively by +running it without arguments on a terminal): + +```shell +blueye models list +blueye models enable yolov8n-coco +blueye models set-device yolov8n-coco tensorrt-dla0 +blueye models warmup yolov8n-coco # pre-build the TensorRT engine +``` + +The same operations are available programmatically through the SDK: + +```python +import blueye.sdk + +drone = blueye.sdk.Drone(auto_connect=False) # HTTP only, takes no control +model = drone.cv_models.upload("yolov8n_package.zip") +drone.cv_models.set_enabled(model.directory, True) +``` + +Note: the `enabled` state is the autolaunch configuration; the API does not expose a +live "running" status. + +Manual alternative: use the Blunux Web App — open `http://192.168.1.101` in a +browser and upload the zip from the Computer Vision tab. diff --git a/docs/reference/blueye/sdk/cv_models.md b/docs/reference/blueye/sdk/cv_models.md new file mode 100644 index 00000000..01336736 --- /dev/null +++ b/docs/reference/blueye/sdk/cv_models.md @@ -0,0 +1 @@ +::: blueye.sdk.cv_models diff --git a/mkdocs.yml b/mkdocs.yml index e644727f..b856582f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,6 +119,7 @@ nav: - blueye.sdk.cli: "reference/blueye/sdk/cli.md" - blueye.sdk.connection: "reference/blueye/sdk/connection.md" - blueye.sdk.constants: "reference/blueye/sdk/constants.md" + - blueye.sdk.cv_models: "reference/blueye/sdk/cv_models.md" - blueye.sdk.drone: "reference/blueye/sdk/drone.md" - blueye.sdk.guestport: "reference/blueye/sdk/guestport.md" - blueye.sdk.logs: "reference/blueye/sdk/logs.md" diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 5fe42f10..0b386cea 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -284,3 +284,87 @@ def test_yes_without_force_does_not_overwrite( assert exit_code == 1 assert output.read_bytes() == b"existing" assert "--force" in capsys.readouterr().err + + +class TestPushToDrone: + @pytest.fixture + def mocked_drone_upload(self, mocker): + from blueye.sdk.cv_models import CvModel + + client = mocker.Mock() + client.upload.return_value = CvModel( + name="Test YOLO", + directory="test-yolo", + type="detection", + output_format="yolov8_flat", + size_bytes=1, + labels=["x"], + enabled=True, + raw={}, + ) + drone_cls = mocker.patch("blueye.sdk.Drone", autospec=True) + drone_cls.return_value.cv_models = client + client._drone_cls = drone_cls + return client + + def test_push_uploads_and_reports( + self, yolov8_model, fake_prompter, mocked_drone_upload, tmp_path, capsys + ): + output = tmp_path / "bundle.zip" + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "-o", str(output), "--push"]) + assert exit_code == 0 + mocked_drone_upload.upload.assert_called_once_with(output) + out = capsys.readouterr().out + assert "test-yolo" in out + + def test_drone_ip_flag_reaches_constructor( + self, yolov8_model, fake_prompter, mocked_drone_upload, tmp_path + ): + output = tmp_path / "bundle.zip" + main( + [ + "bundle-model", + str(yolov8_model), + "--yes", + "-o", + str(output), + "--push", + "--drone-ip", + "192.168.1.42", + ] + ) + assert mocked_drone_upload._drone_cls.call_args.kwargs["ip"] == "192.168.1.42" + + def test_unreachable_drone_fails_gracefully_keeping_bundle( + self, yolov8_model, fake_prompter, mocked_drone_upload, tmp_path, capsys + ): + import requests + + mocked_drone_upload.upload.side_effect = requests.exceptions.ConnectionError("refused") + output = tmp_path / "bundle.zip" + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "-o", str(output), "--push"]) + assert exit_code == 1 + assert zipfile.is_zipfile(output) # The bundle itself was written. + err = capsys.readouterr().err + assert "Could not reach the drone" in err + assert str(output) in err + + def test_drone_rejection_surfaces_reason( + self, yolov8_model, fake_prompter, mocked_drone_upload, tmp_path, capsys + ): + import requests + + mocked_drone_upload.upload.side_effect = requests.exceptions.HTTPError( + "400 error: model_meta.json not found in zip archive." + ) + output = tmp_path / "bundle.zip" + exit_code = main(["bundle-model", str(yolov8_model), "--yes", "-o", str(output), "--push"]) + assert exit_code == 1 + assert "rejected" in capsys.readouterr().err + + def test_yes_without_push_does_not_upload( + self, yolov8_model, fake_prompter, mocked_drone_upload, tmp_path + ): + output = tmp_path / "bundle.zip" + assert main(["bundle-model", str(yolov8_model), "--yes", "-o", str(output)]) == 0 + mocked_drone_upload.upload.assert_not_called() diff --git a/tests/test_cli_models_command.py b/tests/test_cli_models_command.py new file mode 100644 index 00000000..24dedfba --- /dev/null +++ b/tests/test_cli_models_command.py @@ -0,0 +1,161 @@ +import pytest +import requests + +from blueye.sdk.cli.main import main +from blueye.sdk.cv_models import CvModel + +MODEL = CvModel( + name="Cod Detector", + directory="cod-detector", + type="detection", + output_format="yolov8_flat", + size_bytes=10 * 1024 * 1024, + labels=["cod"], + enabled=False, + raw={"runtime": {"device": "tensorrt-dla0", "hz": 10, "enabled": False}}, +) + + +@pytest.fixture +def cv_models(mocker, monkeypatch): + """Mock the CvModels client the command builds, capturing the Drone ctor args.""" + monkeypatch.setenv("COLUMNS", "200") # Keep rich from wrapping table cells. + client = mocker.Mock() + client.list.return_value = [MODEL] + client.upload.return_value = MODEL + drone_cls = mocker.patch("blueye.sdk.Drone", autospec=True) + drone_cls.return_value.cv_models = client + client._drone_cls = drone_cls + return client + + +class TestList: + def test_list_renders_table(self, cv_models, capsys): + assert main(["models", "list"]) == 0 + out = capsys.readouterr().out + assert "Cod Detector" in out + assert "cod-detector" in out + assert "yolov8_flat" in out + assert "tensorrt-dla0" in out + assert "10" in out + + def test_drone_ip_flag_reaches_constructor(self, cv_models): + assert main(["models", "list", "--drone-ip", "192.168.1.42"]) == 0 + assert cv_models._drone_cls.call_args.kwargs["ip"] == "192.168.1.42" + + def test_empty_list(self, cv_models, capsys): + cv_models.list.return_value = [] + assert main(["models", "list"]) == 0 + assert "No CV models" in capsys.readouterr().out + + def test_bare_invocation_without_tty_lists(self, cv_models, mocker, capsys): + mocker.patch("sys.stdin.isatty", return_value=False) + assert main(["models"]) == 0 + assert "cod-detector" in capsys.readouterr().out + + +class TestActions: + def test_enable(self, cv_models): + assert main(["models", "enable", "cod-detector"]) == 0 + cv_models.set_enabled.assert_called_once_with("cod-detector", True, timeout=5.0) + + def test_disable(self, cv_models): + assert main(["models", "disable", "cod-detector"]) == 0 + cv_models.set_enabled.assert_called_once_with("cod-detector", False, timeout=5.0) + + def test_set_device(self, cv_models): + assert main(["models", "set-device", "cod-detector", "tensorrt-dla1"]) == 0 + cv_models.set_device.assert_called_once_with("cod-detector", "tensorrt-dla1", timeout=5.0) + + def test_set_device_rejects_unknown_choice(self, cv_models): + with pytest.raises(SystemExit): + main(["models", "set-device", "cod-detector", "gameboy"]) + + def test_set_hz(self, cv_models): + assert main(["models", "set-hz", "cod-detector", "10"]) == 0 + cv_models.set_hz.assert_called_once_with("cod-detector", 10, timeout=5.0) + + def test_warmup(self, cv_models): + assert main(["models", "warmup", "cod-detector"]) == 0 + cv_models.warmup.assert_called_once_with("cod-detector") + + def test_rescan(self, cv_models): + assert main(["models", "rescan"]) == 0 + cv_models.rescan.assert_called_once() + + def test_upload(self, cv_models, tmp_path, capsys): + package = tmp_path / "pkg.zip" + package.write_bytes(b"zip") + assert main(["models", "upload", str(package)]) == 0 + cv_models.upload.assert_called_once() + assert "cod-detector" in capsys.readouterr().out + + def test_upload_missing_file(self, cv_models, tmp_path, capsys): + assert main(["models", "upload", str(tmp_path / "nope.zip")]) == 1 + assert "No such file" in capsys.readouterr().err + + def test_download(self, cv_models, tmp_path, capsys): + cv_models.download.return_value = tmp_path / "cod-detector.zip" + assert main(["models", "download", "cod-detector", "-o", str(tmp_path)]) == 0 + cv_models.download.assert_called_once_with("cod-detector", output_path=tmp_path) + + +class TestDelete: + def test_delete_with_force(self, cv_models): + assert main(["models", "delete", "cod-detector", "--force"]) == 0 + cv_models.delete.assert_called_once_with("cod-detector", timeout=5.0) + + def test_delete_without_force_non_interactive_fails(self, cv_models, mocker, capsys): + mocker.patch("sys.stdin.isatty", return_value=False) + assert main(["models", "delete", "cod-detector"]) == 1 + cv_models.delete.assert_not_called() + assert "--force" in capsys.readouterr().err + + +class TestFailureHandling: + def test_unreachable_drone_is_friendly(self, cv_models, capsys): + cv_models.list.side_effect = requests.exceptions.ConnectionError("refused") + assert main(["models", "list"]) == 1 + err = capsys.readouterr().err + assert "Could not reach the drone" in err + assert "Traceback" not in err + + def test_server_reason_surfaces(self, cv_models, capsys): + cv_models.set_device.side_effect = requests.exceptions.HTTPError( + "400 error: 'device' must be one of: cuda, tensorrt." + ) + assert main(["models", "set-device", "cod-detector", "cuda"]) == 1 + assert "must be one of" in capsys.readouterr().err + + def test_models_command_needs_no_onnx(self, cv_models, mocker): + def fake_missing(names): + return [name for name in names if name == "onnx"] + + mocker.patch("blueye.sdk.cli.deps.missing", side_effect=fake_missing) + assert main(["models", "list"]) == 0 + + +class TestInteractive: + def test_interactive_toggle_flow(self, cv_models, mocker): + """Select the model, toggle autolaunch, then quit.""" + answers = iter( + [ + "cod-detector (disabled, tensorrt-dla0)", # model select + "Enable autolaunch", # action select + "Quit", # second round: quit + ] + ) + + class FakePrompter: + def select(self, question, choices, default, flag): + return next(answers) + + def confirm(self, question, default, flag): + return default + + mocker.patch("sys.stdin.isatty", return_value=True) + mocker.patch("sys.stdout.isatty", return_value=True) + mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=FakePrompter()) + + assert main(["models"]) == 0 + cv_models.set_enabled.assert_called_once_with("cod-detector", True, timeout=5.0) diff --git a/tests/test_cv_models.py b/tests/test_cv_models.py new file mode 100644 index 00000000..1b0c2190 --- /dev/null +++ b/tests/test_cv_models.py @@ -0,0 +1,166 @@ +import json + +import pytest +import requests + +from blueye.sdk.cv_models import CvModel, CvModels + +BASE = "http://192.168.1.101/api/cv-models" + +MODEL_ENTRY = { + "name": "Cod Detector", + "directory": "cod-detector", + "type": "detection", + "output_format": "yolov8_flat", + "size_bytes": 10485760, + "labels": ["cod", "salmon"], + "enabled": False, + "runtime": {"device": "tensorrt-dla0", "hz": 10, "enabled": False}, +} + + +@pytest.fixture +def cv_models(mocker): + mocked_drone = mocker.patch("blueye.sdk.Drone", autospec=True, _ip="192.168.1.101") + return CvModels(mocked_drone) + + +class TestList: + def test_list_parses_entries(self, cv_models, requests_mock): + requests_mock.get(f"{BASE}/", json=[MODEL_ENTRY]) + models = cv_models.list() + assert len(models) == 1 + model = models[0] + assert model.name == "Cod Detector" + assert model.directory == "cod-detector" + assert model.type == "detection" + assert model.output_format == "yolov8_flat" + assert model.size_bytes == 10485760 + assert model.labels == ["cod", "salmon"] + assert model.enabled is False + assert model.raw["runtime"]["device"] == "tensorrt-dla0" + + def test_empty_list(self, cv_models, requests_mock): + requests_mock.get(f"{BASE}/", json=[]) + assert cv_models.list() == [] + + def test_missing_optional_fields_defaulted(self): + model = CvModel.from_json({"name": "x", "directory": "x"}) + assert model.type == "unknown" + assert model.size_bytes == 0 + assert model.labels == [] + assert model.enabled is False + + +class TestUpload: + def test_upload_sends_multipart_file_field(self, cv_models, requests_mock, tmp_path): + package = tmp_path / "pkg.zip" + package.write_bytes(b"zip-bytes") + requests_mock.post(f"{BASE}/upload", json=MODEL_ENTRY) + + model = cv_models.upload(package) + + assert model.directory == "cod-detector" + request = requests_mock.last_request + assert 'name="file"' in request.text + assert "zip-bytes" in request.text + + def test_upload_rejection_surfaces_server_reason(self, cv_models, requests_mock, tmp_path): + package = tmp_path / "pkg.zip" + package.write_bytes(b"not really a zip") + requests_mock.post( + f"{BASE}/upload", + status_code=400, + text="Uploaded file is not a valid zip archive.", + ) + with pytest.raises(requests.exceptions.HTTPError, match="not a valid zip archive"): + cv_models.upload(package) + + +class TestDelete: + def test_delete(self, cv_models, requests_mock): + requests_mock.delete(f"{BASE}/cod-detector", json={"success": True, "message": "deleted"}) + cv_models.delete("cod-detector") + assert requests_mock.called + + def test_delete_unknown_raises_with_reason(self, cv_models, requests_mock): + requests_mock.delete(f"{BASE}/nope", status_code=404, text="Model 'nope' not found.") + with pytest.raises(requests.exceptions.HTTPError, match="not found"): + cv_models.delete("nope") + + +class TestDownload: + def test_download_uses_content_disposition_name(self, cv_models, requests_mock, tmp_path): + requests_mock.get( + f"{BASE}/cod-detector/download", + content=b"zip-bytes", + headers={"Content-Disposition": 'attachment; filename="cod-detector.zip"'}, + ) + output = cv_models.download("cod-detector", output_path=tmp_path) + assert output == tmp_path / "cod-detector.zip" + assert output.read_bytes() == b"zip-bytes" + + def test_download_fallback_name_and_explicit_path(self, cv_models, requests_mock, tmp_path): + requests_mock.get(f"{BASE}/cod-detector/download", content=b"zip-bytes") + output = cv_models.download("cod-detector", output_path=tmp_path / "my.zip") + assert output == tmp_path / "my.zip" + assert output.read_bytes() == b"zip-bytes" + + +class TestConfiguration: + def test_set_enabled_payload(self, cv_models, requests_mock): + requests_mock.patch(f"{BASE}/cod-detector/enabled", json={"success": True}) + cv_models.set_enabled("cod-detector", True) + assert json.loads(requests_mock.last_request.text) == {"enabled": True} + + def test_set_device_payload(self, cv_models, requests_mock): + requests_mock.patch(f"{BASE}/cod-detector/device", json={"success": True}) + cv_models.set_device("cod-detector", "tensorrt-dla1") + assert json.loads(requests_mock.last_request.text) == {"device": "tensorrt-dla1"} + + def test_set_device_rejection_surfaces_reason(self, cv_models, requests_mock): + requests_mock.patch( + f"{BASE}/cod-detector/device", + status_code=400, + text="'device' must be one of: cuda, tensorrt, tensorrt-dla0, tensorrt-dla1.", + ) + with pytest.raises(requests.exceptions.HTTPError, match="must be one of"): + cv_models.set_device("cod-detector", "gameboy") + + def test_set_hz_payload(self, cv_models, requests_mock): + requests_mock.patch(f"{BASE}/cod-detector/hz", json={"success": True}) + cv_models.set_hz("cod-detector", 10) + assert json.loads(requests_mock.last_request.text) == {"hz": 10} + + +class TestWarmupAndRescan: + def test_warmup(self, cv_models, requests_mock): + requests_mock.post(f"{BASE}/cod-detector/warmup", json={"success": True}) + cv_models.warmup("cod-detector") + assert requests_mock.called + + def test_warmup_unavailable_off_drone(self, cv_models, requests_mock): + requests_mock.post( + f"{BASE}/cod-detector/warmup", + status_code=503, + json={"success": False, "message": "Warmup is not available on this device."}, + ) + with pytest.raises(requests.exceptions.HTTPError, match="503"): + cv_models.warmup("cod-detector") + + def test_rescan(self, cv_models, requests_mock): + requests_mock.post(f"{BASE}/rescan", json={"success": True}) + cv_models.rescan() + assert requests_mock.called + + +def test_drone_has_cv_models_feature(mocker): + import blueye.sdk + + mocker.patch("blueye.sdk.drone.CtrlClient", autospec=True) + mocker.patch("blueye.sdk.drone.TelemetryClient", autospec=True) + mocker.patch("blueye.sdk.drone.WatchdogPublisher", autospec=True) + mocker.patch("blueye.sdk.drone.ReqRepClient", autospec=True) + drone = blueye.sdk.Drone(auto_connect=False) + assert isinstance(drone.cv_models, CvModels) + assert drone.cv_models._parent_drone is drone From 88695a4f0c675f98afe24bbeec7ec4d3e31eac1e Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 12:55:55 +0200 Subject: [PATCH 07/17] feat: add `blueye logs` command for listing and downloading dive logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the documented drone.logs workflow as a CLI built-in: - `blueye logs list` — table of the drone's binary logs (name, time, max depth, size, dive flag). - `blueye logs download [NAME ...] [--latest N | --all] [-o DIR]` — downloads .bez files; unknown names error listing what is available. - Bare `blueye logs` on a terminal opens an interactive picker: the table plus a checkbox multi-select and a destination prompt. The Prompter seam gains a checkbox method (questionary.checkbox interactively; a CliError naming the flags non-interactively). - Connects to the drone as an observer (connect_as_observer=True), so no control is taken and no SDK changes were needed; an unreachable drone fails with the friendly message and exit 1. The connection is released on exit. - The --drone-ip/--timeout parent parser and the failure-translation helper are lifted into commands/_common.py, now shared with the models command. Verified against the bench drone (list + `--latest 1` downloaded a real 2.5 MiB .bez) and an unreachable address. +14 tests (432 total); docs gain a "From the command line" section in the logs guide. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/__init__.py | 3 +- blueye/sdk/cli/commands/_common.py | 41 +++++ blueye/sdk/cli/commands/logs/__init__.py | 14 ++ blueye/sdk/cli/commands/logs/command.py | 179 ++++++++++++++++++++++ blueye/sdk/cli/commands/models/command.py | 54 +++---- blueye/sdk/cli/prompts.py | 11 +- docs/logs/listing-and-downloading.md | 12 ++ tests/test_cli_logs_command.py | 138 +++++++++++++++++ 8 files changed, 414 insertions(+), 38 deletions(-) create mode 100644 blueye/sdk/cli/commands/_common.py create mode 100644 blueye/sdk/cli/commands/logs/__init__.py create mode 100644 blueye/sdk/cli/commands/logs/command.py create mode 100644 tests/test_cli_logs_command.py diff --git a/blueye/sdk/cli/commands/__init__.py b/blueye/sdk/cli/commands/__init__.py index 48634e3e..246203f0 100644 --- a/blueye/sdk/cli/commands/__init__.py +++ b/blueye/sdk/cli/commands/__init__.py @@ -52,7 +52,8 @@ class CommandSpec: def all_commands() -> tuple[CommandSpec, ...]: """Return every built-in command, in the order shown in ``blueye --help``.""" from .bundle_model import COMMAND as bundle_model_command + from .logs import COMMAND as logs_command from .models import COMMAND as models_command from .tools import COMMAND as tools_command - return (bundle_model_command, models_command, tools_command) + return (bundle_model_command, logs_command, models_command, tools_command) diff --git a/blueye/sdk/cli/commands/_common.py b/blueye/sdk/cli/commands/_common.py new file mode 100644 index 00000000..0c13ce22 --- /dev/null +++ b/blueye/sdk/cli/commands/_common.py @@ -0,0 +1,41 @@ +"""Shared helpers for built-in commands that talk to the drone.""" + +from __future__ import annotations + +import argparse +import logging + +from ..errors import CliError + +logger = logging.getLogger(__name__) + + +def drone_options_parser(timeout_default: float = 5.0) -> argparse.ArgumentParser: + """Build the parent parser carrying the common drone connection options.""" + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--drone-ip", default="192.168.1.101", help="Drone address (default: %(default)s)" + ) + common.add_argument( + "--timeout", type=float, default=timeout_default, help="Request timeout in seconds" + ) + return common + + +def friendly_errors(action): + """Run an action, translating transport/API failures into CliErrors.""" + import requests + + try: + return action() + except ( + ConnectionError, # Raised by Drone.connect()/_update_drone_info. + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ) as error: + raise CliError( + "Could not reach the drone — is it connected? (Use --drone-ip if it is not " + "at the default address.)" + ) from error + except requests.exceptions.HTTPError as error: + raise CliError(str(error)) from error diff --git a/blueye/sdk/cli/commands/logs/__init__.py b/blueye/sdk/cli/commands/logs/__init__.py new file mode 100644 index 00000000..e033f418 --- /dev/null +++ b/blueye/sdk/cli/commands/logs/__init__.py @@ -0,0 +1,14 @@ +"""The `blueye logs` command: list and download dive logs from the drone.""" + +from __future__ import annotations + +from .. import CommandSpec +from .command import add_parser, run + +COMMAND = CommandSpec( + name="logs", + help="List and download dive logs from the drone", + requires=("rich", "questionary"), + add_parser=add_parser, + run=run, +) diff --git a/blueye/sdk/cli/commands/logs/command.py b/blueye/sdk/cli/commands/logs/command.py new file mode 100644 index 00000000..49efc587 --- /dev/null +++ b/blueye/sdk/cli/commands/logs/command.py @@ -0,0 +1,179 @@ +"""Implementation of the `blueye logs` subcommands. + +Follows the documented log workflow (docs/logs/listing-and-downloading.md): connect to +the drone **as an observer** (taking no control), read the binary log index from +`drone.logs`, and download `.bez` files with `LogFile.download`. Legacy CSV logs are +not covered — use `drone.legacy_logs` from the SDK for those. + +Argument definitions are stdlib-only; rich/questionary/blueye.sdk imports happen +inside `run` (after the dependency gate). +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +from ...errors import CliError +from .._common import drone_options_parser, friendly_errors + +logger = logging.getLogger(__name__) + + +def add_parser(subparsers) -> None: + """Register the ``logs`` subcommand and its sub-subcommands.""" + common = drone_options_parser(timeout_default=10.0) + + parser = subparsers.add_parser( + "logs", + parents=[common], + help="List and download dive logs from the drone", + description=( + "List and download the drone's binary dive logs (.bez). Connects to the " + "drone as an observer, taking no control. Run without an action on a " + "terminal to pick logs interactively." + ), + ) + actions = parser.add_subparsers(dest="logs_command", metavar="ACTION") + + actions.add_parser("list", parents=[common], help="List the logs on the drone") + + download = actions.add_parser("download", parents=[common], help="Download logs from the drone") + download.add_argument("names", nargs="*", help="Log names to download") + download.add_argument( + "-o", "--output", default=".", help="Destination directory (default: current)" + ) + download.add_argument( + "--latest", + type=int, + metavar="N", + help="Download the N most recent logs", + ) + download.add_argument("--all", action="store_true", help="Download every log") + + +def _connect(args): + """Connect to the drone as an observer and return the Drone object.""" + from blueye.sdk import Drone + + return friendly_errors( + lambda: Drone(ip=args.drone_ip, timeout=args.timeout, connect_as_observer=True) + ) + + +def _log_rows(logs) -> list: + """The drone's logs as a list of LogFile objects (index fetched lazily).""" + return friendly_errors(lambda: list(logs)) + + +def _print_logs_table(console, log_files) -> None: + from rich.table import Table + + from blueye.sdk.logs import human_readable_filesize + + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + for column in ("NAME", "TIME", "MAX DEPTH", "SIZE", "DIVE"): + table.add_column(column) + for log in log_files: + table.add_row( + log.name, + log.start_time.strftime("%d. %b %Y %H:%M"), + f"{log.max_depth_magnitude} m", + human_readable_filesize(log.filesize), + "yes" if log.is_dive else "[dim]no[/dim]", + ) + console.print(table) + + +def _download_logs(console, log_files, output_dir: Path, timeout: float) -> None: + from blueye.sdk.logs import human_readable_filesize + + output_dir.mkdir(parents=True, exist_ok=True) + for log in log_files: + with console.status(f"[cyan]Downloading {log.name}..."): + friendly_errors(lambda: log.download(output_path=output_dir, timeout=timeout)) + console.print( + f"Downloaded {log.name}.bez ({human_readable_filesize(log.filesize)}) " + f"to {output_dir}" + ) + + +def _select_downloads(args, log_files) -> list: + """Resolve the download selection from names/--latest/--all.""" + by_name = {log.name: log for log in log_files} + if args.all: + return list(log_files) + if args.latest is not None: + newest_first = sorted(log_files, key=lambda log: log.start_time, reverse=True) + return newest_first[: args.latest] + if args.names: + missing = [name for name in args.names if name not in by_name] + if missing: + available = ", ".join(sorted(by_name)) or "none" + raise CliError( + f"No log named {', '.join(missing)} on the drone (available: {available})." + ) + return [by_name[name] for name in args.names] + raise CliError("Nothing selected — pass log names, --latest N, or --all.") + + +def _run_interactive(console, args, prompter, drone) -> int: + """Show the table, pick logs with a checkbox, download to a chosen directory.""" + log_files = _log_rows(drone.logs) + if not log_files: + console.print("No logs on the drone.") + return 0 + _print_logs_table(console, log_files) + + from blueye.sdk.logs import human_readable_filesize + + by_label = { + f"{log.name} {log.start_time.strftime('%d. %b %Y %H:%M')} " + f"{human_readable_filesize(log.filesize)}": log + for log in log_files + } + selected = prompter.checkbox("Select logs to download:", list(by_label), "--latest/--all") + if not selected: + console.print("Nothing selected.") + return 0 + output_dir = Path(prompter.text("Download to directory:", ".", "--output")).expanduser() + _download_logs(console, [by_label[label] for label in selected], output_dir, args.timeout) + return 0 + + +def run(args: argparse.Namespace) -> int: + """Dispatch the logs sub-subcommand.""" + from ... import prompts, ui + + console = ui.make_console() + action = getattr(args, "logs_command", None) + + drone = _connect(args) + try: + if action == "download": + log_files = _log_rows(drone.logs) + selection = _select_downloads(args, log_files) + _download_logs(console, selection, Path(args.output).expanduser(), args.timeout) + return 0 + + if action is None and sys.stdin.isatty() and sys.stdout.isatty(): + try: + return _run_interactive(console, args, prompts.QuestionaryPrompter(), drone) + except prompts.PromptAborted: + console.print("[yellow]Cancelled.[/yellow]") + return 130 + + # `logs list` and non-TTY bare invocation. + log_files = _log_rows(drone.logs) + if not log_files: + console.print("No logs on the drone.") + return 0 + _print_logs_table(console, log_files) + return 0 + finally: + try: + drone.disconnect() + except Exception: # Never let cleanup mask the real outcome. + logger.debug("Failed to disconnect cleanly", exc_info=True) diff --git a/blueye/sdk/cli/commands/models/command.py b/blueye/sdk/cli/commands/models/command.py index 67707629..72798749 100644 --- a/blueye/sdk/cli/commands/models/command.py +++ b/blueye/sdk/cli/commands/models/command.py @@ -17,6 +17,7 @@ from pathlib import Path from ...errors import CliError +from .._common import drone_options_parser, friendly_errors logger = logging.getLogger(__name__) @@ -26,11 +27,7 @@ def add_parser(subparsers) -> None: """Register the ``models`` subcommand and its sub-subcommands.""" - common = argparse.ArgumentParser(add_help=False) - common.add_argument( - "--drone-ip", default="192.168.1.101", help="Drone address (default: %(default)s)" - ) - common.add_argument("--timeout", type=float, default=5.0, help="Request timeout in seconds") + common = drone_options_parser(timeout_default=5.0) parser = subparsers.add_parser( "models", @@ -100,21 +97,6 @@ def _cv_models(args): return Drone(ip=args.drone_ip, auto_connect=False).cv_models -def _friendly_errors(action): - """Run an action, translating transport/API failures into CliErrors.""" - import requests - - try: - return action() - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as error: - raise CliError( - "Could not reach the drone — is it connected? (Use --drone-ip if it is not " - "at the default address.)" - ) from error - except requests.exceptions.HTTPError as error: - raise CliError(str(error)) from error - - def _runtime_field(model, key: str, default: str = "-") -> str: value = model.raw.get("runtime", {}).get(key) return str(value) if value is not None else default @@ -143,7 +125,7 @@ def _print_models_table(console, models) -> None: def _run_list(console, args) -> int: - models = _friendly_errors(lambda: _cv_models(args).list(timeout=args.timeout)) + models = friendly_errors(lambda: _cv_models(args).list(timeout=args.timeout)) if not models: console.print("No CV models installed on the drone.") return 0 @@ -155,7 +137,7 @@ def _run_interactive(console, args, prompter) -> int: """Interactive management loop: pick a model, pick an action, repeat.""" cv_models = _cv_models(args) while True: - models = _friendly_errors(lambda: cv_models.list(timeout=args.timeout)) + models = friendly_errors(lambda: cv_models.list(timeout=args.timeout)) if not models: console.print("No CV models installed on the drone.") return 0 @@ -180,7 +162,7 @@ def _run_interactive(console, args, prompter) -> int: "ACTION", ) if action == toggle: - _friendly_errors( + friendly_errors( lambda: cv_models.set_enabled( model.directory, not model.enabled, timeout=args.timeout ) @@ -189,7 +171,7 @@ def _run_interactive(console, args, prompter) -> int: device = prompter.select( "Execution device:", list(_DEVICES), _runtime_field(model, "device"), "ACTION" ) - _friendly_errors( + friendly_errors( lambda: cv_models.set_device(model.directory, device, timeout=args.timeout) ) elif action == "Set rate": @@ -200,15 +182,15 @@ def _run_interactive(console, args, prompter) -> int: "ACTION", ) hz = 0 if rate.startswith("max") else int(rate) - _friendly_errors(lambda: cv_models.set_hz(model.directory, hz, timeout=args.timeout)) + friendly_errors(lambda: cv_models.set_hz(model.directory, hz, timeout=args.timeout)) elif action == "Warm up": with console.status( f"[cyan]Warming up '{model.directory}' (TensorRT builds can take minutes)..." ): - _friendly_errors(lambda: cv_models.warmup(model.directory)) + friendly_errors(lambda: cv_models.warmup(model.directory)) elif action == "Delete": if prompter.confirm(f"Delete '{model.directory}' from the drone?", False, "--force"): - _friendly_errors(lambda: cv_models.delete(model.directory, timeout=args.timeout)) + friendly_errors(lambda: cv_models.delete(model.directory, timeout=args.timeout)) def run(args: argparse.Namespace) -> int: @@ -232,23 +214,23 @@ def run(args: argparse.Namespace) -> int: cv_models = _cv_models(args) if action == "enable": - _friendly_errors(lambda: cv_models.set_enabled(args.name, True, timeout=args.timeout)) + friendly_errors(lambda: cv_models.set_enabled(args.name, True, timeout=args.timeout)) console.print(f"Enabled autolaunch for '{args.name}'.") elif action == "disable": - _friendly_errors(lambda: cv_models.set_enabled(args.name, False, timeout=args.timeout)) + friendly_errors(lambda: cv_models.set_enabled(args.name, False, timeout=args.timeout)) console.print(f"Disabled autolaunch for '{args.name}'.") elif action == "set-device": - _friendly_errors(lambda: cv_models.set_device(args.name, args.device, timeout=args.timeout)) + friendly_errors(lambda: cv_models.set_device(args.name, args.device, timeout=args.timeout)) console.print(f"'{args.name}' now runs on {args.device}.") elif action == "set-hz": - _friendly_errors(lambda: cv_models.set_hz(args.name, args.hz, timeout=args.timeout)) + friendly_errors(lambda: cv_models.set_hz(args.name, args.hz, timeout=args.timeout)) rate = "unlimited" if args.hz == 0 else f"{args.hz} Hz" console.print(f"'{args.name}' rate set to {rate}.") elif action == "warmup": with console.status( f"[cyan]Warming up '{args.name}' (TensorRT builds can take minutes)..." ): - _friendly_errors(lambda: cv_models.warmup(args.name)) + friendly_errors(lambda: cv_models.warmup(args.name)) console.print(f"Warmup of '{args.name}' complete.") elif action == "delete": if not args.force: @@ -261,22 +243,22 @@ def run(args: argparse.Namespace) -> int: f"Delete '{args.name}' from the drone?", False, "--force" ): return 1 - _friendly_errors(lambda: cv_models.delete(args.name, timeout=args.timeout)) + friendly_errors(lambda: cv_models.delete(args.name, timeout=args.timeout)) console.print(f"Deleted '{args.name}' from the drone.") elif action == "upload": package = Path(args.package).expanduser() if not package.is_file(): raise CliError(f"No such file: {package}") with console.status("[cyan]Uploading to the drone..."): - model = _friendly_errors(lambda: cv_models.upload(package)) + model = friendly_errors(lambda: cv_models.upload(package)) state = "enabled" if model.enabled else "disabled" console.print(f"Uploaded '{model.name}' as '{model.directory}' (autolaunch {state}).") elif action == "download": output = Path(args.output).expanduser() if args.output else None with console.status("[cyan]Downloading from the drone..."): - path = _friendly_errors(lambda: cv_models.download(args.name, output_path=output)) + path = friendly_errors(lambda: cv_models.download(args.name, output_path=output)) console.print(f"Downloaded '{args.name}' to {path}.") elif action == "rescan": - _friendly_errors(lambda: cv_models.rescan(timeout=args.timeout)) + friendly_errors(lambda: cv_models.rescan(timeout=args.timeout)) console.print("Rescan triggered.") return 0 diff --git a/blueye/sdk/cli/prompts.py b/blueye/sdk/cli/prompts.py index 7715f36f..aeef060f 100644 --- a/blueye/sdk/cli/prompts.py +++ b/blueye/sdk/cli/prompts.py @@ -22,7 +22,7 @@ class PromptAborted(Exception): class Prompter(Protocol): - """The questions the bundler can ask. Implementations decide how.""" + """The questions the CLI commands can ask. Implementations decide how.""" def select( self, question: str, choices: Sequence[str], default: str | None, flag: str @@ -34,6 +34,8 @@ def confirm(self, question: str, default: bool, flag: str) -> bool: ... def path(self, question: str, default: str | None, flag: str) -> str: ... + def checkbox(self, question: str, choices: Sequence[str], flag: str) -> list[str]: ... + def _require(answer: object) -> object: """Translate questionary's None (Ctrl+C) into PromptAborted.""" @@ -68,6 +70,10 @@ def confirm(self, question: str, default: bool, flag: str) -> bool: def path(self, question: str, default: str | None, flag: str) -> str: return str(_require(questionary.path(question, default=default or "").ask())) + def checkbox(self, question: str, choices: Sequence[str], flag: str) -> list[str]: + answer = _require(questionary.checkbox(question, choices=list(choices)).ask()) + return [str(item) for item in answer] + class NonInteractivePrompter: """Prompt resolution for ``--yes`` runs and non-TTY environments. @@ -96,3 +102,6 @@ def path(self, question: str, default: str | None, flag: str) -> str: if not default: raise CliError(f"Cannot answer '{question}' non-interactively — pass {flag}.") return default + + def checkbox(self, question: str, choices: Sequence[str], flag: str) -> list[str]: + raise CliError(f"Cannot answer '{question}' non-interactively — pass {flag}.") diff --git a/docs/logs/listing-and-downloading.md b/docs/logs/listing-and-downloading.md index eaa86998..37cc0981 100644 --- a/docs/logs/listing-and-downloading.md +++ b/docs/logs/listing-and-downloading.md @@ -6,6 +6,18 @@ When the drone is powered on a new log file is created, where it stores telemetr Every entry in the binary log is a [BinlogRecord][blueye.protocol.types.message_formats.BinlogRecord] Protobuf message, which in turn contains a unix timestamp in UTC, the monotonic timestamp (time since boot), and an Any message wrapping the Blueye telemetry message. The telemetry messages are documented in the [telemetry proto][blueye.protocol.types.telemetry]. +## From the command line + +The binary logs are also available through the `blueye` CLI (installed with the +SDK's `[cli]` extra), which connects to the drone as an observer — taking no control: + +```shell +blueye logs list # table of logs on the drone +blueye logs download --latest 1 # newest log to the current directory +blueye logs download BYEDP000000_ea9ac92e1817a1d4_00002 -o ~/dives +blueye logs # interactive: pick logs to download +``` + ## Listing the log files If your drone has completed 5 dives and you do diff --git a/tests/test_cli_logs_command.py b/tests/test_cli_logs_command.py new file mode 100644 index 00000000..e3934bd7 --- /dev/null +++ b/tests/test_cli_logs_command.py @@ -0,0 +1,138 @@ +import pytest + +from blueye.sdk.cli.main import main +from blueye.sdk.logs import LogFile + + +def make_log(name: str, start_time: int, filesize: int = 2048, is_dive: bool = True) -> LogFile: + return LogFile( + name=name, + is_dive=is_dive, + filesize=filesize, + start_time=start_time, + max_depth_magnitude=20, + ip="192.168.1.101", + ) + + +@pytest.fixture +def drone(mocker, monkeypatch): + """Mocked Drone with two real LogFile objects; download patched out.""" + monkeypatch.setenv("COLUMNS", "200") + logs = [ + make_log("BYEDP000000_aaaa_00000", start_time=1700000000), + make_log("BYEDP000000_aaaa_00001", start_time=1700100000, is_dive=False), + ] + mocker.patch.object(LogFile, "download", autospec=True, return_value=b"") + drone_cls = mocker.patch("blueye.sdk.Drone", autospec=True) + instance = drone_cls.return_value + instance.logs = logs # `list(drone.logs)` works on a plain list. + instance._logs = {log.name: log for log in logs} + instance._drone_cls = drone_cls + return instance + + +class TestList: + def test_list_renders_table(self, drone, capsys): + assert main(["logs", "list"]) == 0 + out = capsys.readouterr().out + assert "BYEDP000000_aaaa_00000" in out + assert "20 m" in out + assert "2.0 KiB" in out + assert "yes" in out and "no" in out + + def test_connects_as_observer_and_disconnects(self, drone): + assert main(["logs", "list"]) == 0 + kwargs = drone._drone_cls.call_args.kwargs + assert kwargs["connect_as_observer"] is True + assert kwargs["ip"] == "192.168.1.101" + drone.disconnect.assert_called_once() + + def test_drone_ip_flag(self, drone): + assert main(["logs", "list", "--drone-ip", "192.168.1.42"]) == 0 + assert drone._drone_cls.call_args.kwargs["ip"] == "192.168.1.42" + + def test_empty_logs(self, drone, capsys): + drone.logs = [] + assert main(["logs", "list"]) == 0 + assert "No logs" in capsys.readouterr().out + + def test_bare_invocation_without_tty_lists(self, drone, mocker, capsys): + mocker.patch("sys.stdin.isatty", return_value=False) + assert main(["logs"]) == 0 + assert "BYEDP000000_aaaa_00000" in capsys.readouterr().out + + +class TestDownload: + def test_download_by_name(self, drone, tmp_path): + assert main(["logs", "download", "BYEDP000000_aaaa_00000", "-o", str(tmp_path)]) == 0 + LogFile.download.assert_called_once() + call = LogFile.download.call_args + assert call.args[0].name == "BYEDP000000_aaaa_00000" + assert call.kwargs["output_path"] == tmp_path + + def test_unknown_name_lists_available(self, drone, capsys): + assert main(["logs", "download", "nope"]) == 1 + err = capsys.readouterr().err + assert "No log named nope" in err + assert "BYEDP000000_aaaa_00000" in err + + def test_latest_picks_newest(self, drone, tmp_path): + assert main(["logs", "download", "--latest", "1", "-o", str(tmp_path)]) == 0 + call = LogFile.download.call_args + assert call.args[0].name == "BYEDP000000_aaaa_00001" # newer start_time + + def test_all_downloads_everything(self, drone, tmp_path): + assert main(["logs", "download", "--all", "-o", str(tmp_path)]) == 0 + assert LogFile.download.call_count == 2 + + def test_no_selector_errors(self, drone, capsys): + assert main(["logs", "download"]) == 1 + assert "--latest" in capsys.readouterr().err + + +class TestFailureHandling: + def test_unreachable_drone_is_friendly(self, drone, capsys): + drone._drone_cls.side_effect = ConnectionError("Could not establish connection with drone") + assert main(["logs", "list"]) == 1 + err = capsys.readouterr().err + assert "Could not reach the drone" in err + assert "Traceback" not in err + + def test_logs_command_needs_no_onnx(self, drone, mocker): + def fake_missing(names): + return [name for name in names if name == "onnx"] + + mocker.patch("blueye.sdk.cli.deps.missing", side_effect=fake_missing) + assert main(["logs", "list"]) == 0 + + +class TestInteractive: + def test_interactive_checkbox_download(self, drone, mocker, tmp_path): + class FakePrompter: + def checkbox(self, question, choices, flag): + return [choices[0]] # Select the first log. + + def text(self, question, default, flag): + return str(tmp_path) + + mocker.patch("sys.stdin.isatty", return_value=True) + mocker.patch("sys.stdout.isatty", return_value=True) + mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=FakePrompter()) + + assert main(["logs"]) == 0 + LogFile.download.assert_called_once() + assert LogFile.download.call_args.kwargs["output_path"] == tmp_path + + def test_interactive_empty_selection(self, drone, mocker, capsys): + class FakePrompter: + def checkbox(self, question, choices, flag): + return [] + + mocker.patch("sys.stdin.isatty", return_value=True) + mocker.patch("sys.stdout.isatty", return_value=True) + mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=FakePrompter()) + + assert main(["logs"]) == 0 + LogFile.download.assert_not_called() + assert "Nothing selected" in capsys.readouterr().out From c34cc573fcdb55ffbec2e0e60a6c5cf376b7a026 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 13:04:16 +0200 Subject: [PATCH 08/17] feat: add --mcap conversion to `blueye logs download` `blueye logs download ... --mcap` converts each downloaded .bez to a Foxglove-ready .mcap next to it; the interactive picker offers the same conversion after selecting logs. The converter (commands/logs/mcap.py) is adapted from examples/foxglove_bez_to_mcap.py: a first LogStream pass anchors the dive start time (last record's wall clock minus its monotonic delta, so logs where the clock was set mid-dive stay continuous), a second pass writes every protobuf message via mcap_protobuf.writer. mcap-protobuf-support joins the [cli] extra (and dev group); the --mcap path gates on it at runtime with the standard install guidance, so the rest of the logs command works without it. Verified against the bench drone: `blueye logs download --latest 1 --mcap` produced a valid 8.5 MB .mcap (217k messages, correct MCAP magic) from a real dive log. +6 tests incl. a real protobuf-built .bez round trip (438 total); the Foxglove doc gains a one-step download-and-convert tip. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/logs/command.py | 48 +++++++++++- blueye/sdk/cli/commands/logs/mcap.py | 61 +++++++++++++++ docs/logs/foxglove-bez-to-mcap.md | 11 +++ docs/logs/listing-and-downloading.md | 1 + pyproject.toml | 3 + tests/test_cli_logs_command.py | 98 +++++++++++++++++++++++++ uv.lock | 4 + 7 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 blueye/sdk/cli/commands/logs/mcap.py diff --git a/blueye/sdk/cli/commands/logs/command.py b/blueye/sdk/cli/commands/logs/command.py index 49efc587..7d6cca7f 100644 --- a/blueye/sdk/cli/commands/logs/command.py +++ b/blueye/sdk/cli/commands/logs/command.py @@ -52,6 +52,11 @@ def add_parser(subparsers) -> None: help="Download the N most recent logs", ) download.add_argument("--all", action="store_true", help="Download every log") + download.add_argument( + "--mcap", + action="store_true", + help="Also convert each downloaded log to .mcap (for Foxglove)", + ) def _connect(args): @@ -87,9 +92,25 @@ def _print_logs_table(console, log_files) -> None: console.print(table) -def _download_logs(console, log_files, output_dir: Path, timeout: float) -> None: +def _ensure_mcap_support() -> None: + """Gate the --mcap path on its optional dependency, with install guidance.""" + from ... import deps + + missing = deps.missing(("mcap_protobuf",)) + if missing: + deps.print_install_guidance(missing) + raise CliError("Converting to .mcap requires the mcap-protobuf-support package.") + + +def _download_logs( + console, log_files, output_dir: Path, timeout: float, convert_mcap: bool = False +) -> None: from blueye.sdk.logs import human_readable_filesize + if convert_mcap: + _ensure_mcap_support() + from .mcap import convert_bez_to_mcap + output_dir.mkdir(parents=True, exist_ok=True) for log in log_files: with console.status(f"[cyan]Downloading {log.name}..."): @@ -98,6 +119,14 @@ def _download_logs(console, log_files, output_dir: Path, timeout: float) -> None f"Downloaded {log.name}.bez ({human_readable_filesize(log.filesize)}) " f"to {output_dir}" ) + if convert_mcap: + bez_path = output_dir / f"{log.name}.bez" + mcap_path = output_dir / f"{log.name}.mcap" + with console.status(f"[cyan]Converting {log.name} to .mcap..."): + message_count = convert_bez_to_mcap(bez_path, mcap_path) + console.print( + f"Converted to {mcap_path.name} ({message_count} messages) — open it in " "Foxglove" + ) def _select_downloads(args, log_files) -> list: @@ -139,7 +168,14 @@ def _run_interactive(console, args, prompter, drone) -> int: console.print("Nothing selected.") return 0 output_dir = Path(prompter.text("Download to directory:", ".", "--output")).expanduser() - _download_logs(console, [by_label[label] for label in selected], output_dir, args.timeout) + convert_mcap = prompter.confirm("Also convert to .mcap for Foxglove?", False, "--mcap") + _download_logs( + console, + [by_label[label] for label in selected], + output_dir, + args.timeout, + convert_mcap=convert_mcap, + ) return 0 @@ -155,7 +191,13 @@ def run(args: argparse.Namespace) -> int: if action == "download": log_files = _log_rows(drone.logs) selection = _select_downloads(args, log_files) - _download_logs(console, selection, Path(args.output).expanduser(), args.timeout) + _download_logs( + console, + selection, + Path(args.output).expanduser(), + args.timeout, + convert_mcap=args.mcap, + ) return 0 if action is None and sys.stdin.isatty() and sys.stdout.isatty(): diff --git a/blueye/sdk/cli/commands/logs/mcap.py b/blueye/sdk/cli/commands/logs/mcap.py new file mode 100644 index 00000000..6d81b8e0 --- /dev/null +++ b/blueye/sdk/cli/commands/logs/mcap.py @@ -0,0 +1,61 @@ +"""Conversion of .bez dive logs to Foxglove-compatible .mcap files. + +Adapted from examples/foxglove_bez_to_mcap.py: the log is streamed twice — a first +pass finds the true dive start time (the drone's clock may be set mid-log, so the +last record's wall time minus its monotonic delta is the reliable anchor), and a +second pass writes every protobuf message with continuous timestamps. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from ...errors import CliError + +logger = logging.getLogger(__name__) + + +def convert_bez_to_mcap(bez_path: Path, mcap_path: Path) -> int: + """Convert a downloaded .bez log to an .mcap file for Foxglove. + + Args: + bez_path: The .bez file to convert. + mcap_path: Destination .mcap path (overwritten if present). + + Returns: + The number of messages written. + + Raises: + CliError: When the log contains no readable records. + """ + from mcap_protobuf.writer import Writer + + from blueye.sdk.logs import LogStream + + log_bytes = bez_path.read_bytes() + + # First pass: the last record's wall clock minus its monotonic delta gives the + # dive start time even when the drone's clock was set partway through the log. + last_time = None + last_delta = None + for last_time, last_delta, _, _ in LogStream(log_bytes): + continue + if last_time is None: + raise CliError(f"{bez_path.name} contains no readable log records.") + start_time = last_time - last_delta + + count = 0 + with mcap_path.open("wb") as mcap_file: + writer = Writer(mcap_file) + for _, delta, msg_type, msg in LogStream(log_bytes): + timestamp_ns = int((start_time + delta).timestamp() * 1e9) + writer.write_message( + topic=msg_type.__name__, + message=msg._pb, + log_time=timestamp_ns, + publish_time=timestamp_ns, + ) + count += 1 + writer.finish() + return count diff --git a/docs/logs/foxglove-bez-to-mcap.md b/docs/logs/foxglove-bez-to-mcap.md index 46f8bb2d..942056f7 100644 --- a/docs/logs/foxglove-bez-to-mcap.md +++ b/docs/logs/foxglove-bez-to-mcap.md @@ -1,6 +1,17 @@ # Visualize dive log sensor data with Foxglove With some simple steps you can visualize dive log data with ease in Foxglove. This is a great tool to play back and visualize control signals and estimated states and other sensor data from the dive. +!!! tip "One-step download and convert" + The `blueye` CLI (installed with `pip install "blueye.sdk[cli]"`) can download and + convert in one go: + + ```shell + blueye logs download --latest 1 --mcap + ``` + + This fetches the newest log from the drone and writes both the `.bez` and a + Foxglove-ready `.mcap` next to it. Then continue from step 5 below. + 1. Download foxglove [here](https://foxglove.dev/download) and create an account. 2. Download a divelog from the drone as shown [here](https://blueye-robotics.github.io/blueye.sdk/latest/logs/listing-and-downloading/). 3. Run `pip install "blueye.sdk[examples]"` to get the necessary dependencies, if you have not done so already. diff --git a/docs/logs/listing-and-downloading.md b/docs/logs/listing-and-downloading.md index 37cc0981..1c6e6318 100644 --- a/docs/logs/listing-and-downloading.md +++ b/docs/logs/listing-and-downloading.md @@ -14,6 +14,7 @@ SDK's `[cli]` extra), which connects to the drone as an observer — taking no c ```shell blueye logs list # table of logs on the drone blueye logs download --latest 1 # newest log to the current directory +blueye logs download --latest 1 --mcap # ...and convert it for Foxglove blueye logs download BYEDP000000_ea9ac92e1817a1d4_00002 -o ~/dives blueye logs # interactive: pick logs to download ``` diff --git a/pyproject.toml b/pyproject.toml index e5173fec..bfe5015c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ cli = [ "questionary>=2.0,<3", # TOML parser for reading third-party tools' PEP 723 metadata; stdlib from 3.11. "tomli>=2,<3; python_version < '3.11'", + # .bez -> .mcap conversion (`blueye logs download --mcap`). + "mcap-protobuf-support>=0.5.3,<0.6", ] # These are dependencies that are not necessary for the core functionality of the SDK, but are # necessary for some of the examples. @@ -61,6 +63,7 @@ dev = [ "rich>=13,<15", "questionary>=2.0,<3", "tomli>=2,<3; python_version < '3.11'", + "mcap-protobuf-support>=0.5.3,<0.6", "pytest~=8.3", "pytest-mock~=3.11", "mike~=2.1", diff --git a/tests/test_cli_logs_command.py b/tests/test_cli_logs_command.py index e3934bd7..d683fc3f 100644 --- a/tests/test_cli_logs_command.py +++ b/tests/test_cli_logs_command.py @@ -116,6 +116,9 @@ def checkbox(self, question, choices, flag): def text(self, question, default, flag): return str(tmp_path) + def confirm(self, question, default, flag): + return default # Decline the .mcap conversion. + mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch("sys.stdout.isatty", return_value=True) mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=FakePrompter()) @@ -136,3 +139,98 @@ def checkbox(self, question, choices, flag): assert main(["logs"]) == 0 LogFile.download.assert_not_called() assert "Nothing selected" in capsys.readouterr().out + + +class TestMcapConversion: + @pytest.fixture + def bez_file(self, tmp_path): + """A tiny real .bez (uncompressed binlog records) built with protobuf.""" + import blueye.protocol as bp + + from tests.test_logs import create_real_binlog_record + + records = b"" + for seconds in (100, 101, 102): + payload = bp.DepthTel(depth=bp.Depth(value=float(seconds))) + records += create_real_binlog_record(1700000000 + seconds, seconds, payload) + path = tmp_path / "dive.bez" + path.write_bytes(records) + return path + + def test_convert_writes_valid_mcap(self, bez_file, tmp_path): + from blueye.sdk.cli.commands.logs.mcap import convert_bez_to_mcap + + mcap_path = tmp_path / "dive.mcap" + count = convert_bez_to_mcap(bez_file, mcap_path) + assert count == 3 + content = mcap_path.read_bytes() + assert content.startswith(b"\x89MCAP") # MCAP magic bytes. + assert len(content) > 100 + + def test_convert_empty_log_errors(self, tmp_path): + from blueye.sdk.cli.commands.logs.mcap import convert_bez_to_mcap + from blueye.sdk.cli.errors import CliError + + empty = tmp_path / "empty.bez" + empty.write_bytes(b"") + with pytest.raises(CliError, match="no readable log records"): + convert_bez_to_mcap(empty, tmp_path / "empty.mcap") + + def test_download_mcap_flag_converts(self, drone, mocker, tmp_path): + convert = mocker.patch( + "blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap", return_value=5 + ) + assert ( + main( + [ + "logs", + "download", + "BYEDP000000_aaaa_00000", + "-o", + str(tmp_path), + "--mcap", + ] + ) + == 0 + ) + convert.assert_called_once_with( + tmp_path / "BYEDP000000_aaaa_00000.bez", tmp_path / "BYEDP000000_aaaa_00000.mcap" + ) + + def test_download_without_mcap_flag_does_not_convert(self, drone, mocker, tmp_path): + convert = mocker.patch("blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap") + assert main(["logs", "download", "--all", "-o", str(tmp_path)]) == 0 + convert.assert_not_called() + + def test_missing_mcap_dependency_gives_guidance(self, drone, mocker, tmp_path, capsys): + def fake_missing(names): + return [name for name in names if name == "mcap_protobuf"] + + mocker.patch("blueye.sdk.cli.deps.missing", side_effect=fake_missing) + exit_code = main(["logs", "download", "--all", "-o", str(tmp_path), "--mcap"]) + assert exit_code == 1 + captured = capsys.readouterr() + assert "blueye.sdk[cli]" in captured.out + assert "mcap" in captured.err + + def test_interactive_offers_mcap_conversion(self, drone, mocker, tmp_path): + convert = mocker.patch( + "blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap", return_value=5 + ) + + class FakePrompter: + def checkbox(self, question, choices, flag): + return [choices[0]] + + def text(self, question, default, flag): + return str(tmp_path) + + def confirm(self, question, default, flag): + return "mcap" in question # Say yes to the conversion confirm. + + mocker.patch("sys.stdin.isatty", return_value=True) + mocker.patch("sys.stdout.isatty", return_value=True) + mocker.patch("blueye.sdk.cli.prompts.QuestionaryPrompter", return_value=FakePrompter()) + + assert main(["logs"]) == 0 + convert.assert_called_once() diff --git a/uv.lock b/uv.lock index 460fcb65..b5b84c66 100644 --- a/uv.lock +++ b/uv.lock @@ -133,6 +133,7 @@ dependencies = [ [package.optional-dependencies] cli = [ + { name = "mcap-protobuf-support" }, { name = "onnx" }, { name = "questionary" }, { name = "rich" }, @@ -154,6 +155,7 @@ dev = [ { name = "black" }, { name = "essentials-openapi" }, { name = "freezegun" }, + { name = "mcap-protobuf-support" }, { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-gen-files" }, @@ -180,6 +182,7 @@ requires-dist = [ { name = "foxglove-websocket", marker = "extra == 'examples'", specifier = ">=0.1.2,<0.2" }, { name = "inputs", marker = "extra == 'examples'", specifier = ">=0.5,<0.6" }, { name = "matplotlib", marker = "extra == 'examples'", specifier = "~=3.10" }, + { name = "mcap-protobuf-support", marker = "extra == 'cli'", specifier = ">=0.5.3,<0.6" }, { name = "mcap-protobuf-support", marker = "extra == 'examples'", specifier = ">=0.5.3,<0.6" }, { name = "onnx", marker = "extra == 'cli'", specifier = ">=1.16,<2" }, { name = "packaging", specifier = ">=24.2" }, @@ -202,6 +205,7 @@ dev = [ { name = "black", specifier = "~=26.5" }, { name = "essentials-openapi", specifier = ">=1.3.0,<2" }, { name = "freezegun", specifier = "~=1.2" }, + { name = "mcap-protobuf-support", specifier = ">=0.5.3,<0.6" }, { name = "mike", specifier = "~=2.1" }, { name = "mkdocs", specifier = "~=1.5" }, { name = "mkdocs-gen-files", specifier = ">=0.5.0,<0.6" }, From f748cd2a92be2e46a61ec51c63c58806e3cda54e Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 14:10:45 +0200 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20CLI=20in=20core=20deps,=20single=20docs=20page,=20curl=20end?= =?UTF-8?q?point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies sindrehan's review suggestions: - The CLI is no longer optional: rich, questionary, and the tomli backport move into the core dependencies, so `blueye` (including the models and tools commands) works after a plain `pip install blueye.sdk`. The `[cli]` extra now carries only the heavyweight onnx dependency needed by bundle-model, which keeps gating on it with the existing install guidance (verified in a fresh no-extra venv). - CLI documentation consolidated into a single docs/cli.md with one section per command (bundle-model, models, third-party tools), replacing bundling-cv-models.md and extending-the-cli.md in the nav. The "Future work: pip-installable plugins" section is dropped from the docs (the design note lives in the blueye.sdk.cli.commands docstring); the built-in-command recipe becomes a pointer to that docstring. - The deploy section now documents the raw HTTP upload endpoint (curl -F "file=@pkg.zip" http://192.168.1.101/api/cv-models/upload) alongside the Web App, for uploads without the SDK installed. Co-Authored-By: Claude Fable 5 --- .../sdk/cli/commands/bundle_model/__init__.py | 2 +- blueye/sdk/cli/commands/models/__init__.py | 2 +- blueye/sdk/cli/commands/tools/__init__.py | 2 +- blueye/sdk/cli/deps.py | 6 +- docs/bundling-cv-models.md | 128 ---------- docs/cli.md | 223 ++++++++++++++++++ docs/extending-the-cli.md | 117 --------- mkdocs.yml | 3 +- pyproject.toml | 17 +- uv.lock | 18 +- 10 files changed, 244 insertions(+), 274 deletions(-) delete mode 100644 docs/bundling-cv-models.md create mode 100644 docs/cli.md delete mode 100644 docs/extending-the-cli.md diff --git a/blueye/sdk/cli/commands/bundle_model/__init__.py b/blueye/sdk/cli/commands/bundle_model/__init__.py index 1b1152f9..416c8a51 100644 --- a/blueye/sdk/cli/commands/bundle_model/__init__.py +++ b/blueye/sdk/cli/commands/bundle_model/__init__.py @@ -8,7 +8,7 @@ COMMAND = CommandSpec( name="bundle-model", help="Bundle an ONNX model into a BlueyeCV model-package zip", - requires=("onnx", "rich", "questionary"), + requires=("onnx",), # rich/questionary are core SDK dependencies. add_parser=add_parser, run=run, ) diff --git a/blueye/sdk/cli/commands/models/__init__.py b/blueye/sdk/cli/commands/models/__init__.py index 67c95aa9..9cbf4d10 100644 --- a/blueye/sdk/cli/commands/models/__init__.py +++ b/blueye/sdk/cli/commands/models/__init__.py @@ -8,7 +8,7 @@ COMMAND = CommandSpec( name="models", help="Manage the CV model packages installed on the drone", - requires=("rich", "questionary"), + requires=(), # rich/questionary are core SDK dependencies. add_parser=add_parser, run=run, ) diff --git a/blueye/sdk/cli/commands/tools/__init__.py b/blueye/sdk/cli/commands/tools/__init__.py index 2ec9d9e4..86e31624 100644 --- a/blueye/sdk/cli/commands/tools/__init__.py +++ b/blueye/sdk/cli/commands/tools/__init__.py @@ -8,7 +8,7 @@ COMMAND = CommandSpec( name="tools", help="List, validate, install, and uninstall third-party CLI tools", - requires=(), # The bootstrap surface must run with zero optional extras. + requires=(), add_parser=add_parser, run=run, ) diff --git a/blueye/sdk/cli/deps.py b/blueye/sdk/cli/deps.py index c510fdd6..41da45a4 100644 --- a/blueye/sdk/cli/deps.py +++ b/blueye/sdk/cli/deps.py @@ -1,9 +1,9 @@ """Optional-dependency detection and install guidance for the `blueye` CLI. This module must only import from the standard library: it runs precisely when the -optional `[cli]` extra (onnx, rich, questionary) is not installed, and its job is to -tell the user how to install it on their platform instead of failing with an -ImportError. +optional `[cli]` extra (the heavyweight onnx dependency used by `bundle-model`) is +not installed, and its job is to tell the user how to install it on their platform +instead of failing with an ImportError. """ from __future__ import annotations diff --git a/docs/bundling-cv-models.md b/docs/bundling-cv-models.md deleted file mode 100644 index 4e68dc16..00000000 --- a/docs/bundling-cv-models.md +++ /dev/null @@ -1,128 +0,0 @@ -# Bundling CV models - -Blueye drones with an onboard GPU can run your own computer vision models: object -detection, instance segmentation, and single-object tracking. The drone's vision -pipeline consumes **model packages** — a zip containing an ONNX model and a -`model_meta.json` file that describes how to preprocess frames and decode the model's -outputs. - -The `blueye bundle-model` command turns an exported ONNX file into such a package. It - -- validates that the model is of a supported type, -- auto-generates `model_meta.json` from the ONNX graph and any embedded metadata - (Ultralytics exports carry their class names and input size along), -- interactively asks about anything that cannot be inferred, and -- writes a deployable zip. - -## Installation - -The CLI needs a few extra packages, installed with the SDK's `cli` extra: - -```shell -pip install "blueye.sdk[cli]" -``` - -or with [uv](https://docs.astral.sh/uv/): - -```shell -uv pip install "blueye.sdk[cli]" -``` - -(Keep the quotes — most shells treat square brackets specially.) If the extra is -missing, `blueye bundle-model` will detect it and print the install command for your -platform instead of failing. - -## Interactive use - -Point the command at your ONNX file and answer the prompts: - -```shell -blueye bundle-model path/to/model.onnx -``` - -The CLI inspects the model, shows what it inferred (output format, class count, input -size, labels), and walks through the remaining choices with interactive prompts — model -name, tracking algorithm, and the runtime configuration for the drone: - -- **Execution device** — the CLI analyzes the network and recommends the Jetson DLA - cores (`tensorrt-dla0`/`tensorrt-dla1`) for convolution-style models, which frees the - GPU for other work. Models with layers the DLA cannot run (NMS-in-graph, - transformers) get `tensorrt` recommended instead. You can always pick any device. -- **Inference rate** — maximum rate in Hz, defaulting to unlimited. -- **Autolaunch** — whether the drone should start this model automatically. Defaults - to enabled; pass `--no-runtime-enabled` to bundle the package disabled. - -The result is a zip with `model.onnx` and `model_meta.json` at its root. - -## Scripted use - -Every prompt can be answered with a flag, and `--yes` accepts all inferred defaults: - -```shell -blueye bundle-model yolov8n.onnx --yes \ - --name "YOLOv8n (COCO)" \ - --tracking byte_track \ - --runtime-device tensorrt-dla0 --runtime-hz 10 --runtime-enabled \ - --output yolov8n_package.zip -``` - -Use `--dry-run` to print the generated `model_meta.json` without writing anything, and -`--labels labels.txt` (one class name per line) when the model does not embed its class -names. - -## Supported model types - -| Output format | Model family | -| -------------- | ----------------------------------------------- | -| `yolov2_grid` | YOLOv2 / TinyYOLOv2 (grid + anchors) | -| `yolov5_flat` | YOLOv5 ONNX export | -| `yolov8_flat` | YOLOv8 / YOLO11 ONNX export | -| `yolov8_seg` | YOLOv8/v11 segmentation | -| `yolo_e2e` | End-to-end YOLO with NMS in the model (YOLO26) | -| `yolo_e2e_seg` | End-to-end YOLO segmentation | -| `ssd_multi` | SSD (multi-output, e.g. TensorFlow exports) | -| `detr` | DETR transformer detectors | -| `ostrack` | OSTrack single-object tracker | -| `mixformerv2` | MixFormerV2 single-object tracker | - -The model must take float32 image input; models with a clearly unsupported structure -(image classifiers, float16 inputs, non-image inputs) are rejected with an explanation. - -## Deploying to the drone - -The easiest way is to push the package directly: interactive runs offer it after the -zip is written, and scripts pass `--push` (with `--drone-ip` if the drone is not at -the default `192.168.1.101`): - -```shell -blueye bundle-model yolov8n.onnx --yes --push -``` - -If the drone cannot be reached, the command fails with a clear message — the zip is -still written and can be pushed later. - -Installed models are managed with the `blueye models` command (or interactively by -running it without arguments on a terminal): - -```shell -blueye models list -blueye models enable yolov8n-coco -blueye models set-device yolov8n-coco tensorrt-dla0 -blueye models warmup yolov8n-coco # pre-build the TensorRT engine -``` - -The same operations are available programmatically through the SDK: - -```python -import blueye.sdk - -drone = blueye.sdk.Drone(auto_connect=False) # HTTP only, takes no control -model = drone.cv_models.upload("yolov8n_package.zip") -drone.cv_models.set_enabled(model.directory, True) -``` - -Note: the `enabled` state is the autolaunch configuration; the API does not expose a -live "running" status. - -Manual alternative: use the Blunux Web App — open `http://192.168.1.101` in a -browser and upload the zip from the Computer Vision tab. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..fc20fc30 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,223 @@ +# The blueye CLI + +The SDK ships with a `blueye` command line interface for working with the drone from +the terminal. It is installed together with the SDK: + +```shell +pip install blueye.sdk +``` + +Running `blueye --help` lists the available commands. One command needs an extra: +`blueye bundle-model` inspects ONNX files and requires the heavyweight `onnx` package, +installed with the SDK's `cli` extra: + +```shell +pip install "blueye.sdk[cli]" +``` + +(Keep the quotes — most shells treat square brackets specially.) If the extra is +missing, `bundle-model` detects it and prints the install command for your platform +instead of failing. + +## Bundling CV models — `blueye bundle-model` + +Blueye drones with an onboard GPU can run your own computer vision models: object +detection, instance segmentation, and single-object tracking. The drone's vision +pipeline consumes **model packages** — a zip containing an ONNX model and a +`model_meta.json` file that describes how to preprocess frames and decode the model's +outputs. + +The `blueye bundle-model` command turns an exported ONNX file into such a package. It + +- validates that the model is of a supported type, +- auto-generates `model_meta.json` from the ONNX graph and any embedded metadata + (Ultralytics exports carry their class names and input size along), +- interactively asks about anything that cannot be inferred, and +- writes a deployable zip. + +### Interactive use + +Point the command at your ONNX file and answer the prompts: + +```shell +blueye bundle-model path/to/model.onnx +``` + +The CLI inspects the model, shows what it inferred (output format, class count, input +size, labels), and walks through the remaining choices with interactive prompts — model +name, tracking algorithm, and the runtime configuration for the drone: + +- **Execution device** — the CLI analyzes the network and recommends the Jetson DLA + cores (`tensorrt-dla0`/`tensorrt-dla1`) for convolution-style models, which frees the + GPU for other work. Models with layers the DLA cannot run (NMS-in-graph, + transformers) get `tensorrt` recommended instead. You can always pick any device. +- **Inference rate** — maximum rate in Hz, defaulting to unlimited. +- **Autolaunch** — whether the drone should start this model automatically. Defaults + to enabled; pass `--no-runtime-enabled` to bundle the package disabled. + +The result is a zip with `model.onnx` and `model_meta.json` at its root. + +### Scripted use + +Every prompt can be answered with a flag, and `--yes` accepts all inferred defaults: + +```shell +blueye bundle-model yolov8n.onnx --yes \ + --name "YOLOv8n (COCO)" \ + --tracking byte_track \ + --runtime-device tensorrt-dla0 --runtime-hz 10 --runtime-enabled \ + --output yolov8n_package.zip +``` + +Use `--dry-run` to print the generated `model_meta.json` without writing anything, and +`--labels labels.txt` (one class name per line) when the model does not embed its class +names. + +### Supported model types + +| Output format | Model family | +| -------------- | ----------------------------------------------- | +| `yolov2_grid` | YOLOv2 / TinyYOLOv2 (grid + anchors) | +| `yolov5_flat` | YOLOv5 ONNX export | +| `yolov8_flat` | YOLOv8 / YOLO11 ONNX export | +| `yolov8_seg` | YOLOv8/v11 segmentation | +| `yolo_e2e` | End-to-end YOLO with NMS in the model (YOLO26) | +| `yolo_e2e_seg` | End-to-end YOLO segmentation | +| `ssd_multi` | SSD (multi-output, e.g. TensorFlow exports) | +| `detr` | DETR transformer detectors | +| `ostrack` | OSTrack single-object tracker | +| `mixformerv2` | MixFormerV2 single-object tracker | + +The model must take float32 image input; models with a clearly unsupported structure +(image classifiers, float16 inputs, non-image inputs) are rejected with an explanation. + +### Deploying to the drone + +The easiest way is to push the package directly: interactive runs offer it after the +zip is written, and scripts pass `--push` (with `--drone-ip` if the drone is not at +the default `192.168.1.101`): + +```shell +blueye bundle-model yolov8n.onnx --yes --push +``` + +If the drone cannot be reached, the command fails with a clear message — the zip is +still written and can be pushed later. + +The same operations are available programmatically through the SDK: + +```python +import blueye.sdk + +drone = blueye.sdk.Drone(auto_connect=False) # HTTP only, takes no control +model = drone.cv_models.upload("yolov8n_package.zip") +drone.cv_models.set_enabled(model.directory, True) +``` + +Without the SDK installed, the drone's HTTP endpoint accepts the package zip as a +multipart upload (field name `file`): + +```shell +curl -F "file=@yolov8n_package.zip" http://192.168.1.101/api/cv-models/upload +``` + +or use the Blunux Web App — open `http://192.168.1.101` in a browser and upload the +zip from the Computer Vision tab. + +## Managing models on the drone — `blueye models` + +Installed models are managed with the `blueye models` command, or interactively by +running it without arguments on a terminal: + +```shell +blueye models list +blueye models enable yolov8n-coco +blueye models set-device yolov8n-coco tensorrt-dla0 +blueye models warmup yolov8n-coco # pre-build the TensorRT engine +blueye models delete yolov8n-coco +``` + +Model names are the directory slugs shown by `blueye models list`. Note: the +`enabled` state is the autolaunch configuration; the API does not expose a live +"running" status. + +## Third-party tools — `blueye tools` + +The `blueye` command is built to grow: besides the built-in commands, anyone can drop +single-file Python tools into a per-user directory. The CLI discovers them +automatically, lists them in `blueye --help`, and runs them as +`blueye ...` — no SDK changes needed. + +### Writing a tool + +A tool is a normal Python script carrying [PEP 723](https://peps.python.org/pep-0723/) +inline metadata, extended with a `[tool.blueye]` table: + +```python +# /// script +# requires-python = ">=3.10" +# dependencies = ["pandas"] +# +# [tool.blueye] +# name = "export-logs" +# description = "Export dive logs to CSV" +# min-sdk-version = "2.7.0" +# /// +import sys + +import pandas as pd + + +def main() -> int: + print(f"exporting with args: {sys.argv[1:]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +The `[tool.blueye]` keys: + +| Key | Required | Meaning | +| ----------------- | -------- | -------------------------------------------------------------- | +| `name` | yes | The subcommand name (`blueye export-logs`). Lowercase letters, digits, and hyphens; must start with a letter; at most 32 characters. | +| `description` | yes | One line shown in `blueye --help` and `blueye tools list`. | +| `min-sdk-version` | no | Minimum blueye.sdk version; a mismatch prints a warning but never blocks. | + +Arguments after the tool name are passed to the script verbatim, and its exit code +becomes the CLI's exit code. Invocation is strictly `blueye args...`. + +**Dependencies**: when the script declares PEP 723 `dependencies` and +[uv](https://docs.astral.sh/uv/) is installed, the CLI runs it with `uv run`, giving +the script an isolated environment with those dependencies — your tool can use pandas +without pandas ever being installed next to the SDK. Without uv, the script runs with +the current interpreter and must find its dependencies there. + +### Installing and managing tools + +```shell +blueye tools validate my_script.py # check the metadata before installing +blueye tools install my_script.py # copy it into the tools directory +blueye tools list # built-ins + installed tools +blueye tools uninstall export-logs +blueye tools dir # print the resolved tools directory +``` + +Discovery scans the tools directory on every invocation and parses **only the +metadata block — tool code is never executed during discovery** or listing. + +The directory is resolved from the `BLUEYE_CLI_TOOLS_DIR` environment variable when +set, otherwise from the platform default: + +| Platform | Default tools directory | +| -------- | ---------------------------------------------------- | +| macOS | `~/Library/Application Support/blueye/cli-tools` | +| Linux | `$XDG_DATA_HOME/blueye/cli-tools` (or `~/.local/share/blueye/cli-tools`) | +| Windows | `%APPDATA%\blueye\cli-tools` | + +Name collisions always resolve in favor of built-in commands; `blueye tools list` +shows shadowed or invalid tools with the reason. + +SDK contributors adding a **built-in** command should follow the recipe and +invariants documented in the `blueye.sdk.cli.commands` module docstring. diff --git a/docs/extending-the-cli.md b/docs/extending-the-cli.md deleted file mode 100644 index 2503a13b..00000000 --- a/docs/extending-the-cli.md +++ /dev/null @@ -1,117 +0,0 @@ -# Extending the blueye CLI - -The `blueye` command is built to grow. There are two ways to add commands: - -- **Built-in commands** live in the SDK itself, under `blueye/sdk/cli/commands/`, and - ship with every release — this is how `bundle-model` and `tools` are implemented. -- **Third-party tools** are single-file Python scripts that anyone can drop into a - per-user tools directory. The CLI discovers them automatically, lists them in - `blueye --help`, and runs them as `blueye ...` — no SDK changes needed. - -## Writing a third-party tool - -A tool is a normal Python script carrying [PEP 723](https://peps.python.org/pep-0723/) -inline metadata, extended with a `[tool.blueye]` table: - -```python -# /// script -# requires-python = ">=3.10" -# dependencies = ["pandas"] -# -# [tool.blueye] -# name = "export-logs" -# description = "Export dive logs to CSV" -# min-sdk-version = "2.7.0" -# /// -import sys - -import pandas as pd - - -def main() -> int: - print(f"exporting with args: {sys.argv[1:]}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -The `[tool.blueye]` keys: - -| Key | Required | Meaning | -| ----------------- | -------- | -------------------------------------------------------------- | -| `name` | yes | The subcommand name (`blueye export-logs`). Lowercase letters, digits, and hyphens; must start with a letter; at most 32 characters. | -| `description` | yes | One line shown in `blueye --help` and `blueye tools list`. | -| `min-sdk-version` | no | Minimum blueye.sdk version; a mismatch prints a warning but never blocks. | - -Arguments after the tool name are passed to the script verbatim, and its exit code -becomes the CLI's exit code. Invocation is strictly `blueye args...`. - -**Dependencies**: when the script declares PEP 723 `dependencies` and -[uv](https://docs.astral.sh/uv/) is installed, the CLI runs it with `uv run`, giving -the script an isolated environment with those dependencies — your tool can use pandas -without pandas ever being installed next to the SDK. Without uv, the script runs with -the current interpreter and must find its dependencies there. - -## Installing and managing tools - -```shell -blueye tools validate my_script.py # check the metadata before installing -blueye tools install my_script.py # copy it into the tools directory -blueye tools list # built-ins + installed tools -blueye tools uninstall export-logs -blueye tools dir # print the resolved tools directory -``` - -Discovery scans the tools directory on every invocation and parses **only the -metadata block — tool code is never executed during discovery** or listing. - -The directory is resolved from the `BLUEYE_CLI_TOOLS_DIR` environment variable when -set, otherwise from the platform default: - -| Platform | Default tools directory | -| -------- | ---------------------------------------------------- | -| macOS | `~/Library/Application Support/blueye/cli-tools` | -| Linux | `$XDG_DATA_HOME/blueye/cli-tools` (or `~/.local/share/blueye/cli-tools`) | -| Windows | `%APPDATA%\blueye\cli-tools` | - -Name collisions always resolve in favor of built-in commands; `blueye tools list` -shows shadowed or invalid tools with the reason. - -## Adding a built-in command (SDK contributors) - -Each built-in command is a self-contained package under `blueye/sdk/cli/commands/` -exposing a `COMMAND` spec: - -```python -# blueye/sdk/cli/commands/my_command/__init__.py -from .. import CommandSpec -from .command import add_parser, run - -COMMAND = CommandSpec( - name="my-command", - help="One line shown in `blueye --help`", - requires=("rich",), # optional deps gated before run(); () if stdlib-only - add_parser=add_parser, # argparse-only argument definitions - run=run, # returns the exit code; heavy imports go inside -) -``` - -Register it in `all_commands()` in `blueye/sdk/cli/commands/__init__.py` — that is the -only central change. The invariants: - -- The command package must be importable with **zero optional extras** installed - (`blueye --help` runs before the `[cli]` extra exists). Import onnx/rich/questionary - inside `run`, never at module level. -- Declare optional imports in `requires`; the CLI prints install guidance and exits - with code 2 when they are missing. -- Raise `blueye.sdk.cli.errors.CliError` for user-facing failures — it is printed as a - clean message, never a traceback. - -## Future work: pip-installable plugins - -A third route is planned but not yet implemented: packages registering a -`CommandSpec` under a `blueye.cli` [entry-point group](https://packaging.python.org/en/latest/specifications/entry-points/), -so `pip install blueye-tool-x` would add a subcommand. The `CommandSpec` contract -above is designed to be that plugin interface unchanged. diff --git a/mkdocs.yml b/mkdocs.yml index b856582f..134b6c66 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,8 +108,7 @@ nav: - "Visualize live sensor data": "foxglove-bridge.md" - "Forwarding positioning to NMEA": "nmea-publisher.md" - "Mission Planning": "mission-planning.md" - - "Bundling CV models": "bundling-cv-models.md" - - "Extending the blueye CLI": "extending-the-cli.md" + - "The blueye CLI": "cli.md" - "Odometer forwarding": odometer-to-831l.md - "Updating from v1 to v2": "migrating-to-v2.md" - "HTTP API": "http-api.md" diff --git a/pyproject.toml b/pyproject.toml index e5173fec..35cb53de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,20 +22,22 @@ dependencies = [ # cp313 and has to be compiled from source on newer interpreters. "pyzmq>=26,<28", "proto-plus>=1.22.2,<2", + # The `blueye` CLI ships with the SDK; these power its terminal UI and the + # PEP 723 metadata parsing for third-party tools. + "rich>=13,<15", + "questionary>=2.0,<3", + "tomli>=2,<3; python_version < '3.11'", ] [project.scripts] blueye = "blueye.sdk.cli:main" [project.optional-dependencies] -# Dependencies for the `blueye` command line interface (e.g. `blueye bundle-model`). Install -# with: pip install "blueye.sdk[cli]" +# Heavyweight dependencies for `blueye bundle-model` (ONNX model introspection). +# The rest of the CLI works without this extra. Install with: +# pip install "blueye.sdk[cli]" cli = [ "onnx>=1.16,<2", - "rich>=13,<15", - "questionary>=2.0,<3", - # TOML parser for reading third-party tools' PEP 723 metadata; stdlib from 3.11. - "tomli>=2,<3; python_version < '3.11'", ] # These are dependencies that are not necessary for the core functionality of the SDK, but are # necessary for some of the examples. @@ -58,9 +60,6 @@ Repository = "https://github.com/blueye-robotics/blueye.sdk" dev = [ # The CLI extra, repeated here so the test suite can exercise the CLI modules. "onnx>=1.16,<2", - "rich>=13,<15", - "questionary>=2.0,<3", - "tomli>=2,<3; python_version < '3.11'", "pytest~=8.3", "pytest-mock~=3.11", "mike~=2.1", diff --git a/uv.lock b/uv.lock index 460fcb65..685d7d35 100644 --- a/uv.lock +++ b/uv.lock @@ -127,16 +127,16 @@ dependencies = [ { name = "proto-plus" }, { name = "python-dateutil" }, { name = "pyzmq" }, + { name = "questionary" }, { name = "requests" }, + { name = "rich" }, { name = "tabulate" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.optional-dependencies] cli = [ { name = "onnx" }, - { name = "questionary" }, - { name = "rich" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] examples = [ { name = "asciimatics" }, @@ -167,10 +167,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, - { name = "questionary" }, { name = "requests-mock" }, - { name = "rich" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] @@ -188,11 +185,11 @@ requires-dist = [ { name = "pyserial", marker = "extra == 'examples'", specifier = "~=3.5" }, { name = "python-dateutil", specifier = ">=2.8.2,<3" }, { name = "pyzmq", specifier = ">=26,<28" }, - { name = "questionary", marker = "extra == 'cli'", specifier = ">=2.0,<3" }, + { name = "questionary", specifier = ">=2.0,<3" }, { name = "requests", specifier = ">=2.22.0,<3" }, - { name = "rich", marker = "extra == 'cli'", specifier = ">=13,<15" }, + { name = "rich", specifier = ">=13,<15" }, { name = "tabulate", specifier = ">=0.9,<0.10" }, - { name = "tomli", marker = "python_full_version < '3.11' and extra == 'cli'", specifier = ">=2,<3" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2,<3" }, { name = "webdavclient3", marker = "extra == 'examples'", specifier = ">=3.14.6,<4" }, ] provides-extras = ["cli", "examples"] @@ -215,10 +212,7 @@ dev = [ { name = "pytest", specifier = "~=8.3" }, { name = "pytest-cov", specifier = "~=6.0" }, { name = "pytest-mock", specifier = "~=3.11" }, - { name = "questionary", specifier = ">=2.0,<3" }, { name = "requests-mock", specifier = "~=1.11" }, - { name = "rich", specifier = ">=13,<15" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2,<3" }, ] [[package]] From 3ddfab8b932cd9601333f0c39142b378c57da81d Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 14:13:19 +0200 Subject: [PATCH 10/17] docs: fold `blueye logs` into the CLI page, add quick-start example Follow-up to the review changes merged from jp-pino/bundle-model-cli: - The logs command joins the consolidated docs/cli.md as a "Downloading dive logs" section (incl. --mcap); the logs guide and the Foxglove doc keep short pointers to the CLI page. - The quick start gains a "Try the command line interface" section showcasing `blueye logs download --latest 1` with links to the CLI page. - With rich/questionary in the core dependencies, the logs command declares requires=() (the --mcap path keeps its runtime gate); mcap-protobuf-support stays in the [cli] extra alongside onnx. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/logs/__init__.py | 2 +- docs/cli.md | 18 ++++++++++++++++++ docs/logs/foxglove-bez-to-mcap.md | 4 ++-- docs/logs/listing-and-downloading.md | 4 ++-- docs/quick_start.md | 11 +++++++++++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/blueye/sdk/cli/commands/logs/__init__.py b/blueye/sdk/cli/commands/logs/__init__.py index e033f418..a217b55e 100644 --- a/blueye/sdk/cli/commands/logs/__init__.py +++ b/blueye/sdk/cli/commands/logs/__init__.py @@ -8,7 +8,7 @@ COMMAND = CommandSpec( name="logs", help="List and download dive logs from the drone", - requires=("rich", "questionary"), + requires=(), # rich/questionary are core SDK dependencies; --mcap gates at runtime. add_parser=add_parser, run=run, ) diff --git a/docs/cli.md b/docs/cli.md index fc20fc30..bae0de03 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -141,6 +141,24 @@ Model names are the directory slugs shown by `blueye models list`. Note: the `enabled` state is the autolaunch configuration; the API does not expose a live "running" status. +## Downloading dive logs — `blueye logs` + +The drone's binary dive logs (`.bez`) can be listed and downloaded from the terminal. +The command connects to the drone as an observer — taking no control: + +```shell +blueye logs list # table of logs on the drone +blueye logs download --latest 1 # newest log to the current directory +blueye logs download --latest 1 --mcap # ...and convert it for Foxglove +blueye logs download BYEDP000000_ea9ac92e1817a1d4_00002 -o ~/dives +blueye logs # interactive: pick logs to download +``` + +`--mcap` converts each downloaded log to a Foxglove-ready `.mcap` next to the +`.bez` — see [visualizing dive logs with Foxglove](logs/foxglove-bez-to-mcap.md). +For working with logs from Python (streaming, filtering, plotting), see +[logs from the drone](logs/listing-and-downloading.md). + ## Third-party tools — `blueye tools` The `blueye` command is built to grow: besides the built-in commands, anyone can drop diff --git a/docs/logs/foxglove-bez-to-mcap.md b/docs/logs/foxglove-bez-to-mcap.md index 942056f7..8beda155 100644 --- a/docs/logs/foxglove-bez-to-mcap.md +++ b/docs/logs/foxglove-bez-to-mcap.md @@ -2,8 +2,8 @@ With some simple steps you can visualize dive log data with ease in Foxglove. This is a great tool to play back and visualize control signals and estimated states and other sensor data from the dive. !!! tip "One-step download and convert" - The `blueye` CLI (installed with `pip install "blueye.sdk[cli]"`) can download and - convert in one go: + The [`blueye` CLI](../cli.md) (installed with the SDK) can download and convert + in one go: ```shell blueye logs download --latest 1 --mcap diff --git a/docs/logs/listing-and-downloading.md b/docs/logs/listing-and-downloading.md index 1c6e6318..4da9199d 100644 --- a/docs/logs/listing-and-downloading.md +++ b/docs/logs/listing-and-downloading.md @@ -8,8 +8,8 @@ When the drone is powered on a new log file is created, where it stores telemetr ## From the command line -The binary logs are also available through the `blueye` CLI (installed with the -SDK's `[cli]` extra), which connects to the drone as an observer — taking no control: +The binary logs are also available through the [`blueye` CLI](../cli.md), which is +installed with the SDK and connects to the drone as an observer — taking no control: ```shell blueye logs list # table of logs on the drone diff --git a/docs/quick_start.md b/docs/quick_start.md index 34fb16a8..d882b7c7 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -152,6 +152,17 @@ The normal Blueye app cannot be used to spectate when controlling the drone from it will interfere with the commands sent from the SDK. The Observer app, however, is only a spectator and can be used together with the SDK. +### Try the command line interface +The SDK also installs a [`blueye` command](cli.md) for common tasks straight from the +terminal — for example, grabbing the newest dive log from the drone: + +```shell +blueye logs download --latest 1 +``` + +Run `blueye --help` to see everything it can do, or read more on +[the blueye CLI page](cli.md). + ### Explore the examples For further examples on how to use the SDK to control the drone have a look at the [motion examples](movement/from-the-CLI.md). From 0ec14ad6168f09d7ff16d3d5ec11026fd24b9576 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 14:16:25 +0200 Subject: [PATCH 11/17] docs: name the Blueye X3 Ultra as the CV-capable model Co-Authored-By: Claude Fable 5 --- blueye/sdk/cv_models.py | 2 +- docs/cli.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/blueye/sdk/cv_models.py b/blueye/sdk/cv_models.py index 53f5351c..ea9879a6 100644 --- a/blueye/sdk/cv_models.py +++ b/blueye/sdk/cv_models.py @@ -1,6 +1,6 @@ """Manage the computer vision model packages installed on the drone. -Drones with an onboard GPU run CV model packages (an ONNX model plus a +The Blueye X3 Ultra runs CV model packages (an ONNX model plus a `model_meta.json`, see the "Bundling CV models" documentation). This module wraps the drone's HTTP API for managing those packages: listing, uploading, deleting, downloading, configuring (autolaunch/device/rate), and pre-building inference engines. diff --git a/docs/cli.md b/docs/cli.md index fc20fc30..ba608d59 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -21,7 +21,7 @@ instead of failing. ## Bundling CV models — `blueye bundle-model` -Blueye drones with an onboard GPU can run your own computer vision models: object +The Blueye X3 Ultra can run your own computer vision models: object detection, instance segmentation, and single-object tracking. The drone's vision pipeline consumes **model packages** — a zip containing an ONNX model and a `model_meta.json` file that describes how to preprocess frames and decode the model's From 5829fd2033fba024d6d52cb84d8206ebf64fb66c Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 17:05:37 +0200 Subject: [PATCH 12/17] feat: local log conversion, filterable interactive view, retire mcap example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the logs command: - New `blueye logs convert [-o DIR]` converts already-downloaded logs to .mcap without touching the drone (dispatch happens before the observer connection; same runtime mcap gate). - Interactive view redesigned: instead of printing the full table and then a duplicate checkbox list, it is now a single scrollable multi-select table (each choice is a column-aligned row under one header line) with type-to-filter (questionary use_search_filter), sorted descending alphabetically — newest logs first. An explicit instruction string works around questionary 2.1.1 showing for both toggle-all and invert (the real invert binding is ctrl-i/tab). - New --dives-only / --since / --until filters shared by list, download, and the interactive view; list output is sorted descending too. - The example converter (examples/foxglove_bez_to_mcap.py) is retired in favor of the first-party command: the Foxglove doc now walks through `blueye logs download --latest 1 --mcap` and `blueye logs convert`, and mcap-protobuf-support leaves the [examples] extra (its only consumer). Verified against the bench drone: filtered+sorted list, download, and a local `blueye logs convert` producing a valid .mcap (1.19M messages). +13 tests (451 total); docs build clean with the embed removed. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/logs/command.py | 156 ++++++++++++++++++++---- blueye/sdk/cli/prompts.py | 16 ++- docs/cli.md | 7 +- docs/logs/foxglove-bez-to-mcap.md | 31 ++--- examples/foxglove_bez_to_mcap.py | 65 ---------- pyproject.toml | 1 - tests/test_cli_logs_command.py | 118 +++++++++++++++++- uv.lock | 2 - 8 files changed, 282 insertions(+), 114 deletions(-) delete mode 100644 examples/foxglove_bez_to_mcap.py diff --git a/blueye/sdk/cli/commands/logs/command.py b/blueye/sdk/cli/commands/logs/command.py index 7d6cca7f..56367cec 100644 --- a/blueye/sdk/cli/commands/logs/command.py +++ b/blueye/sdk/cli/commands/logs/command.py @@ -2,8 +2,9 @@ Follows the documented log workflow (docs/logs/listing-and-downloading.md): connect to the drone **as an observer** (taking no control), read the binary log index from -`drone.logs`, and download `.bez` files with `LogFile.download`. Legacy CSV logs are -not covered — use `drone.legacy_logs` from the SDK for those. +`drone.logs`, and download `.bez` files with `LogFile.download`. `convert` works on +already-downloaded files and never touches the drone. Legacy CSV logs are not covered +— use `drone.legacy_logs` from the SDK for those. Argument definitions are stdlib-only; rich/questionary/blueye.sdk imports happen inside `run` (after the dependency gate). @@ -12,6 +13,7 @@ from __future__ import annotations import argparse +import datetime import logging import sys from pathlib import Path @@ -22,6 +24,16 @@ logger = logging.getLogger(__name__) +def _add_filter_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--dives-only", action="store_true", help="Only logs classified as dives") + parser.add_argument( + "--since", metavar="YYYY-MM-DD", help="Only logs starting on or after this date" + ) + parser.add_argument( + "--until", metavar="YYYY-MM-DD", help="Only logs starting on or before this date" + ) + + def add_parser(subparsers) -> None: """Register the ``logs`` subcommand and its sub-subcommands.""" common = drone_options_parser(timeout_default=10.0) @@ -29,16 +41,18 @@ def add_parser(subparsers) -> None: parser = subparsers.add_parser( "logs", parents=[common], - help="List and download dive logs from the drone", + help="List, download, and convert dive logs", description=( - "List and download the drone's binary dive logs (.bez). Connects to the " - "drone as an observer, taking no control. Run without an action on a " - "terminal to pick logs interactively." + "List and download the drone's binary dive logs (.bez), and convert them " + "to .mcap for Foxglove. Drone actions connect as an observer, taking no " + "control. Run without an action on a terminal to pick logs interactively." ), ) + _add_filter_options(parser) actions = parser.add_subparsers(dest="logs_command", metavar="ACTION") - actions.add_parser("list", parents=[common], help="List the logs on the drone") + list_parser = actions.add_parser("list", parents=[common], help="List the logs on the drone") + _add_filter_options(list_parser) download = actions.add_parser("download", parents=[common], help="Download logs from the drone") download.add_argument("names", nargs="*", help="Log names to download") @@ -57,6 +71,16 @@ def add_parser(subparsers) -> None: action="store_true", help="Also convert each downloaded log to .mcap (for Foxglove)", ) + _add_filter_options(download) + + convert = actions.add_parser( + "convert", + help="Convert already-downloaded .bez logs to .mcap (local, no drone needed)", + ) + convert.add_argument("files", nargs="+", help="Paths to .bez files") + convert.add_argument( + "-o", "--output", help="Destination directory (default: next to each input file)" + ) def _connect(args): @@ -68,9 +92,50 @@ def _connect(args): ) -def _log_rows(logs) -> list: - """The drone's logs as a list of LogFile objects (index fetched lazily).""" - return friendly_errors(lambda: list(logs)) +def _parse_date(value: str, flag: str) -> datetime.date: + try: + return datetime.date.fromisoformat(value) + except ValueError as error: + raise CliError(f'{flag} must be a date like "2026-06-01", got "{value}"') from error + + +def _filter_logs(args, log_files) -> list: + """Apply the --dives-only/--since/--until filters.""" + filtered = list(log_files) + if getattr(args, "dives_only", False): + filtered = [log for log in filtered if log.is_dive] + since = getattr(args, "since", None) + if since: + since_date = _parse_date(since, "--since") + filtered = [log for log in filtered if log.start_time.date() >= since_date] + until = getattr(args, "until", None) + if until: + until_date = _parse_date(until, "--until") + filtered = [log for log in filtered if log.start_time.date() <= until_date] + return filtered + + +def _log_rows(logs, args) -> list: + """The drone's logs, filtered and sorted descending alphabetically.""" + log_files = friendly_errors(lambda: list(logs)) + return sorted(_filter_logs(args, log_files), key=lambda log: log.name, reverse=True) + + +#: Column widths for the interactive table rows (monospace-aligned). +_NAME_WIDTH = 36 +_TIME_WIDTH = 18 +_SIZE_WIDTH = 10 + + +def _format_row(log) -> str: + from blueye.sdk.logs import human_readable_filesize + + return ( + f"{log.name.ljust(_NAME_WIDTH)}" + f"{log.start_time.strftime('%d. %b %Y %H:%M').ljust(_TIME_WIDTH)}" + f"{human_readable_filesize(log.filesize).ljust(_SIZE_WIDTH)}" + f"{'dive' if log.is_dive else ''}" + ) def _print_logs_table(console, log_files) -> None: @@ -93,7 +158,7 @@ def _print_logs_table(console, log_files) -> None: def _ensure_mcap_support() -> None: - """Gate the --mcap path on its optional dependency, with install guidance.""" + """Gate the .mcap paths on their optional dependency, with install guidance.""" from ... import deps missing = deps.missing(("mcap_protobuf",)) @@ -129,6 +194,28 @@ def _download_logs( ) +def _run_convert(console, args) -> int: + """Convert already-downloaded .bez files to .mcap. Purely local.""" + _ensure_mcap_support() + from .mcap import convert_bez_to_mcap + + inputs = [Path(name).expanduser() for name in args.files] + missing = [str(path) for path in inputs if not path.is_file()] + if missing: + raise CliError(f"No such file: {', '.join(missing)}") + + output_dir = Path(args.output).expanduser() if args.output else None + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + + for bez_path in inputs: + mcap_path = (output_dir or bez_path.parent) / f"{bez_path.stem}.mcap" + with console.status(f"[cyan]Converting {bez_path.name}..."): + message_count = convert_bez_to_mcap(bez_path, mcap_path) + console.print(f"Converted {bez_path.name} to {mcap_path} ({message_count} messages)") + return 0 + + def _select_downloads(args, log_files) -> list: """Resolve the download selection from names/--latest/--all.""" by_name = {log.name: log for log in log_files} @@ -149,21 +236,22 @@ def _select_downloads(args, log_files) -> list: def _run_interactive(console, args, prompter, drone) -> int: - """Show the table, pick logs with a checkbox, download to a chosen directory.""" - log_files = _log_rows(drone.logs) + """One scrollable, filterable, multi-select table of logs to download.""" + log_files = _log_rows(drone.logs, args) if not log_files: - console.print("No logs on the drone.") + console.print( + "No logs match the filters." if _has_filters(args) else "No logs on the drone." + ) return 0 - _print_logs_table(console, log_files) - from blueye.sdk.logs import human_readable_filesize - - by_label = { - f"{log.name} {log.start_time.strftime('%d. %b %Y %H:%M')} " - f"{human_readable_filesize(log.filesize)}": log - for log in log_files - } - selected = prompter.checkbox("Select logs to download:", list(by_label), "--latest/--all") + by_row = {_format_row(log): log for log in log_files} + header = ( + f"{'NAME'.ljust(_NAME_WIDTH)}{'TIME'.ljust(_TIME_WIDTH)}{'SIZE'.ljust(_SIZE_WIDTH)}DIVE" + ) + console.print(f"[bold] {header}[/bold]") + selected = prompter.checkbox( + "Select logs to download (type to filter):", list(by_row), "--latest/--all" + ) if not selected: console.print("Nothing selected.") return 0 @@ -171,7 +259,7 @@ def _run_interactive(console, args, prompter, drone) -> int: convert_mcap = prompter.confirm("Also convert to .mcap for Foxglove?", False, "--mcap") _download_logs( console, - [by_label[label] for label in selected], + [by_row[row] for row in selected], output_dir, args.timeout, convert_mcap=convert_mcap, @@ -179,6 +267,14 @@ def _run_interactive(console, args, prompter, drone) -> int: return 0 +def _has_filters(args) -> bool: + return bool( + getattr(args, "dives_only", False) + or getattr(args, "since", None) + or getattr(args, "until", None) + ) + + def run(args: argparse.Namespace) -> int: """Dispatch the logs sub-subcommand.""" from ... import prompts, ui @@ -186,10 +282,14 @@ def run(args: argparse.Namespace) -> int: console = ui.make_console() action = getattr(args, "logs_command", None) + # `convert` is purely local — no drone connection. + if action == "convert": + return _run_convert(console, args) + drone = _connect(args) try: if action == "download": - log_files = _log_rows(drone.logs) + log_files = _log_rows(drone.logs, args) selection = _select_downloads(args, log_files) _download_logs( console, @@ -208,9 +308,11 @@ def run(args: argparse.Namespace) -> int: return 130 # `logs list` and non-TTY bare invocation. - log_files = _log_rows(drone.logs) + log_files = _log_rows(drone.logs, args) if not log_files: - console.print("No logs on the drone.") + console.print( + "No logs match the filters." if _has_filters(args) else "No logs on the drone." + ) return 0 _print_logs_table(console, log_files) return 0 diff --git a/blueye/sdk/cli/prompts.py b/blueye/sdk/cli/prompts.py index aeef060f..4b9fb845 100644 --- a/blueye/sdk/cli/prompts.py +++ b/blueye/sdk/cli/prompts.py @@ -71,7 +71,21 @@ def path(self, question: str, default: str | None, flag: str) -> str: return str(_require(questionary.path(question, default=default or "").ask())) def checkbox(self, question: str, choices: Sequence[str], flag: str) -> list[str]: - answer = _require(questionary.checkbox(question, choices=list(choices)).ask()) + answer = _require( + questionary.checkbox( + question, + choices=list(choices), + use_search_filter=True, + use_jk_keys=False, + # questionary 2.1.1's default instruction wrongly shows for + # both actions when the search filter is on; the real bindings are + # ctrl-a = toggle all and ctrl-i (tab) = invert. + instruction=( + "(use arrow keys to move, to select, to toggle " + "all, to invert, type to filter)" + ), + ).ask() + ) return [str(item) for item in answer] diff --git a/docs/cli.md b/docs/cli.md index b9e1dbe2..679f6cb6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -151,11 +151,16 @@ blueye logs list # table of logs on the drone blueye logs download --latest 1 # newest log to the current directory blueye logs download --latest 1 --mcap # ...and convert it for Foxglove blueye logs download BYEDP000000_ea9ac92e1817a1d4_00002 -o ~/dives +blueye logs convert mydive.bez # convert an already-downloaded log (no drone) blueye logs # interactive: pick logs to download ``` +`list`, `download`, and the interactive view accept `--dives-only`, `--since +YYYY-MM-DD`, and `--until YYYY-MM-DD` to narrow the selection; the interactive view +is a single scrollable table (type to filter, space to select, sorted newest first). `--mcap` converts each downloaded log to a Foxglove-ready `.mcap` next to the -`.bez` — see [visualizing dive logs with Foxglove](logs/foxglove-bez-to-mcap.md). +`.bez` — `blueye logs convert` does the same for files already on disk. See +[visualizing dive logs with Foxglove](logs/foxglove-bez-to-mcap.md). For working with logs from Python (streaming, filtering, plotting), see [logs from the drone](logs/listing-and-downloading.md). diff --git a/docs/logs/foxglove-bez-to-mcap.md b/docs/logs/foxglove-bez-to-mcap.md index 8beda155..5981d875 100644 --- a/docs/logs/foxglove-bez-to-mcap.md +++ b/docs/logs/foxglove-bez-to-mcap.md @@ -1,25 +1,26 @@ # Visualize dive log sensor data with Foxglove + With some simple steps you can visualize dive log data with ease in Foxglove. This is a great tool to play back and visualize control signals and estimated states and other sensor data from the dive. -!!! tip "One-step download and convert" - The [`blueye` CLI](../cli.md) (installed with the SDK) can download and convert - in one go: +1. Download Foxglove [here](https://foxglove.dev/download) and create an account. +2. Install the SDK with the `cli` extra to get the [`blueye` CLI](../cli.md) and the + `.mcap` converter: `pip install "blueye.sdk[cli]"`. +3. Download a dive log from the drone and convert it in one step: ```shell blueye logs download --latest 1 --mcap ``` - This fetches the newest log from the drone and writes both the `.bez` and a - Foxglove-ready `.mcap` next to it. Then continue from step 5 below. + Already have `.bez` files on disk? Convert them directly — no drone needed: + + ```shell + blueye logs convert mydive.bez + ``` -1. Download foxglove [here](https://foxglove.dev/download) and create an account. -2. Download a divelog from the drone as shown [here](https://blueye-robotics.github.io/blueye.sdk/latest/logs/listing-and-downloading/). -3. Run `pip install "blueye.sdk[examples]"` to get the necessary dependencies, if you have not done so already. -4. Clone the [blueye.sdk repository](https://github.com/BluEye-Robotics/blueye.sdk) to get the examples, or copy the script below into a file. In the examples folder you simply run `python foxglove_bez_to_mcap.py [output_filename.mcap]` to convert your .bez-file. -5. Open foxglove, in the top left menu, click on `Open local file`, and pick your newly created .mcap-file. -6. Click on `Add panel`, and `Raw message`, or `Plot` and select the signal you want to display. -7. Start typing `DepthTel.depth.value` to get auto-complete on all available messages in the protocol. -8. You can also get a nice overview of the logged messages with this command: `mcap info logfile.mcap` in your terminal. +4. Open Foxglove, in the top left menu, click on `Open local file`, and pick your newly created .mcap-file. +5. Click on `Add panel`, and `Raw message`, or `Plot` and select the signal you want to display. +6. Start typing `DepthTel.depth.value` to get auto-complete on all available messages in the protocol. +7. You can also get a nice overview of the logged messages with this command: `mcap info logfile.mcap` in your terminal. -### The .bez to .mcap log file converter: -{{code_from_file("../examples/foxglove_bez_to_mcap.py", "python")}} +For programmatic access to the log records (the converter is built on the same +parser), see [`LogStream`][blueye.sdk.logs.LogStream]. diff --git a/examples/foxglove_bez_to_mcap.py b/examples/foxglove_bez_to_mcap.py deleted file mode 100644 index 0dc34db3..00000000 --- a/examples/foxglove_bez_to_mcap.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -import time -from mcap_protobuf.writer import Writer -import sys -from blueye.sdk.logs import LogStream -from pathlib import Path - - -def parse_logfile(log: Path) -> LogStream: - log_bytes = b"" - with open(log, "rb") as f: - log_bytes = f.read() - return LogStream(log_bytes) - - -def main(logfile_path, output_mcap_path): - start_time_tic = time.time() - print(f"Converting {logfile_path} to {output_mcap_path}...") - - # Prepare MCAP writer - with open(output_mcap_path, "wb") as mcap_file: - writer = Writer(mcap_file) - - # Read messages from the log file, deserialize, and forward the protobuf object to the MCAP file. - path = Path(logfile_path) - - # We need to get the last message's timestamp and delta in order to get the correct start time - # after the clock is set. The delta time is then added to the start time to get a continuous timeline in foxglove. - last_time = 0 - last_delta = 0 - for last_time, last_delta, _, _ in parse_logfile(path): - continue - - start_time = last_time - last_delta - - count = 0 - for unix_ts, delta, msg_type, msg in parse_logfile(path): - writer.write_message( - topic=msg_type.__name__, - message=msg._pb, - log_time=int((start_time + delta).timestamp() * 1e9), - publish_time=int((start_time + delta).timestamp() * 1e9), - ) - count += 1 - - # Add indexes to the MCAP file - writer.finish() - - print(f"MCAP file successfully created!") - print( - f"Total of messages written: {count} in {round(time.time() - start_time_tic, 3)} seconds" - ) - print(f"MCAP file name: {output_mcap_path}") - print(f"MCAP file size: {round(os.path.getsize(output_mcap_path)/1000000, 2)} MB") - print(f"Start of dive time: {unix_ts - delta}") - print(f"Duration of dive log: {delta}") - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python bez_to_mcap.py [output_filename.mcap]") - sys.exit(1) - logfile = sys.argv[1] - output = sys.argv[2] if len(sys.argv) > 2 else sys.argv[1].replace(".bez", ".mcap") - main(logfile, output) diff --git a/pyproject.toml b/pyproject.toml index 2e079926..0098dd27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ examples = [ "webdavclient3>=3.14.6,<4", "foxglove_websocket>=0.1.2,<0.2", "pyserial~=3.5", - "mcap-protobuf-support>=0.5.3,<0.6", ] [project.urls] diff --git a/tests/test_cli_logs_command.py b/tests/test_cli_logs_command.py index d683fc3f..7a75478e 100644 --- a/tests/test_cli_logs_command.py +++ b/tests/test_cli_logs_command.py @@ -108,10 +108,13 @@ def fake_missing(names): class TestInteractive: - def test_interactive_checkbox_download(self, drone, mocker, tmp_path): + def test_interactive_checkbox_download(self, drone, mocker, tmp_path, capsys): + seen_choices = [] + class FakePrompter: def checkbox(self, question, choices, flag): - return [choices[0]] # Select the first log. + seen_choices.extend(choices) + return [choices[0]] # Select the first (newest) log. def text(self, question, default, flag): return str(tmp_path) @@ -126,6 +129,15 @@ def confirm(self, question, default, flag): assert main(["logs"]) == 0 LogFile.download.assert_called_once() assert LogFile.download.call_args.kwargs["output_path"] == tmp_path + # Sorted descending alphabetically: _00001 before _00000. + assert "BYEDP000000_aaaa_00001" in seen_choices[0] + assert "BYEDP000000_aaaa_00000" in seen_choices[1] + # The choices themselves are the table rows (name + time + size columns). + assert "KiB" in seen_choices[0] + # No duplicated full table before the picker — only the header line. + out = capsys.readouterr().out + assert "MAX DEPTH" not in out + assert "NAME" in out def test_interactive_empty_selection(self, drone, mocker, capsys): class FakePrompter: @@ -234,3 +246,105 @@ def confirm(self, question, default, flag): assert main(["logs"]) == 0 convert.assert_called_once() + + +class TestFilters: + def test_dives_only(self, drone, capsys): + assert main(["logs", "list", "--dives-only"]) == 0 + out = capsys.readouterr().out + assert "BYEDP000000_aaaa_00000" in out # is_dive=True + assert "BYEDP000000_aaaa_00001" not in out # is_dive=False + + def test_since_filters_older_logs(self, drone, capsys): + # Log _00001 starts at 1700100000 (~2023-11-16); _00000 at 1700000000 (~11-14). + assert main(["logs", "list", "--since", "2023-11-16"]) == 0 + out = capsys.readouterr().out + assert "BYEDP000000_aaaa_00001" in out + assert "BYEDP000000_aaaa_00000" not in out + + def test_until_filters_newer_logs(self, drone, capsys): + assert main(["logs", "list", "--until", "2023-11-15"]) == 0 + out = capsys.readouterr().out + assert "BYEDP000000_aaaa_00000" in out + assert "BYEDP000000_aaaa_00001" not in out + + def test_bad_date_errors_cleanly(self, drone, capsys): + assert main(["logs", "list", "--since", "tomorrow"]) == 1 + err = capsys.readouterr().err + assert "--since must be a date" in err # The message names the expected format. + + def test_filters_apply_to_download_all(self, drone, tmp_path): + assert main(["logs", "download", "--all", "--dives-only", "-o", str(tmp_path)]) == 0 + assert LogFile.download.call_count == 1 + + def test_no_match_message(self, drone, capsys): + assert main(["logs", "list", "--since", "2030-01-01"]) == 0 + assert "No logs match the filters" in capsys.readouterr().out + + +class TestSorting: + def test_list_sorted_descending(self, drone, capsys): + assert main(["logs", "list"]) == 0 + out = capsys.readouterr().out + assert out.index("BYEDP000000_aaaa_00001") < out.index("BYEDP000000_aaaa_00000") + + +class TestConvert: + @pytest.fixture + def bez_on_disk(self, tmp_path): + path = tmp_path / "mydive.bez" + path.write_bytes(b"bez-bytes") + return path + + def test_convert_writes_sibling_mcap(self, drone, mocker, bez_on_disk, capsys): + convert = mocker.patch( + "blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap", return_value=7 + ) + assert main(["logs", "convert", str(bez_on_disk)]) == 0 + convert.assert_called_once_with(bez_on_disk, bez_on_disk.parent / "mydive.mcap") + assert "7 messages" in capsys.readouterr().out + # Purely local: the Drone class must never be constructed. + drone._drone_cls.assert_not_called() + + def test_convert_with_output_dir(self, drone, mocker, bez_on_disk, tmp_path): + convert = mocker.patch( + "blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap", return_value=7 + ) + out_dir = tmp_path / "converted" + assert main(["logs", "convert", str(bez_on_disk), "-o", str(out_dir)]) == 0 + convert.assert_called_once_with(bez_on_disk, out_dir / "mydive.mcap") + assert out_dir.is_dir() + + def test_convert_multiple_files(self, drone, mocker, tmp_path): + convert = mocker.patch( + "blueye.sdk.cli.commands.logs.mcap.convert_bez_to_mcap", return_value=1 + ) + files = [] + for name in ("a.bez", "b.bez"): + path = tmp_path / name + path.write_bytes(b"x") + files.append(str(path)) + assert main(["logs", "convert", *files]) == 0 + assert convert.call_count == 2 + + def test_convert_missing_file_errors(self, drone, tmp_path, capsys): + assert main(["logs", "convert", str(tmp_path / "nope.bez")]) == 1 + assert "No such file" in capsys.readouterr().err + + def test_convert_missing_dependency_guidance(self, drone, mocker, bez_on_disk, capsys): + def fake_missing(names): + return [name for name in names if name == "mcap_protobuf"] + + mocker.patch("blueye.sdk.cli.deps.missing", side_effect=fake_missing) + assert main(["logs", "convert", str(bez_on_disk)]) == 1 + assert "blueye.sdk[cli]" in capsys.readouterr().out + + +class TestSearchFilterEnabled: + def test_questionary_checkbox_gets_search_filter(self, mocker): + from blueye.sdk.cli.prompts import QuestionaryPrompter + + checkbox = mocker.patch("questionary.checkbox") + checkbox.return_value.ask.return_value = [] + QuestionaryPrompter().checkbox("Pick:", ["a", "b"], "--flag") + assert checkbox.call_args.kwargs["use_search_filter"] is True diff --git a/uv.lock b/uv.lock index b797d3a1..b40bbd3b 100644 --- a/uv.lock +++ b/uv.lock @@ -144,7 +144,6 @@ examples = [ { name = "foxglove-websocket" }, { name = "inputs" }, { name = "matplotlib" }, - { name = "mcap-protobuf-support" }, { name = "pandas" }, { name = "pyserial" }, { name = "webdavclient3" }, @@ -180,7 +179,6 @@ requires-dist = [ { name = "inputs", marker = "extra == 'examples'", specifier = ">=0.5,<0.6" }, { name = "matplotlib", marker = "extra == 'examples'", specifier = "~=3.10" }, { name = "mcap-protobuf-support", marker = "extra == 'cli'", specifier = ">=0.5.3,<0.6" }, - { name = "mcap-protobuf-support", marker = "extra == 'examples'", specifier = ">=0.5.3,<0.6" }, { name = "onnx", marker = "extra == 'cli'", specifier = ">=1.16,<2" }, { name = "packaging", specifier = ">=24.2" }, { name = "pandas", marker = "extra == 'examples'", specifier = "~=2.2" }, From e3c2bd6ea34b926a4eae8b6e68401566c9b23d68 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 17:15:08 +0200 Subject: [PATCH 13/17] fix: scope checkbox toggle-all/invert to the filtered rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Works around a second questionary 2.1.1 search-filter bug (user-found): with a filter active, ctrl-a (toggle all) and ctrl-i/tab (invert) operated on every choice — selecting files not even in view — because questionary's handlers iterate ic.choices instead of ic.filtered_choices. The prompter now replaces both key bindings on the constructed prompt with versions scoped to the visible rows: toggle-all selects/deselects only what the filter shows, invert flips only the visible rows, and selections hidden by the filter are left untouched. The selection logic lives in pure helpers (_toggle_all_visible, _invert_visible) with unit tests, plus an integration test asserting the rebinding attaches to a real questionary prompt. +5 tests (456 total). Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/prompts.py | 90 ++++++++++++++++++++++++++++------ tests/test_cli_logs_command.py | 3 ++ tests/test_cli_prompts.py | 68 +++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 14 deletions(-) diff --git a/blueye/sdk/cli/prompts.py b/blueye/sdk/cli/prompts.py index 4b9fb845..8815e78d 100644 --- a/blueye/sdk/cli/prompts.py +++ b/blueye/sdk/cli/prompts.py @@ -44,6 +44,68 @@ def _require(answer: object) -> object: return answer +def _visible_values(inquirer_control) -> list: + """The selectable values currently shown (respecting the active search filter).""" + from questionary.prompts.common import Separator + + return [ + choice.value + for choice in inquirer_control.filtered_choices + if not isinstance(choice, Separator) and not choice.disabled + ] + + +def _toggle_all_visible(inquirer_control) -> None: + """Select every visible row, or deselect them when all are already selected.""" + visible = _visible_values(inquirer_control) + if visible and all(value in inquirer_control.selected_options for value in visible): + for value in visible: + inquirer_control.selected_options.remove(value) + else: + for value in visible: + if value not in inquirer_control.selected_options: + inquirer_control.selected_options.append(value) + + +def _invert_visible(inquirer_control) -> None: + """Invert the selection of the visible rows, leaving hidden selections intact.""" + for value in _visible_values(inquirer_control): + if value in inquirer_control.selected_options: + inquirer_control.selected_options.remove(value) + else: + inquirer_control.selected_options.append(value) + + +def _scope_bulk_bindings_to_filter(question) -> None: + """Make ctrl-a (toggle all) and ctrl-i/tab (invert) respect the search filter. + + Works around a questionary 2.1.1 bug: with ``use_search_filter=True`` its + toggle-all/invert handlers iterate every choice instead of the filtered view, so + filtering and then pressing ctrl-a selected files the user could not even see. + The original bindings are replaced with ones scoped to `filtered_choices`. + """ + from prompt_toolkit.keys import Keys + from questionary.prompts.common import InquirerControl + + application = question.application + inquirer_control = next( + control + for control in application.layout.find_all_controls() + if isinstance(control, InquirerControl) + ) + bindings = application.key_bindings + bindings.remove(Keys.ControlA) + bindings.remove(Keys.ControlI) + + @bindings.add(Keys.ControlA, eager=True) + def _toggle_all(_event): + _toggle_all_visible(inquirer_control) + + @bindings.add(Keys.ControlI, eager=True) + def _invert(_event): + _invert_visible(inquirer_control) + + class QuestionaryPrompter: """Interactive prompts with arrow-key selection and path autocompletion.""" @@ -71,21 +133,21 @@ def path(self, question: str, default: str | None, flag: str) -> str: return str(_require(questionary.path(question, default=default or "").ask())) def checkbox(self, question: str, choices: Sequence[str], flag: str) -> list[str]: - answer = _require( - questionary.checkbox( - question, - choices=list(choices), - use_search_filter=True, - use_jk_keys=False, - # questionary 2.1.1's default instruction wrongly shows for - # both actions when the search filter is on; the real bindings are - # ctrl-a = toggle all and ctrl-i (tab) = invert. - instruction=( - "(use arrow keys to move, to select, to toggle " - "all, to invert, type to filter)" - ), - ).ask() + prompt = questionary.checkbox( + question, + choices=list(choices), + use_search_filter=True, + use_jk_keys=False, + # questionary 2.1.1's default instruction wrongly shows for + # both actions when the search filter is on; the real bindings are + # ctrl-a = toggle all and ctrl-i (tab) = invert. + instruction=( + "(use arrow keys to move, to select, to toggle " + "all, to invert, type to filter)" + ), ) + _scope_bulk_bindings_to_filter(prompt) + answer = _require(prompt.ask()) return [str(item) for item in answer] diff --git a/tests/test_cli_logs_command.py b/tests/test_cli_logs_command.py index 7a75478e..c68a0cde 100644 --- a/tests/test_cli_logs_command.py +++ b/tests/test_cli_logs_command.py @@ -346,5 +346,8 @@ def test_questionary_checkbox_gets_search_filter(self, mocker): checkbox = mocker.patch("questionary.checkbox") checkbox.return_value.ask.return_value = [] + # The binding rework runs on the real prompt object; not exercisable on a Mock. + scope = mocker.patch("blueye.sdk.cli.prompts._scope_bulk_bindings_to_filter") QuestionaryPrompter().checkbox("Pick:", ["a", "b"], "--flag") assert checkbox.call_args.kwargs["use_search_filter"] is True + scope.assert_called_once_with(checkbox.return_value) diff --git a/tests/test_cli_prompts.py b/tests/test_cli_prompts.py index 40767f98..4adb4395 100644 --- a/tests/test_cli_prompts.py +++ b/tests/test_cli_prompts.py @@ -72,3 +72,71 @@ def test_guidance_uses_single_quotes_with_uv_on_windows(self, mocker, capsys): deps.print_install_guidance(["onnx"]) out = capsys.readouterr().out assert "uv pip install 'blueye.sdk[cli]'" in out + + +class _StubChoice: + def __init__(self, value, disabled=False): + self.value = value + self.disabled = disabled + + +class _StubControl: + """Mimics questionary's InquirerControl selection state for the bulk helpers.""" + + def __init__(self, visible, selected=()): + self.filtered_choices = [_StubChoice(value) for value in visible] + self.selected_options = list(selected) + + +class TestFilterScopedBulkActions: + def test_toggle_all_selects_only_visible(self): + from blueye.sdk.cli.prompts import _toggle_all_visible + + control = _StubControl(visible=["a", "b"], selected=["hidden"]) + _toggle_all_visible(control) + assert sorted(control.selected_options) == ["a", "b", "hidden"] + + def test_toggle_all_deselects_when_all_visible_selected(self): + from blueye.sdk.cli.prompts import _toggle_all_visible + + control = _StubControl(visible=["a", "b"], selected=["a", "b", "hidden"]) + _toggle_all_visible(control) + assert control.selected_options == ["hidden"] + + def test_invert_only_touches_visible(self): + from blueye.sdk.cli.prompts import _invert_visible + + control = _StubControl(visible=["a", "b"], selected=["a", "hidden"]) + _invert_visible(control) + assert sorted(control.selected_options) == ["b", "hidden"] + + def test_disabled_choices_are_skipped(self): + from blueye.sdk.cli.prompts import _toggle_all_visible + + control = _StubControl(visible=["a"]) + control.filtered_choices.append(_StubChoice("locked", disabled=True)) + _toggle_all_visible(control) + assert control.selected_options == ["a"] + + def test_rebinding_attaches_to_real_prompt(self): + """The workaround must find the control and replace both key bindings.""" + import questionary + from prompt_toolkit.keys import Keys + + from blueye.sdk.cli.prompts import _scope_bulk_bindings_to_filter + + prompt = questionary.checkbox( + "Pick:", choices=["a", "b"], use_search_filter=True, use_jk_keys=False + ) + _scope_bulk_bindings_to_filter(prompt) + bindings = prompt.application.key_bindings + + def exact(keys): + # get_bindings_for_keys also returns the search-character catch-all; + # only the exact-key binding handles the shortcut at dispatch time. + return [b for b in bindings.get_bindings_for_keys(keys) if b.keys == keys] + + toggle = exact((Keys.ControlA,)) + invert = exact((Keys.ControlI,)) + assert len(toggle) == 1 and toggle[0].handler.__name__ == "_toggle_all" + assert len(invert) == 1 and invert[0].handler.__name__ == "_invert" From bb7e5fba5ba3335c6291c7a8c03774ec522663ba Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 17:18:35 +0200 Subject: [PATCH 14/17] fix: construct the rebinding test prompt headlessly for Windows CI prompt_toolkit raises NoConsoleScreenBufferError when questionary builds a prompt without a console; the test now uses a pipe input and DummyOutput app session. Co-Authored-By: Claude Fable 5 --- tests/test_cli_prompts.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_prompts.py b/tests/test_cli_prompts.py index 4adb4395..b0d20066 100644 --- a/tests/test_cli_prompts.py +++ b/tests/test_cli_prompts.py @@ -120,15 +120,25 @@ def test_disabled_choices_are_skipped(self): def test_rebinding_attaches_to_real_prompt(self): """The workaround must find the control and replace both key bindings.""" + import contextlib + import questionary + from prompt_toolkit.application import create_app_session + from prompt_toolkit.input import create_pipe_input from prompt_toolkit.keys import Keys + from prompt_toolkit.output import DummyOutput from blueye.sdk.cli.prompts import _scope_bulk_bindings_to_filter - prompt = questionary.checkbox( - "Pick:", choices=["a", "b"], use_search_filter=True, use_jk_keys=False - ) - _scope_bulk_bindings_to_filter(prompt) + with contextlib.ExitStack() as stack: + # Windows CI has no console; give prompt_toolkit a pipe input and a + # dummy output so the prompt can be constructed headlessly. + pipe_input = stack.enter_context(create_pipe_input()) + stack.enter_context(create_app_session(input=pipe_input, output=DummyOutput())) + prompt = questionary.checkbox( + "Pick:", choices=["a", "b"], use_search_filter=True, use_jk_keys=False + ) + _scope_bulk_bindings_to_filter(prompt) bindings = prompt.application.key_bindings def exact(keys): From 44cdcad561cf68f473595647100856c971722151 Mon Sep 17 00:00:00 2001 From: Juan Pablo Pino Bravo Date: Fri, 10 Jul 2026 17:38:10 +0200 Subject: [PATCH 15/17] fix: reject combined download selectors and non-positive --latest Addresses the Copilot review on #220: names/--latest/--all are now mutually exclusive instead of silently prioritized, and --latest must be at least 1 rather than succeeding with an empty selection. Co-Authored-By: Claude Fable 5 --- blueye/sdk/cli/commands/logs/command.py | 5 +++++ tests/test_cli_logs_command.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/blueye/sdk/cli/commands/logs/command.py b/blueye/sdk/cli/commands/logs/command.py index 56367cec..12a31f88 100644 --- a/blueye/sdk/cli/commands/logs/command.py +++ b/blueye/sdk/cli/commands/logs/command.py @@ -218,10 +218,15 @@ def _run_convert(console, args) -> int: def _select_downloads(args, log_files) -> list: """Resolve the download selection from names/--latest/--all.""" + selectors = [bool(args.names), args.latest is not None, args.all] + if sum(selectors) > 1: + raise CliError("Pass only one of log names, --latest N, or --all.") by_name = {log.name: log for log in log_files} if args.all: return list(log_files) if args.latest is not None: + if args.latest < 1: + raise CliError("--latest must be a positive number of logs.") newest_first = sorted(log_files, key=lambda log: log.start_time, reverse=True) return newest_first[: args.latest] if args.names: diff --git a/tests/test_cli_logs_command.py b/tests/test_cli_logs_command.py index c68a0cde..c1b73329 100644 --- a/tests/test_cli_logs_command.py +++ b/tests/test_cli_logs_command.py @@ -90,6 +90,24 @@ def test_no_selector_errors(self, drone, capsys): assert main(["logs", "download"]) == 1 assert "--latest" in capsys.readouterr().err + def test_combined_selectors_error(self, drone, capsys): + assert main(["logs", "download", "BYEDP000000_aaaa_00000", "--all"]) == 1 + assert "only one of" in capsys.readouterr().err + LogFile.download.assert_not_called() + + def test_latest_with_names_errors(self, drone, capsys): + assert main(["logs", "download", "BYEDP000000_aaaa_00000", "--latest", "1"]) == 1 + assert "only one of" in capsys.readouterr().err + LogFile.download.assert_not_called() + + def test_latest_zero_errors(self, drone, capsys): + assert main(["logs", "download", "--latest", "0"]) == 1 + assert "--latest must be a positive number" in capsys.readouterr().err + + def test_latest_negative_errors(self, drone, capsys): + assert main(["logs", "download", "--latest", "-1"]) == 1 + assert "--latest must be a positive number" in capsys.readouterr().err + class TestFailureHandling: def test_unreachable_drone_is_friendly(self, drone, capsys): From 7b6fa783bcb86ac187a04972704cccc10046571a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Folles=C3=B8?= Date: Wed, 22 Jul 2026 12:10:54 +0200 Subject: [PATCH 16/17] feat: add version/description/author/license to bundle-model metadata The BlueyeCV package format gains four optional informational top-level fields in model_meta.json, so packages stay self-describing wherever they travel (drone web UI, Blueye App, Blueye Cloud). bundle-model now collects them: --model-version defaults to "1.0.0" (prompted after the name), and description/author/license sit behind an optional "package details" prompt, each answerable with a flag. build_meta emits the fields between name and preprocessing; validate_meta type-checks them. Non-interactive runs omit the optional fields instead of failing on the empty prompt default. Co-Authored-By: Claude Fable 5 --- .../sdk/cli/commands/bundle_model/command.py | 43 +++++++++++++++++++ blueye/sdk/cli/commands/bundle_model/meta.py | 11 +++++ docs/cli.md | 7 +++ tests/test_cli_meta.py | 33 ++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/blueye/sdk/cli/commands/bundle_model/command.py b/blueye/sdk/cli/commands/bundle_model/command.py index d2a64eb0..d8e81cbb 100644 --- a/blueye/sdk/cli/commands/bundle_model/command.py +++ b/blueye/sdk/cli/commands/bundle_model/command.py @@ -35,6 +35,16 @@ def add_parser(subparsers) -> None: ) parser.add_argument("onnx_path", nargs="?", help="Path to the ONNX model file") parser.add_argument("--name", help="Human-readable model name") + parser.add_argument( + "--model-version", + help='Model version string written to the metadata (default: "1.0.0")', + ) + parser.add_argument("--description", help="Human-readable model description") + parser.add_argument("--author", help="Model author/publisher") + parser.add_argument( + "--license", + help='Model license, preferably an SPDX id (e.g. "MIT", "Apache-2.0", "Proprietary")', + ) parser.add_argument("-o", "--output", help="Output zip path (default: _package.zip)") parser.add_argument( "--format", @@ -153,6 +163,16 @@ def _parse_float_list(value: str, flag: str) -> list[float]: raise CliError(f"{flag} must be a comma-separated float list: {error}") from error +def _optional_text(value: str | None, prompter, interactive: bool, question: str, flag: str) -> str: + """Resolve an optional informational field: the flag wins, interactive runs prompt, + non-interactive runs omit the field instead of failing on the empty default.""" + if value is not None: + return value + if not interactive: + return "" + return prompter.text(question, "", flag) + + def _read_labels_file(path: Path) -> list[str]: if not path.is_file(): raise CliError(f"Labels file not found: {path}") @@ -327,6 +347,29 @@ def run(args: argparse.Namespace) -> int: options = meta.MetaOptions(name=name, output_format=output_format, kind=kind) + options.version = args.model_version or prompter.text( + "Model version:", "1.0.0", "--model-version" + ) + has_detail_flags = any( + value is not None for value in (args.description, args.author, args.license) + ) + if has_detail_flags or prompter.confirm( + "Add package details (description / author / license)?", False, "--description" + ): + options.description = _optional_text( + args.description, prompter, interactive, "Description:", "--description" + ) + options.author = _optional_text( + args.author, prompter, interactive, "Author:", "--author" + ) + options.license = _optional_text( + args.license, + prompter, + interactive, + "License (SPDX id, e.g. MIT, Apache-2.0, AGPL-3.0, Proprietary):", + "--license", + ) + if args.input_size: options.input_width, options.input_height = _parse_input_size(args.input_size) else: diff --git a/blueye/sdk/cli/commands/bundle_model/meta.py b/blueye/sdk/cli/commands/bundle_model/meta.py index c269bb95..613728c4 100644 --- a/blueye/sdk/cli/commands/bundle_model/meta.py +++ b/blueye/sdk/cli/commands/bundle_model/meta.py @@ -78,6 +78,10 @@ class MetaOptions: """ name: str = "" + version: str = "" + description: str = "" + author: str = "" + license: str = "" output_format: str = "" kind: str = "detection" # "detection" or "sot" num_classes: int | None = None @@ -136,6 +140,10 @@ def build_meta(options: MetaOptions) -> dict: "model_file": "model.onnx", "name": options.name, } + for key in ("version", "description", "author", "license"): + value = getattr(options, key) + if value: + meta[key] = value preprocessing: dict = { "color_order": options.color_order, @@ -211,6 +219,9 @@ def validate_meta(meta: dict) -> list[str]: errors.append("format_version must be 1") if not meta.get("model_file"): errors.append("model_file must be non-empty") + for key in ("name", "version", "description", "author", "license"): + if key in meta and not isinstance(meta[key], str): + errors.append(f"{key} must be a string") detection = meta.get("detection") sot = meta.get("sot") diff --git a/docs/cli.md b/docs/cli.md index ba608d59..b7022484 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -54,6 +54,11 @@ name, tracking algorithm, and the runtime configuration for the drone: - **Inference rate** — maximum rate in Hz, defaulting to unlimited. - **Autolaunch** — whether the drone should start this model automatically. Defaults to enabled; pass `--no-runtime-enabled` to bundle the package disabled. +- **Package metadata** — a version string (default `1.0.0`) plus optional description, + author, and license. These are informational fields carried inside + `model_meta.json` so the package stays self-describing wherever it travels + (drone web UI, Blueye App, Blueye Cloud). Use an [SPDX identifier](https://spdx.org/licenses/) + for the license when you can (`MIT`, `Apache-2.0`, `AGPL-3.0`, `Proprietary`). The result is a zip with `model.onnx` and `model_meta.json` at its root. @@ -64,6 +69,8 @@ Every prompt can be answered with a flag, and `--yes` accepts all inferred defau ```shell blueye bundle-model yolov8n.onnx --yes \ --name "YOLOv8n (COCO)" \ + --model-version 1.0.0 --author "Ultralytics" --license AGPL-3.0 \ + --description "YOLOv8n nano object detector trained on COCO (80 classes)" \ --tracking byte_track \ --runtime-device tensorrt-dla0 --runtime-hz 10 --runtime-enabled \ --output yolov8n_package.zip diff --git a/tests/test_cli_meta.py b/tests/test_cli_meta.py index 0d5710f0..f05e807c 100644 --- a/tests/test_cli_meta.py +++ b/tests/test_cli_meta.py @@ -103,6 +103,28 @@ def test_one_indexed_classes_only_when_set(self): meta = build_meta(detection_options(output_format="ssd_multi", one_indexed_classes=True)) assert meta["detection"]["one_indexed_classes"] is True + def test_informational_fields_emitted_after_name(self): + meta = build_meta( + detection_options( + version="1.2.0", + description="Test detector", + author="Blueye Robotics", + license="MIT", + ) + ) + assert meta["version"] == "1.2.0" + assert meta["description"] == "Test detector" + assert meta["author"] == "Blueye Robotics" + assert meta["license"] == "MIT" + keys = list(meta) + assert keys.index("name") < keys.index("version") + assert keys.index("license") < keys.index("preprocessing") + + def test_informational_fields_omitted_when_empty(self): + meta = build_meta(detection_options()) + for key in ("version", "description", "author", "license"): + assert key not in meta + class TestDefaultPreprocessing: def test_raw_pixel_formats(self): @@ -166,6 +188,17 @@ def test_sot_sizes_must_be_positive(self): meta = build_meta(options) assert any("template_size" in error for error in validate_meta(meta)) + def test_valid_informational_fields_pass(self): + meta = build_meta( + detection_options(version="1.0.0", description="d", author="a", license="MIT") + ) + assert validate_meta(meta) == [] + + def test_non_string_informational_field_rejected(self): + meta = build_meta(detection_options()) + meta["license"] = 42 + assert "license must be a string" in validate_meta(meta) + class TestDetectionLabelsNotDefaulted: def test_detection_without_labels_fails_validation(self): From 92119dc2ea92856837f7e98eb480f3c8e505690d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Folles=C3=B8?= Date: Wed, 22 Jul 2026 12:35:55 +0200 Subject: [PATCH 17/17] fix: address Copilot review round two - CvModels.list() now sorts by directory so the docstring matches the behaviour and callers get a deterministic order. - Content-Disposition filename parsing no longer over-captures when the header carries additional parameters. - ui.py docstring cross-references point at the actual commands.bundle_model module paths. - The interactive device prompt only offers the four devices the drone exposes; cpu/coreml stay reachable via an explicit --runtime-device for local be-cv testing, with a printed note when chosen. Co-Authored-By: Claude Fable 5 --- .../sdk/cli/commands/bundle_model/command.py | 14 ++++++++++-- blueye/sdk/cli/ui.py | 4 ++-- blueye/sdk/cv_models.py | 5 +++-- tests/test_cv_models.py | 22 +++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/blueye/sdk/cli/commands/bundle_model/command.py b/blueye/sdk/cli/commands/bundle_model/command.py index d8e81cbb..a95c9a2a 100644 --- a/blueye/sdk/cli/commands/bundle_model/command.py +++ b/blueye/sdk/cli/commands/bundle_model/command.py @@ -18,7 +18,11 @@ logger = logging.getLogger(__name__) -_DEVICES = ("cpu", "cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1", "coreml") +#: Devices the drone's CV model API exposes; the interactive prompt offers only these. +_DRONE_DEVICES = ("cuda", "tensorrt", "tensorrt-dla0", "tensorrt-dla1") +#: The package format additionally allows cpu/coreml for local desktop testing with +#: be-cv; they stay reachable via an explicit --runtime-device flag. +_DEVICES = (*_DRONE_DEVICES, "cpu", "coreml") _RATE_PRESETS = ("max (unlimited)", "30", "15", "10", "5", "2", "custom...") @@ -225,7 +229,8 @@ def _resolve_runtime(args, dla, prompter): else: recommended = "tensorrt-dla0" if dla.good_fit else "tensorrt" choices = [ - device + (" (recommended)" if device == recommended else "") for device in _DEVICES + device + (" (recommended)" if device == recommended else "") + for device in _DRONE_DEVICES ] answer = prompter.select( f"Execution device on the drone? ({dla.reason})", @@ -465,6 +470,11 @@ def run(args: argparse.Namespace) -> int: options.runtime_device = device options.runtime_hz = hz options.runtime_enabled = enabled + if device not in _DRONE_DEVICES: + console.print( + f"[yellow]Note:[/yellow] device '{device}' is for local be-cv testing — " + f"the drone only exposes {', '.join(_DRONE_DEVICES)}." + ) # Stage 4 — build and validate. meta_dict = meta.build_meta(options) diff --git a/blueye/sdk/cli/ui.py b/blueye/sdk/cli/ui.py index 2c6cecea..1e988839 100644 --- a/blueye/sdk/cli/ui.py +++ b/blueye/sdk/cli/ui.py @@ -33,7 +33,7 @@ def model_summary_panel(info) -> Panel: """Render the introspected model as a summary panel. Args: - info: A :class:`~blueye.sdk.cli.introspect.ModelInfo`. + info: A :class:`~blueye.sdk.cli.commands.bundle_model.introspect.ModelInfo`. """ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) table.add_column("Tensor") @@ -59,7 +59,7 @@ def inference_panel(config) -> Panel: """Render the inference result and its reasoning. Args: - config: A :class:`~blueye.sdk.cli.heuristics.InferredConfig`. + config: A :class:`~blueye.sdk.cli.commands.bundle_model.heuristics.InferredConfig`. """ lines = [] if config.output_format: diff --git a/blueye/sdk/cv_models.py b/blueye/sdk/cv_models.py index ea9879a6..d31c2ff3 100644 --- a/blueye/sdk/cv_models.py +++ b/blueye/sdk/cv_models.py @@ -119,7 +119,8 @@ def list(self, timeout: float = 5) -> list[CvModel]: One CvModel per installed package, sorted by directory name. """ response = self._check(requests.get(f"{self._base_url}/", timeout=timeout)) - return [CvModel.from_json(entry) for entry in response.json()] + models = [CvModel.from_json(entry) for entry in response.json()] + return sorted(models, key=lambda model: model.directory) def upload(self, package: Path | str, timeout: float = 60) -> CvModel: """Upload a model package zip to the drone. @@ -169,7 +170,7 @@ def download( """ response = self._check(requests.get(f"{self._base_url}/{name}/download", timeout=timeout)) disposition = response.headers.get("Content-Disposition", "") - matches = re.findall('filename="(.+)"', disposition) + matches = re.findall('filename="([^"]+)"', disposition) filename = matches[0] if matches else f"{name}.zip" if output_path is None: diff --git a/tests/test_cv_models.py b/tests/test_cv_models.py index 1b0c2190..806bf2fd 100644 --- a/tests/test_cv_models.py +++ b/tests/test_cv_models.py @@ -44,6 +44,17 @@ def test_empty_list(self, cv_models, requests_mock): requests_mock.get(f"{BASE}/", json=[]) assert cv_models.list() == [] + def test_list_sorted_by_directory(self, cv_models, requests_mock): + requests_mock.get( + f"{BASE}/", + json=[ + {"name": "B", "directory": "b-model"}, + {"name": "A", "directory": "a-model"}, + ], + ) + models = cv_models.list() + assert [model.directory for model in models] == ["a-model", "b-model"] + def test_missing_optional_fields_defaulted(self): model = CvModel.from_json({"name": "x", "directory": "x"}) assert model.type == "unknown" @@ -100,6 +111,17 @@ def test_download_uses_content_disposition_name(self, cv_models, requests_mock, assert output == tmp_path / "cod-detector.zip" assert output.read_bytes() == b"zip-bytes" + def test_download_ignores_extra_disposition_parameters( + self, cv_models, requests_mock, tmp_path + ): + requests_mock.get( + f"{BASE}/cod-detector/download", + content=b"zip-bytes", + headers={"Content-Disposition": 'attachment; filename="cod-detector.zip"; foo="bar"'}, + ) + output = cv_models.download("cod-detector", output_path=tmp_path) + assert output == tmp_path / "cod-detector.zip" + def test_download_fallback_name_and_explicit_path(self, cv_models, requests_mock, tmp_path): requests_mock.get(f"{BASE}/cod-detector/download", content=b"zip-bytes") output = cv_models.download("cod-detector", output_path=tmp_path / "my.zip")