• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nvidia-holoscan / holoscan-cli / 29833109714

21 Jul 2026 01:08PM UTC coverage: 78.611% (+0.01%) from 78.598%
29833109714

Pull #202

github

wyli
update based on comments

Signed-off-by: Wenqi Li <wenqil@nvidia.com>
Pull Request #202: fix: parse HOLOSCAN_CLI_BUILD_LOCAL as a boolean, not Python truthiness

17 of 17 new or added lines in 7 files covered. (100.0%)

1 existing line in 1 file now uncovered.

3135 of 3988 relevant lines covered (78.61%)

0.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.94
/src/holoscan_cli/container/core.py
1
#!/usr/bin/env python3
2
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
# SPDX-License-Identifier: Apache-2.0
4
#
5
# Licensed under the Apache License, Version 2.0 (the "License");
6
# you may not use this file except in compliance with the License.
7
# You may obtain a copy of the License at
8
#
9
# http://www.apache.org/licenses/LICENSE-2.0
10
#
11
# Unless required by applicable law or agreed to in writing, software
12
# distributed under the License is distributed on an "AS IS" BASIS,
13
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
# See the License for the specific language governing permissions and
15
# limitations under the License.
16

17
import glob
1✔
18
import os
1✔
19
import re
1✔
20
import shlex
1✔
21
import shutil
1✔
22
import signal
1✔
23
import stat
1✔
24
import subprocess
1✔
25
import sys
1✔
26
import tempfile
1✔
27
from pathlib import Path
1✔
28
from typing import Any, List, Optional, Union
1✔
29

30
from holoscan_cli.metadata.utils import list_normalized_languages
1✔
31

32
from ..utils.docker import get_image_pythonpath
1✔
33
from ..utils.holohub import (
1✔
34
    build_holohub_path_mapping,
35
    get_current_branch_slug,
36
    get_git_short_sha,
37
    get_group_id,
38
    get_holohub_root,
39
    get_holohub_setup_scripts_dir,
40
    get_sccache_dir,
41
    replace_placeholders,
42
)
43
from ..utils.io import fatal, info, run_command, warn
1✔
44
from ..utils.sdk import (
1✔
45
    check_nvidia_ctk,
46
    find_hsdk_build_rel_dir,
47
    get_arch_gpu_str,
48
    get_compute_capacity,
49
    get_cuda_tag,
50
    get_default_cuda_version,
51
    get_host_gpu,
52
    is_valid_sdk_installation,
53
)
54
from ..utils.text import get_cli_arg_value, get_env_bool
1✔
55
from .signals import (
1✔
56
    _ContainerTerminationHandler,
57
    _ContainerTerminationSignal,
58
    _read_container_id,
59
)
60

61
SCCACHE_CONTAINER_DIR = "/.cache/sccache"
1✔
62

63

64
class HoloscanContainer:
1✔
65
    """
66
    Describes the container environment for a HoloHub project.
67

68
    This class is responsible for common container operations and environment configuration,
69
    which may differ across different projects.
70

71
    Default attributes may be overridden by a project-specific implementation.
72
    """
73

74
    HOLOHUB_ROOT = get_holohub_root()  # Repository root directory
1✔
75
    # Primary repository prefix - sets defaults for container, workspace, and hostname
76
    REPO_PREFIX = os.environ.get("HOLOSCAN_CLI_REPO_PREFIX", "holohub")
1✔
77
    CONTAINER_PREFIX = os.environ.get("HOLOSCAN_CLI_CONTAINER_PREFIX", REPO_PREFIX)
1✔
78
    WORKSPACE_NAME = os.environ.get("HOLOSCAN_CLI_WORKSPACE_NAME", REPO_PREFIX)
1✔
79
    HOSTNAME_PREFIX = os.environ.get("HOLOSCAN_CLI_HOSTNAME_PREFIX", REPO_PREFIX.replace("_", "-"))
1✔
80

81
    # Docker and runtime configuration
82
    DOCKER_EXE = os.environ.get("HOLOSCAN_CLI_DOCKER_EXE", "docker")  # Docker executable
1✔
83

84
    # SDK and path configuration
85
    SDK_PATH = os.environ.get("HOLOSCAN_CLI_DEFAULT_HSDK_DIR", "/opt/nvidia/holoscan")
1✔
86
    BASE_SDK_VERSION = os.environ.get("HOLOSCAN_CLI_BASE_SDK_VERSION") or None
1✔
87
    BENCHMARKING_SUBDIR = os.environ.get(
1✔
88
        "HOLOSCAN_CLI_BENCHMARKING_SUBDIR", "benchmarks/holoscan_flow_benchmarking"
89
    )
90
    DEFAULT_DOCKERFILE = os.environ.get(
1✔
91
        "HOLOSCAN_CLI_DEFAULT_DOCKERFILE", HOLOHUB_ROOT / "Dockerfile"
92
    )
93

94
    # Image naming format templates
95
    DEFAULT_BASE_IMAGE_NAME = "nvcr.io/nvidia/clara-holoscan/holoscan"
1✔
96
    BASE_IMAGE_NAME = os.environ.get("HOLOSCAN_CLI_BASE_IMAGE", DEFAULT_BASE_IMAGE_NAME)
1✔
97
    BASE_IMAGE_FORMAT = os.environ.get("HOLOSCAN_CLI_BASE_IMAGE_FORMAT") or None
1✔
98
    DEFAULT_IMAGE_FORMAT = os.environ.get("HOLOSCAN_CLI_DEFAULT_IMAGE_FORMAT") or None
1✔
99
    # Additional Default build arguments for docker build command (e.g., --build-context flags)
100
    DEFAULT_DOCKER_BUILD_ARGS = os.environ.get("HOLOSCAN_CLI_DEFAULT_DOCKER_BUILD_ARGS", "")
1✔
101
    # Additional Default run arguments for docker run command
102
    DEFAULT_DOCKER_RUN_ARGS = os.environ.get("HOLOSCAN_CLI_DEFAULT_DOCKER_RUN_ARGS", "")
1✔
103
    DISPLAY_FORWARDING_DISABLED_MESSAGE = (
1✔
104
        "No DISPLAY or WAYLAND_DISPLAY set; skipping display forwarding."
105
    )
106

107
    @staticmethod
1✔
108
    def local_source_build_context_args() -> List[str]:
1✔
109
        """Docker build --build-context args for a local holoscan-cli checkout.
110

111
        Returns an empty list when ``HOLOSCAN_CLI_SOURCE`` is unset. When set,
112
        exposes the checkout as a named ``holoscan-cli-src`` build context so
113
        downstream Dockerfiles can mount it (``RUN --mount=from=holoscan-cli-src
114
        ...``) and pip-install the working tree instead of pulling from PyPI or
115
        git. Used during prototype validation to exercise an in-progress branch
116
        end-to-end without publishing it first.
117
        """
118
        source = os.environ.get("HOLOSCAN_CLI_SOURCE")
1✔
119
        if not source:
1✔
120
            return []
1✔
121
        return ["--build-context", f"holoscan-cli-src={source}"]
1✔
122

123
    @classmethod
1✔
124
    def _format_image_template(cls, template: str, **values: Optional[str]) -> str:
1✔
125
        if "{sdk_version" in template and not values.get("sdk_version"):
1✔
126
            fatal(
×
127
                "Image format references sdk_version, but HOLOSCAN_CLI_BASE_SDK_VERSION "
128
                "is not set."
129
            )
130
        return template.format(**values)
1✔
131

132
    @classmethod
1✔
133
    def default_base_image(cls, cuda_version: Optional[Union[str, int]] = None) -> str:
1✔
134
        cuda_tag = get_cuda_tag(cuda_version, cls.BASE_SDK_VERSION)
1✔
135
        if cls.BASE_IMAGE_FORMAT:
1✔
136
            return cls._format_image_template(
1✔
137
                cls.BASE_IMAGE_FORMAT,
138
                base_image=cls.BASE_IMAGE_NAME,
139
                sdk_version=cls.BASE_SDK_VERSION,
140
                cuda_tag=cuda_tag,
141
            )
142
        if cls.BASE_SDK_VERSION:
1✔
143
            return f"{cls.BASE_IMAGE_NAME}:v{cls.BASE_SDK_VERSION}-{cuda_tag}"
×
144
        if cls.BASE_IMAGE_NAME != cls.DEFAULT_BASE_IMAGE_NAME:
1✔
145
            return cls.BASE_IMAGE_NAME
1✔
146
        fatal(
1✔
147
            "No default Holoscan SDK base image is configured. Pass --base-img, "
148
            "set HOLOSCAN_CLI_BASE_IMAGE to a fully qualified image tag, or set "
149
            "HOLOSCAN_CLI_BASE_SDK_VERSION."
150
        )
151

152
    @classmethod
1✔
153
    def default_image(cls, cuda_version: Optional[Union[str, int]] = None) -> str:
1✔
154
        cuda_tag = get_cuda_tag(cuda_version, cls.BASE_SDK_VERSION)
1✔
155
        if cls.DEFAULT_IMAGE_FORMAT:
1✔
156
            return cls._format_image_template(
1✔
157
                cls.DEFAULT_IMAGE_FORMAT,
158
                container_prefix=cls.CONTAINER_PREFIX,
159
                sdk_version=cls.BASE_SDK_VERSION,
160
                cuda_tag=cuda_tag,
161
            )
162
        if cls.BASE_SDK_VERSION:
1✔
163
            return f"{cls.CONTAINER_PREFIX}:ngc-v{cls.BASE_SDK_VERSION}-{cuda_tag}"
×
164
        return f"{cls.CONTAINER_PREFIX}:ngc-{cuda_tag}"
1✔
165

166
    @classmethod
1✔
167
    def default_dockerfile(cls) -> Path:
1✔
168
        return cls.DEFAULT_DOCKERFILE
1✔
169

170
    @staticmethod
1✔
171
    def ucx_args() -> List[str]:
1✔
172
        """UCX-related docker run arguments"""
173
        return [
1✔
174
            "--ipc=host",
175
            "--cap-add=CAP_SYS_PTRACE",
176
            "--ulimit=memlock=-1",
177
            "--ulimit=stack=67108864",
178
        ]
179

180
    @staticmethod
1✔
181
    def get_device_mounts() -> List[str]:
1✔
182
        """Get docker run arguments for mounting specialized hardware devices and libraries"""
183
        options = []
1✔
184

185
        for video_dev in glob.glob("/dev/video[0-9]*"):
1✔
186
            options.extend(["--device", video_dev])
×
187

188
        for capture_dev in glob.glob("/dev/capture-vi-channel[0-9]*"):
1✔
189
            options.extend(["--device", capture_dev])
×
190

191
        for video_dev in glob.glob("/dev/ajantv2[0-9]*"):
1✔
192
            options.extend(["--device", f"{video_dev}:{video_dev}"])
×
193

194
        # Deltacast capture boards and Videomaster SDK
195
        for i in range(4):
1✔
196
            # Deltacast SDI capture board
197
            delta_sdi = f"/dev/delta-x380{i}"
1✔
198
            if os.path.exists(delta_sdi):
1✔
199
                options.extend(["--device", f"{delta_sdi}:{delta_sdi}"])
×
200

201
            delta_sdi = f"/dev/delta-x370{i}"
1✔
202
            if os.path.exists(delta_sdi):
1✔
203
                options.extend(["--device", f"{delta_sdi}:{delta_sdi}"])
×
204

205
            # Deltacast HDMI capture board
206
            delta_hdmi = f"/dev/delta-x350{i}"
1✔
207
            if os.path.exists(delta_hdmi):
1✔
208
                options.extend(["--device", f"{delta_hdmi}:{delta_hdmi}"])
×
209

210
        # Find and mount all audio devices
211
        if os.path.isdir("/dev/snd"):
1✔
212
            # Only mount specific audio device patterns, exclude directories
213
            audio_patterns = [
×
214
                "/dev/snd/control*",
215
                "/dev/snd/pcm*",
216
                "/dev/snd/timer",
217
                "/dev/snd/seq",
218
                "/dev/snd/midi*",
219
            ]
220
            for pattern in audio_patterns:
×
221
                for audio_dev in glob.glob(pattern):
×
222
                    try:
×
223
                        # Check if it's a character device using stat module
224
                        if stat.S_ISCHR(os.stat(audio_dev).st_mode):
×
225
                            options.extend(["--device", audio_dev])
×
226
                    except OSError:
×
227
                        continue
×
228

229
        # Mount ALSA configuration
230
        if os.path.exists("/etc/asound.conf"):
1✔
231
            options.extend(
×
232
                ["--mount", "source=/etc/asound.conf,target=/etc/asound.conf,readonly,type=bind"]
233
            )
234

235
        # Mount ConnectX device nodes
236
        if os.path.exists("/dev/infiniband/rdma_cm"):
1✔
237
            options.extend(["--device", "/dev/infiniband/rdma_cm"])
×
238

239
        for uverbs_dev in glob.glob("/dev/infiniband/uverbs[0-9]*"):
1✔
UNCOV
240
            options.extend(["--device", uverbs_dev])
×
241

242
        conditional_mounts = [
1✔
243
            "/usr/local/cmake/VideoMasterHDConfigVersion.cmake",
244
            "/usr/local/cmake/VideoMasterHDConfig.cmake",
245
            "/usr/lib/libvideomasterhd.so",
246
            "/usr/lib/libvideomasterhd_audio.so",
247
            "/usr/lib/libvideomasterhd_vbi.so",
248
            "/usr/lib/libvideomasterhd_vbidata.so",
249
            "/usr/include/videomaster",
250
            "/opt/yuan/qcap/include",
251
            "/opt/yuan/qcap/lib",
252
            "/usr/lib/aarch64-linux-gnu/tegra",
253
            "/usr/lib/aarch64-linux-gnu/nvidia",
254
        ]
255

256
        for path in conditional_mounts:
1✔
257
            if os.path.exists(path):
1✔
258
                options.extend(["-v", f"{path}:{path}"])
×
259

260
        if os.path.exists("/dev/nvgpu/igpu0/nvsched"):
1✔
261
            options.extend(["--device", "/dev/nvgpu/igpu0/nvsched"])
×
262
        if os.path.exists("/dev/nvhost-ctrl-nvdec"):
1✔
263
            options.extend(["--device", "/dev/nvhost-ctrl-nvdec"])
×
264
        if os.path.exists("/dev/nvhost-ctxsw-gpu"):
1✔
265
            options.extend(["--device", "/dev/nvhost-ctxsw-gpu"])
×
266
        if os.path.exists("/dev/nvhost-nvsched-gpu"):
1✔
267
            options.extend(["--device", "/dev/nvhost-nvsched-gpu"])
×
268
        if os.path.exists("/dev/nvhost-sched-gpu"):
1✔
269
            options.extend(["--device", "/dev/nvhost-sched-gpu"])
×
270
        if os.path.exists("/dev/nvidia-modeset"):
1✔
271
            options.extend(["--device", "/dev/nvidia-modeset"])
×
272
        if os.path.exists("/usr/share/nvidia/nvoptix.bin"):
1✔
273
            options.extend(["-v", "/usr/share/nvidia/nvoptix.bin:/usr/share/nvidia/nvoptix.bin:ro"])
×
274
        return options
1✔
275

276
    @staticmethod
1✔
277
    def group_args() -> List[str]:
1✔
278
        """Get docker run arguments for adding groups to the container"""
279
        options = []
1✔
280
        for group in ["video", "render", "docker", "audio"]:
1✔
281
            gid = get_group_id(group)
1✔
282
            if gid is None:
1✔
283
                continue
1✔
284
            options.extend(["--group-add", str(gid)])
1✔
285
        return options
1✔
286

287
    def get_conditional_options(
1✔
288
        self, use_tini: bool = False, persistent: bool = False
289
    ) -> List[str]:
290
        options = []
1✔
291
        if use_tini:
1✔
292
            options.append("--init")
1✔
293
        if not persistent:
1✔
294
            options.append("--rm")
1✔
295
        return options
1✔
296

297
    @property
1✔
298
    def image_name(self) -> str:
1✔
299
        if self.dockerfile_path != HoloscanContainer.default_dockerfile():
1✔
300
            project_tag = self.get_project_name()
1✔
301
            if project_tag:
1✔
302
                return f"{self.CONTAINER_PREFIX}:{project_tag}"
1✔
303
            return self.CONTAINER_PREFIX
×
304
        return HoloscanContainer.default_image(self.cuda_version)
1✔
305

306
    @property
1✔
307
    def image_names(self) -> List[str]:
1✔
308
        """Return list of image tags to apply: branch-tag, sha-tag, and legacy tag."""
309
        project = self.get_project_name()
1✔
310
        repo = f"{self.CONTAINER_PREFIX}-{project}" if project else self.CONTAINER_PREFIX
1✔
311
        sha_tag = f"{repo}:{get_git_short_sha()}"
1✔
312
        branch_tag = f"{repo}:{get_current_branch_slug()}"
1✔
313
        legacy_tag = self.image_name
1✔
314
        # Deduplicate while preserving order.
315
        seen = set()
1✔
316
        result = []
1✔
317
        for tag in [branch_tag, sha_tag, legacy_tag]:
1✔
318
            if tag and tag not in seen:
1✔
319
                result.append(tag)
1✔
320
                seen.add(tag)
1✔
321
        return result
1✔
322

323
    @property
1✔
324
    def dockerfile_path(self) -> Path:
1✔
325
        """
326
        Get Dockerfile path for the project according to the search strategy:
327
        1. As specified in metadata.json
328
        2. <app_source>/<language>/Dockerfile
329
        3. <app_source>/Dockerfile
330
        4. <app_source>/../Dockerfile (traverse up to HOLOHUB_ROOT)
331
        5. `HOLOSCAN_CLI_DEFAULT_DOCKERFILE` env variable
332
        6. `<HOLOHUB_ROOT>/Dockerfile`
333
        """
334
        if not self.project_metadata:
1✔
335
            return HoloscanContainer.default_dockerfile()
×
336

337
        # Strategy 1: Check metadata for explicit dockerfile path
338
        dockerfile_from_metadata = self.project_metadata.get("metadata", {}).get("dockerfile")
1✔
339
        if dockerfile_from_metadata:
1✔
340
            # Build path mapping for this project
341
            path_mapping = build_holohub_path_mapping(
1✔
342
                holohub_root=HoloscanContainer.HOLOHUB_ROOT,
343
                project_data=self.project_metadata,
344
            )
345

346
            dockerfile_str = replace_placeholders(dockerfile_from_metadata, path_mapping)
1✔
347
            dockerfile = Path(dockerfile_str)
1✔
348

349
            # If the path is not absolute, make it relative to HOLOHUB_ROOT
350
            if not dockerfile.is_absolute():
1✔
351
                dockerfile = HoloscanContainer.HOLOHUB_ROOT / dockerfile
×
352

353
            # Validate that the Dockerfile exists
354
            if dockerfile.exists():
1✔
355
                return dockerfile
1✔
356
            else:
357
                warn(
1✔
358
                    f"Dockerfile specified in metadata.json not found: {dockerfile}\n"
359
                    "Falling back to default Dockerfile search strategy."
360
                )
361

362
        # Strategy 2-4: Search in source_folder hierarchy
363
        source_folder = self.project_metadata.get("source_folder")
1✔
364
        if source_folder:
1✔
365
            source_folder = Path(source_folder).resolve()
1✔
366

367
            # Strategy 2: Check language-specific Dockerfile
368
            dockerfile_path = source_folder / self.language / "Dockerfile"
1✔
369
            if dockerfile_path.exists():
1✔
370
                return dockerfile_path
1✔
371

372
            # Strategy 3: Check Dockerfile in source folder
373
            dockerfile_path = source_folder / "Dockerfile"
1✔
374
            if dockerfile_path.exists():
1✔
375
                return dockerfile_path
1✔
376

377
            # Strategy 4: Traverse up parent directories to HOLOHUB_ROOT
378
            for parent in source_folder.parents:
1✔
379
                # Stop at the root directory
380
                if parent == HoloscanContainer.HOLOHUB_ROOT:
1✔
381
                    break
1✔
382
                dockerfile_path = parent / "Dockerfile"
1✔
383
                if dockerfile_path.exists():
1✔
384
                    return dockerfile_path
1✔
385

386
        # Strategy 5-6: Fall back to default Dockerfile
387
        return HoloscanContainer.default_dockerfile()
1✔
388

389
    def get_project_name(self) -> str:
1✔
390
        """Return docker-safe project name."""
391
        project_name = (self.project_metadata or {}).get("project_name", "")
1✔
392
        if not project_name:
1✔
393
            return ""
1✔
394
        sanitized = project_name.lower()
1✔
395
        sanitized = re.sub(r"[^a-z0-9._-]", "-", sanitized)
1✔
396
        sanitized = re.sub(r"-{2,}", "-", sanitized).strip("-")
1✔
397
        sanitized = re.sub(r"^[^a-z0-9]+", "", sanitized)  # Docker tags must start alnum
1✔
398
        return sanitized or ""
1✔
399

400
    def __init__(self, project_metadata: Optional[dict[str, Any]], language: Optional[str] = None):
1✔
401
        if not isinstance(project_metadata, dict):
1✔
402
            print("No project provided, proceeding with default container")
1✔
403

404
        self.project_metadata = project_metadata
1✔
405
        # Get first language from project metadata if not provided.
406
        if language is None and self.project_metadata:
1✔
407
            language = self.project_metadata.get("metadata", {}).get("language", "")
1✔
408
        self.language = list_normalized_languages(language, strict=True)[0]
1✔
409

410
        self.cuda_version = None  # None means use default from get_cuda_tag
1✔
411
        self.dryrun = False
1✔
412
        self.verbose = False
1✔
413
        self._display_temp_files: List[Path] = []
1✔
414

415
    def build(
1✔
416
        self,
417
        docker_file: Optional[str] = None,
418
        base_img: Optional[str] = None,
419
        img: Optional[str] = None,
420
        no_cache: bool = False,
421
        build_args: Optional[str] = None,
422
        extra_scripts: Optional[List[str]] = None,
423
        cuda_version: Optional[Union[str, int]] = None,
424
    ) -> None:
425
        """
426
        Build the container image according to the procedure:
427

428
        1. Build the Dockerfile provided environment with the given BASE_IMAGE and given tag.
429
            If extra_scripts are provided, also tag this image as {img}-base.
430
        2. If extra_scripts are provided, build an additional Docker layer for each script.
431
            Tag each iterative layer as {img}-{script} and {img}.
432

433
        Result: Docker image named {img} based on the Dockerfile and any additional scripts.
434
        """
435

436
        if cuda_version is not None:
1✔
437
            self.cuda_version = cuda_version
1✔
438

439
        # Get Dockerfile path
440
        docker_file_path = docker_file or self.dockerfile_path
1✔
441
        base_img = base_img or self.default_base_image(self.cuda_version)
1✔
442
        tags = [img] if img else self.image_names
1✔
443
        gpu_type = get_host_gpu()
1✔
444
        compute_capacity = get_compute_capacity()
1✔
445

446
        cuda_major = (
1✔
447
            self.cuda_version if self.cuda_version is not None else get_default_cuda_version()
448
        )
449

450
        # Check if buildx exists
451
        if not self.dryrun:
1✔
452
            try:
×
453
                run_command([self.DOCKER_EXE, "buildx", "version"], check=True, capture_output=True)
×
454
            except subprocess.CalledProcessError:
×
455
                fatal(
×
456
                    "docker buildx plugin is missing. Please install docker-buildx-plugin:\n"
457
                    "https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository"
458
                )
459

460
        # Set DOCKER_BUILDKIT environment variable
461
        os.environ["DOCKER_BUILDKIT"] = "1"
1✔
462

463
        cmd = [
1✔
464
            self.DOCKER_EXE,
465
            "build",
466
            "--build-arg",
467
            "BUILDKIT_INLINE_CACHE=1",
468
            "--build-arg",
469
            f"BASE_IMAGE={base_img}",
470
            "--build-arg",
471
            f"GPU_TYPE={gpu_type}",
472
            "--build-arg",
473
            f"COMPUTE_CAPACITY={compute_capacity}",
474
            "--build-arg",
475
            f"CUDA_MAJOR={cuda_major}",
476
            "--network=host",
477
        ]
478
        if self.BASE_SDK_VERSION:
1✔
479
            cmd.extend(["--build-arg", f"BASE_SDK_VERSION={self.BASE_SDK_VERSION}"])
1✔
480

481
        if no_cache:
1✔
482
            cmd.append("--no-cache")
1✔
483

484
        cmd.extend(self.local_source_build_context_args())
1✔
485

486
        full_build_args = " ".join(
1✔
487
            filter(None, [HoloscanContainer.DEFAULT_DOCKER_BUILD_ARGS, build_args])
488
        )
489
        if full_build_args:
1✔
490
            cmd.extend(shlex.split(full_build_args))
1✔
491

492
        cmd.extend(["-f", str(docker_file_path)])
1✔
493
        for tag_name in tags:
1✔
494
            cmd.extend(["-t", tag_name])
1✔
495
        if extra_scripts:
1✔
496
            # Tag the base (pre-scripts) image for all tags for consistency
497
            for tag_name in tags:
1✔
498
                cmd.extend(["-t", f"{tag_name}-base"])
1✔
499
        cmd.append(str(HoloscanContainer.HOLOHUB_ROOT))
1✔
500

501
        run_command(cmd, dry_run=self.dryrun)
1✔
502

503
        if extra_scripts:
1✔
504
            setup_scripts_dir = get_holohub_setup_scripts_dir()
1✔
505
            for script in extra_scripts:
1✔
506
                script_path = setup_scripts_dir / f"{script}.sh"
1✔
507
                if not script_path.exists():
1✔
508
                    fatal(f"Script {script}.sh not found in {setup_scripts_dir}")
×
509
                try:
1✔
510
                    relative_script_path = script_path.relative_to(HoloscanContainer.HOLOHUB_ROOT)
1✔
511
                    script_build_context = HoloscanContainer.HOLOHUB_ROOT
1✔
512
                except ValueError:
1✔
513
                    relative_script_path = script_path.relative_to(setup_scripts_dir)
1✔
514
                    script_build_context = setup_scripts_dir
1✔
515
                cmd = [
1✔
516
                    self.DOCKER_EXE,
517
                    "build",
518
                    "--build-arg",
519
                    "BUILDKIT_INLINE_CACHE=1",
520
                    "--build-arg",
521
                    f"BASE_IMAGE={tags[0]}",  # reuse the default tag to sequentially add the scripts on top of each other.
522
                    "--network=host",
523
                    "--build-arg",
524
                    f"SCRIPT={relative_script_path}",
525
                    "-f",
526
                    str(setup_scripts_dir / "Dockerfile.util"),
527
                    str(script_build_context),
528
                ]
529
                for tag_name in tags:
1✔
530
                    # We override the default tag so we can add the next scripts on top of this.
531
                    cmd.extend(["-t", f"{tag_name}-{script}", "-t", f"{tag_name}"])
1✔
532
                run_command(cmd, dry_run=self.dryrun)
1✔
533

534
    def run(
1✔
535
        self,
536
        img: Optional[str] = None,
537
        local_sdk_root: Optional[Path] = None,
538
        enable_x11: bool = True,
539
        ssh_x11: bool = False,
540
        use_tini: bool = False,
541
        persistent: bool = False,
542
        nsys_profile: bool = False,
543
        nsys_location: str = "",
544
        as_root: bool = False,
545
        docker_opts: str = "",
546
        add_volumes: List[str] = None,
547
        enable_mps: bool = False,
548
        extra_args: List[str] = None,
549
        include_default_run_args: bool = True,
550
    ) -> None:
551
        """Launch the container"""
552

553
        default_run_args = shlex.split(
1✔
554
            (HoloscanContainer.DEFAULT_DOCKER_RUN_ARGS or "") if include_default_run_args else ""
555
        )
556
        extra_run_args = shlex.split(docker_opts or "")
1✔
557
        configured_runtime = get_cli_arg_value(default_run_args + extra_run_args, "--runtime")
1✔
558
        runtime = configured_runtime or "nvidia"
1✔
559

560
        if not self.dryrun and runtime == "nvidia":
1✔
561
            check_nvidia_ctk()
×
562

563
        if local_sdk_root is not None:
1✔
564
            local_sdk_root = Path(local_sdk_root)
×
565

566
        img = img or self.image_names[0]
1✔
567
        add_volumes = add_volumes or []
1✔
568
        extra_args = extra_args or []
1✔
569

570
        # If the caller already supplies --cidfile (via DEFAULT_DOCKER_RUN_ARGS or
571
        # docker_opts), use that path for cleanup and skip injecting our own —
572
        # Docker rejects duplicate --cidfile flags.
573
        explicit_cidfile = get_cli_arg_value(default_run_args + extra_run_args, "--cidfile")
1✔
574
        internal_cidfile: Optional[Path] = None
1✔
575
        if explicit_cidfile:
1✔
576
            cidfile = Path(explicit_cidfile)
1✔
577
        else:
578
            internal_cidfile = Path(tempfile.gettempdir()) / f"holohub-container-{os.getpid()}.cid"
1✔
579
            cidfile = internal_cidfile
1✔
580

581
        cmd = [self.DOCKER_EXE, "run"]
1✔
582

583
        cmd.extend(self.get_basic_args())
1✔
584
        if internal_cidfile is not None:
1✔
585
            cmd.extend(["--cidfile", str(internal_cidfile)])
1✔
586
        cmd.extend(self.get_security_args(as_root))
1✔
587
        cmd.extend(self.get_volume_args(add_volumes, enable_mps))
1✔
588
        cmd.extend(self.get_gpu_runtime_args(None if configured_runtime is not None else runtime))
1✔
589
        cmd.extend(self.get_environment_args())
1✔
590

591
        cmd.extend(self.get_conditional_options(use_tini, persistent))
1✔
592
        cmd.extend(self.ucx_args())
1✔
593
        cmd.extend(self.get_device_mounts())
1✔
594
        cmd.extend(self.group_args())
1✔
595
        self._display_temp_files = []
1✔
596
        cmd.extend(self.get_display_options(enable_x11, ssh_x11))
1✔
597
        cmd.extend(self.get_nsys_options(nsys_profile, nsys_location))
1✔
598
        cmd.extend(self.get_pythonpath_options(local_sdk_root, img))
1✔
599
        cmd.extend(self.get_ngc_options())
1✔
600

601
        if local_sdk_root or os.environ.get("HOLOSCAN_SDK_ROOT"):
1✔
602
            cmd.extend(self.get_local_sdk_options(local_sdk_root))
×
603

604
        # Default docker run arguments and caller-supplied docker_opts (parsed above).
605
        cmd.extend(default_run_args)
1✔
606
        cmd.extend(extra_run_args)
1✔
607
        if as_root:
1✔
608
            cmd.extend(["--user", "0:0"])
1✔
609

610
        cmd.append(img)
1✔
611
        cmd.extend(extra_args)
1✔
612

613
        if self.verbose:
1✔
614
            cmd_list = [f'"{arg}"' if " " in str(arg) else str(arg) for arg in cmd]
1✔
615
            print(f"Launch command: {' '.join(cmd_list)}")
1✔
616

617
        try:
1✔
618
            if self.dryrun:
1✔
619
                run_command(cmd, dry_run=self.dryrun)
1✔
620
                return
1✔
621

622
            # Docker refuses to start if --cidfile already exists; clear stale internal
623
            # files left by a prior crashed run that happened to share this PID. Caller-
624
            # provided cidfiles are the caller's responsibility — never remove them.
625
            if internal_cidfile is not None:
1✔
626
                internal_cidfile.unlink(missing_ok=True)
×
627

628
            try:
1✔
629
                try:
1✔
630
                    with _ContainerTerminationHandler():
1✔
631
                        run_command(cmd)
1✔
632
                    return
1✔
633
                except _ContainerTerminationSignal as exc:
×
634
                    sig = exc.signum
×
635

636
                try:
×
637
                    signal_name = signal.Signals(sig).name
×
638
                except ValueError:
×
639
                    signal_name = str(sig)
×
640
                container_id = _read_container_id(cidfile)
×
641
                if container_id:
×
642
                    warn(f"Received {signal_name}; stopping HoloHub container {container_id}")
×
643
                    subprocess.run(
×
644
                        [self.DOCKER_EXE, "stop", "--time", "10", container_id],
645
                        check=False,
646
                        stdout=subprocess.DEVNULL,
647
                    )
648
                else:
649
                    warn(
×
650
                        f"Received {signal_name}; no container ID was written to {cidfile} yet — "
651
                        "the container may still be starting. Run `docker ps` to check and stop it manually."
652
                    )
653
                # os.kill below may terminate the process before the outer `finally`
654
                # blocks run, so unlink the cidfile and clean display temp files here.
655
                if internal_cidfile is not None:
×
656
                    internal_cidfile.unlink(missing_ok=True)
×
657
                self._cleanup_display_temp_files()
×
658
                # Re-raise via default handler so we exit with the conventional 128+N status.
659
                signal.signal(sig, signal.SIG_DFL)
×
660
                os.kill(os.getpid(), sig)
×
661
                sys.exit(128 + sig)
×
662
            finally:
663
                if internal_cidfile is not None:
1✔
664
                    internal_cidfile.unlink(missing_ok=True)
×
665
        finally:
666
            self._cleanup_display_temp_files()
1✔
667

668
    def get_basic_args(self) -> List[str]:
1✔
669
        """Basic container runtime arguments"""
670
        args = ["--net", "host", "--interactive"]
1✔
671
        if sys.stdout.isatty():
1✔
672
            args.append("--tty")
×
673
        return args
1✔
674

675
    def get_security_args(self, as_root: bool) -> List[str]:
1✔
676
        """User and security arguments"""
677
        args = []
1✔
678

679
        if not as_root:
1✔
680
            args.extend(["-u", f"{os.getuid()}:{os.getgid()}"])
1✔
681

682
        args.extend(["-v", "/etc/group:/etc/group:ro", "-v", "/etc/passwd:/etc/passwd:ro"])
1✔
683

684
        return args
1✔
685

686
    def get_volume_args(self, add_volumes: List[str], enable_mps: bool) -> List[str]:
1✔
687
        """Volume mounting arguments"""
688
        args = []
1✔
689

690
        args.extend(
1✔
691
            [
692
                "-v",
693
                f"{HoloscanContainer.HOLOHUB_ROOT}:/workspace/{self.WORKSPACE_NAME}",
694
                "-w",
695
                f"/workspace/{self.WORKSPACE_NAME}",
696
            ]
697
        )
698

699
        for volume in add_volumes:
1✔
700
            volume = os.path.abspath(volume)
1✔
701
            base = os.path.basename(volume)
1✔
702
            args.extend(["-v", f"{volume}:/workspace/volumes/{base}"])
1✔
703

704
        if enable_mps:
1✔
705
            if os.path.isdir("/tmp/nvidia-mps") and os.path.isdir("/tmp/nvidia-log"):
×
706
                args.extend(
×
707
                    [
708
                        "-v",
709
                        "/tmp/nvidia-mps:/tmp/nvidia-mps",
710
                        "-v",
711
                        "/tmp/nvidia-log:/tmp/nvidia-log",
712
                    ]
713
                )
714
            else:
715
                print("Warning: MPS directories not found. MPS may not be enabled on the host.")
×
716

717
        # sccache mounting
718
        _, enable_sccache = get_env_bool("HOLOSCAN_CLI_ENABLE_SCCACHE", default=False)
1✔
719
        has_host_sccache_env = any(k.startswith("SCCACHE_") for k in os.environ)
1✔
720
        if enable_sccache:
1✔
721
            sccache_host_dir = get_sccache_dir()
×
722
            info(f"Host SCCACHE_DIR: {sccache_host_dir}")
×
723
            info(f"Container mount point: {SCCACHE_CONTAINER_DIR}")
×
724
            os.makedirs(sccache_host_dir, exist_ok=True)  # Pre-create for the current user to own
×
725
            args.extend(["-v", f"{sccache_host_dir}:{SCCACHE_CONTAINER_DIR}"])
×
726
        elif has_host_sccache_env:
1✔
727
            warn(
×
728
                "SCCACHE_* environment variables detected but HOLOSCAN_CLI_ENABLE_SCCACHE is "
729
                "disabled; not mounting sccache cache into the container."
730
            )
731
        return args
1✔
732

733
    def get_nvidia_runtime_args(self) -> List[str]:
1✔
734
        return ["--runtime", "nvidia"]
1✔
735

736
    def get_device_cgroup_args(self) -> List[str]:
1✔
737
        return [
1✔
738
            "--device-cgroup-rule",
739
            "c 81:* rmw",  # /dev/video*
740
            "--device-cgroup-rule",
741
            "c 189:* rmw",  # /dev/bus/usb/*
742
        ]
743

744
    def get_gpu_runtime_args(self, runtime: Optional[str] = "nvidia") -> List[str]:
1✔
745
        args = []
1✔
746
        if runtime == "nvidia":
1✔
747
            args.extend(self.get_nvidia_runtime_args())
1✔
748
        elif runtime is not None:
1✔
749
            args.extend(["--runtime", runtime])
×
750
        args.extend(
1✔
751
            [
752
                "--cap-add",
753
                "CAP_SYS_PTRACE",
754
                "--ipc=host",
755
                "-v",
756
                "/dev:/dev",
757
            ]
758
        )
759
        args.extend(self.get_device_cgroup_args())
1✔
760
        return args
1✔
761

762
    def get_environment_args(self) -> List[str]:
1✔
763
        """Environment variable arguments"""
764
        # Default GPU visibility is controlled via NVIDIA_VISIBLE_DEVICES (from the image and/or
765
        # environment args). This keeps the default behavior ("all") while allowing users to
766
        # override with `--gpus=...` or CDI `--device nvidia.com/gpu=...` in `--docker-opts`.
767
        nvidia_visible_devices = os.environ.get("NVIDIA_VISIBLE_DEVICES", "all")
1✔
768
        args = [
1✔
769
            "-e",
770
            "NVIDIA_DRIVER_CAPABILITIES=graphics,video,compute,utility,display",
771
            "-e",
772
            f"NVIDIA_VISIBLE_DEVICES={nvidia_visible_devices}",
773
            "-e",
774
            f"HOME=/workspace/{self.WORKSPACE_NAME}",
775
            "-e",
776
            f"CUPY_CACHE_DIR=/workspace/{self.WORKSPACE_NAME}/.cupy/kernel_cache",
777
            "-e",
778
            "HOLOSCAN_CLI_BUILD_LOCAL=1",
779
        ]
780
        # Pass CMAKE_BUILD_PARALLEL_LEVEL to container if set on host
781
        cmake_parallel_level = os.environ.get("CMAKE_BUILD_PARALLEL_LEVEL")
1✔
782
        if cmake_parallel_level:
1✔
783
            args.extend(["-e", f"CMAKE_BUILD_PARALLEL_LEVEL={cmake_parallel_level}"])
×
784
        # Forward host-side wrapper customizations that the in-container CLI needs
785
        # to reproduce project discovery and command routing decisions.
786
        for new_name in (
1✔
787
            "HOLOSCAN_CLI_PATH_PREFIX",
788
            "HOLOSCAN_CLI_SEARCH_PATH",
789
            "HOLOSCAN_CLI_CTEST_SCRIPT",
790
        ):
791
            value = os.environ.get(new_name)
1✔
792
            if value:
1✔
793
                args.extend(["-e", f"{new_name}={value}"])
1✔
794

795
        # Pass adequate variables for SCCACHE
796
        _, enable_sccache = get_env_bool("HOLOSCAN_CLI_ENABLE_SCCACHE", default=False)
1✔
797
        sccache_keys = [k for k in os.environ if k.startswith("SCCACHE_")]
1✔
798
        if enable_sccache:
1✔
799
            # Forward HOLOSCAN_CLI_ENABLE_SCCACHE so the in-container launcher
800
            # enables sccache before cmake build.
801
            args.extend(["-e", "HOLOSCAN_CLI_ENABLE_SCCACHE"])
1✔
802
            # Always set SCCACHE_DIR inside container to mounted path
803
            args.extend(["-e", f"SCCACHE_DIR={SCCACHE_CONTAINER_DIR}"])
1✔
804
            # Forward other SCCACHE_* environment variables present on host
805
            for k in sccache_keys:
1✔
806
                if k != "SCCACHE_DIR":
1✔
807
                    args.extend(["-e", k])
1✔
808
        elif len(sccache_keys) > 0:
1✔
809
            warn(
×
810
                "SCCACHE_* environment variables detected but HOLOSCAN_CLI_ENABLE_SCCACHE is "
811
                "disabled; not forwarding sccache environment variables into the container: "
812
                f"{', '.join(sccache_keys)}"
813
            )
814
        return args
1✔
815

816
    def get_display_options(self, enable_x11: bool, ssh_x11: bool) -> List[str]:
1✔
817
        """Get display-related Docker options from DISPLAY and WAYLAND_DISPLAY."""
818
        options = []
1✔
819
        del enable_x11, ssh_x11
1✔
820

821
        display = os.environ.get("DISPLAY")
1✔
822
        wayland_display = os.environ.get("WAYLAND_DISPLAY")
1✔
823
        if not display and not wayland_display:
1✔
824
            info(self.DISPLAY_FORWARDING_DISABLED_MESSAGE)
1✔
825
            return options
1✔
826

827
        if os.environ.get("XDG_SESSION_TYPE"):
×
828
            options.extend(["-e", "XDG_SESSION_TYPE"])
×
829

830
        # Required by Vulkan, dconf, pipewire, etc. on both X11 and Wayland.
831
        runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
×
832
        if runtime_dir and Path(runtime_dir).is_dir():
×
833
            options.extend(["-e", "XDG_RUNTIME_DIR", "-v", f"{runtime_dir}:{runtime_dir}"])
×
834

835
        if wayland_display:
×
836
            options.extend(["-e", "WAYLAND_DISPLAY"])
×
837

838
        if display:
×
839
            if Path("/tmp/.X11-unix").is_dir() and not self._is_ssh_x11_display(display):
×
840
                options.extend(["-v", "/tmp/.X11-unix:/tmp/.X11-unix:ro"])
×
841
            options.extend(["-e", "DISPLAY"])
×
842
            options.extend(self._get_xauth_options(display))
×
843

844
        return options
×
845

846
    @staticmethod
1✔
847
    def _is_ssh_x11_display(display: str) -> bool:
1✔
848
        return display.startswith(("localhost:", "127.0.0.1:", "[::1]:", "::1:"))
×
849

850
    def _get_xauth_options(self, display: str) -> List[str]:
1✔
851
        if not shutil.which("xauth"):
×
852
            warn(
×
853
                "xauth not found on host; install xauth (or x11-xauth) so X11 "
854
                "applications can authenticate inside the container."
855
            )
856
            return []
×
857

858
        if self.dryrun:
×
859
            placeholder = "/tmp/.docker.xauth"
×
860
            return ["-v", f"{placeholder}:{placeholder}:ro", "-e", f"XAUTHORITY={placeholder}"]
×
861

862
        result = run_command(
×
863
            ["xauth", "nlist", display],
864
            check=False,
865
            capture_output=True,
866
            text=True,
867
            dry_run=self.dryrun,
868
        )
869
        if result.returncode != 0 or not result.stdout:
×
870
            warn(
×
871
                f"xauth nlist returned no entries for DISPLAY={display}; "
872
                "X11 may not authenticate inside the container."
873
            )
874
            return []
×
875

876
        xauth_fd, xauth_file = tempfile.mkstemp(prefix=".docker.xauth-")
×
877
        os.close(xauth_fd)
×
878
        xauth_path = Path(xauth_file)
×
879

880
        xauth_entries = "".join(
×
881
            f"ffff{line[4:]}" for line in result.stdout.splitlines(keepends=True) if len(line) >= 4
882
        )
883
        merge_result = run_command(
×
884
            ["xauth", "-f", str(xauth_path), "nmerge", "-"],
885
            check=False,
886
            input=xauth_entries,
887
            text=True,
888
            dry_run=self.dryrun,
889
        )
890
        if merge_result.returncode != 0:
×
891
            xauth_path.unlink(missing_ok=True)
×
892
            warn(
×
893
                f"xauth nmerge failed for DISPLAY={display}; "
894
                "X11 may not authenticate inside the container."
895
            )
896
            return []
×
897

898
        self._display_temp_files.append(xauth_path)
×
899
        return ["-v", f"{xauth_path}:{xauth_path}:ro", "-e", f"XAUTHORITY={xauth_path}"]
×
900

901
    def _cleanup_display_temp_files(self) -> None:
1✔
902
        for path in self._display_temp_files:
1✔
903
            try:
×
904
                path.unlink(missing_ok=True)
×
905
            except OSError:
×
906
                # Suppress I/O errors so cleanup doesn't mask the original exception.
907
                pass
×
908
        self._display_temp_files.clear()
1✔
909

910
    def get_ngc_options(self) -> List[str]:
1✔
911
        """Get NGC-related options"""
912
        options = []
1✔
913
        if os.environ.get("NGC_CLI_API_KEY"):
1✔
914
            options.extend(["-e", "NGC_CLI_API_KEY"])
1✔
915
        if os.environ.get("NGC_CLI_ORG"):
1✔
916
            options.extend(["-e", "NGC_CLI_ORG"])
×
917
        if os.environ.get("NGC_CLI_TEAM"):
1✔
918
            options.extend(["-e", "NGC_CLI_TEAM"])
×
919
        # If NGC_CLI_API_KEY is set, the org is required even for public resources
920
        # Thus, set a default org if NGC_CLI_ORG is not set.
921
        if os.environ.get("NGC_CLI_API_KEY") and not os.environ.get("NGC_CLI_ORG"):
1✔
922
            options.extend(["-e", "NGC_CLI_ORG=nvidia"])
1✔
923
        return options
1✔
924

925
    def get_nsys_options(self, nsys_profile: bool, nsys_location: str) -> List[str]:
1✔
926
        """Get nsys-related options"""
927
        options = []
1✔
928
        if nsys_profile:
1✔
929
            options.extend(["--cap-add=SYS_ADMIN"])
1✔
930
        if nsys_location:
1✔
931
            options.extend(["-v", f"{nsys_location}:/opt/nvidia/nsys-host"])
1✔
932
        return options
1✔
933

934
    def get_pythonpath_options(
1✔
935
        self, local_sdk_root: Optional[Union[str, Path]], img: Optional[str] = None
936
    ) -> List[str]:
937
        """Build the PYTHONPATH docker environment flag for the container.
938

939
        Merges paths from three sources (SDK python lib, benchmarking dir, and
940
        any paths already baked into the Docker image) into a single,
941
        deduplicated PYTHONPATH value.
942

943
        When a local SDK is in use (via *local_sdk_root* or ``HOLOSCAN_SDK_ROOT``),
944
        its paths are placed **before** the image paths so the locally-built
945
        ``holoscan`` package is imported instead of the one shipped in the base image.
946
        """
947
        using_local_sdk = bool(local_sdk_root or os.environ.get("HOLOSCAN_SDK_ROOT"))
1✔
948
        benchmarking_path = f"/workspace/{self.WORKSPACE_NAME}/{self.BENCHMARKING_SUBDIR}"
1✔
949

950
        # Resolve SDK python/lib path
951
        if using_local_sdk:
1✔
952
            sdk_dir = find_hsdk_build_rel_dir(local_sdk_root)
×
953
            root = Path(local_sdk_root) if local_sdk_root else Path(os.environ["HOLOSCAN_SDK_ROOT"])
×
954
            if not Path(sdk_dir).is_absolute() and not is_valid_sdk_installation(root / sdk_dir):
×
955
                arch_gpu = get_arch_gpu_str()
×
956
                info(
×
957
                    f"Valid SDK installation not found."
958
                    f" Looking for 'install-{arch_gpu}' or 'build-{arch_gpu}'."
959
                )
960
            if Path(sdk_dir).is_absolute():
×
961
                sdk_python_lib = "/workspace/holoscan-sdk/python/lib"
×
962
            else:
963
                sdk_python_lib = f"/workspace/holoscan-sdk/{sdk_dir}/python/lib"
×
964
        else:
965
            sdk_python_lib = f"{self.SDK_PATH}/python/lib"
1✔
966

967
        image_paths = []
1✔
968
        if img:
1✔
969
            image_pythonpath = get_image_pythonpath(img, self.dryrun)
1✔
970
            if image_pythonpath:
1✔
971
                image_paths = [p for p in image_pythonpath.split(":") if p]
1✔
972

973
        # Local SDK paths first (if configured);
974
        # then image paths (preserving upstream defaults) and benchmarking path.
975
        if using_local_sdk:
1✔
976
            primary, secondary = [sdk_python_lib], image_paths
×
977
        else:
978
            primary, secondary = image_paths, [sdk_python_lib]
1✔
979

980
        all_paths = list(primary)
1✔
981
        all_paths.extend(p for p in secondary if p not in all_paths)
1✔
982
        if benchmarking_path not in all_paths:
1✔
983
            all_paths.append(benchmarking_path)
1✔
984
        return ["-e", f"PYTHONPATH={':'.join(all_paths)}"]
1✔
985

986
    def get_local_sdk_options(self, local_sdk_root: Optional[Union[str, Path]]) -> List[str]:
1✔
987
        """Get Holoscan SDK-related options"""
988
        if local_sdk_root is None:
×
989
            env_root = os.environ.get("HOLOSCAN_SDK_ROOT")
×
990
            if not env_root:
×
991
                fatal(
×
992
                    "Local Holoscan SDK root is not specified. "
993
                    "Please provide --local-sdk-root or set the HOLOSCAN_SDK_ROOT environment variable."
994
                )
995
            local_sdk_root = Path(env_root)
×
996
        else:
997
            local_sdk_root = Path(local_sdk_root)
×
998
        build_dir = find_hsdk_build_rel_dir(local_sdk_root)
×
999
        if not Path(build_dir).is_absolute() and not is_valid_sdk_installation(
×
1000
            local_sdk_root / build_dir
1001
        ):
1002
            arch_gpu = get_arch_gpu_str()
×
1003
            info(
×
1004
                f"Valid SDK installation not found."
1005
                f" Looking for 'install-{arch_gpu}' or 'build-{arch_gpu}'."
1006
            )
1007
        if Path(build_dir).is_absolute():
×
1008
            lib_path = "/workspace/holoscan-sdk/lib"
×
1009
        else:
1010
            lib_path = f"/workspace/holoscan-sdk/{build_dir}/lib"
×
1011
        return [
×
1012
            "-v",
1013
            f"{local_sdk_root}:/workspace/holoscan-sdk",
1014
            "-e",
1015
            f"HOLOSCAN_LIB_PATH={lib_path}",
1016
            "-e",
1017
            "HOLOSCAN_SAMPLE_DATA_PATH=/workspace/holoscan-sdk/data",
1018
            "-e",
1019
            "HOLOSCAN_TESTS_DATA_PATH=/workspace/holoscan-sdk/tests/data",
1020
        ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc