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

nvidia-holoscan / holoscan-cli / 13458548404

21 Feb 2025 02:10PM UTC coverage: 85.772% (+16.8%) from 68.934%
13458548404

Pull #24

github

web-flow
Add unit tests (#27)
Pull Request #24: HSDK 3.0.0 support

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

13 existing lines in 3 files now uncovered.

1688 of 1968 relevant lines covered (85.77%)

0.86 hits per line

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

97.84
/src/holoscan_cli/packager/parameters.py
1
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
# SPDX-License-Identifier: Apache-2.0
3
#
4
# Licensed under the Apache License, Version 2.0 (the "License");
5
# you may not use this file except in compliance with the License.
6
# You may obtain a copy of the License at
7
#
8
# http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
import logging
1✔
16
import os
1✔
17
import platform
1✔
18
from pathlib import Path
1✔
19
from typing import Any, Optional
1✔
20

21
from ..common.constants import SDK, Constants, DefaultValues
1✔
22
from ..common.dockerutils import parse_docker_image_name_and_tag
1✔
23
from ..common.enum_types import (
1✔
24
    ApplicationType,
25
    Arch,
26
    Platform,
27
    PlatformConfiguration,
28
    SdkType,
29
)
30
from ..common.exceptions import InvalidTagValueError, UnknownApplicationTypeError
1✔
31

32

33
class PlatformParameters:
1✔
34
    def __init__(
1✔
35
        self,
36
        platform: Platform,
37
        tag: str,
38
        version: str,
39
    ) -> None:
40
        self._logger = logging.getLogger("platform.parameters")
1✔
41
        self._platform = SDK.INTERNAL_PLATFORM_MAPPINGS[platform][0]
1✔
42
        self._platform_config: PlatformConfiguration = SDK.INTERNAL_PLATFORM_MAPPINGS[
1✔
43
            platform
44
        ][1]
45
        self._arch: Arch = SDK.PLATFORM_ARCH_MAPPINGS[platform]
1✔
46
        self._tag_prefix: Optional[str]
1✔
47
        self._version: Optional[str]
1✔
48

49
        (self._tag_prefix, self._version) = parse_docker_image_name_and_tag(tag)
1✔
50

51
        if self._tag_prefix is None:
1✔
52
            raise InvalidTagValueError(
×
53
                f"'{tag}' is not a valid Docker tag. Format: name[:tag]"
54
            )
55

56
        if self._version is None:
1✔
57
            self._version = version
1✔
58

59
        self._data: dict[str, Any] = {}
1✔
60
        self._data["tag"] = tag
1✔
61
        self._data["base_image"] = None
1✔
62
        self._data["build_image"] = None
1✔
63
        self._data["holoscan_sdk_file"] = None
1✔
64
        self._data["holoscan_sdk_filename"] = None
1✔
65
        self._data["monai_deploy_sdk_file"] = None
1✔
66
        self._data["monai_deploy_sdk_filename"] = None
1✔
67
        self._data["custom_base_image"] = False
1✔
68
        self._data["custom_holoscan_sdk"] = False
1✔
69
        self._data["custom_monai_deploy_sdk"] = False
1✔
70
        self._data["target_arch"] = "aarch64" if self._arch == Arch.arm64 else "x86_64"
1✔
71
        self._data["cuda_deb_arch"] = "sbsa" if self._arch == Arch.arm64 else "x86_64"
1✔
72
        self._data["holoscan_deb_arch"] = (
1✔
73
            "arm64" if self._arch == Arch.arm64 else "amd64"
74
        )
75
        self._data["gpu_type"] = self.platform_config.value
1✔
76

77
    @property
1✔
78
    def tag(self) -> str:
1✔
79
        return (
1✔
80
            f"{self._tag_prefix}-"
81
            f"{self.platform.value}-"
82
            f"{self.platform_config.value}-"
83
            f"{self.platform_arch.value}:"
84
            f"{self.version}"
85
        ).replace("/", "-")
86

87
    @property
1✔
88
    def tag_prefix(self) -> str:
1✔
89
        return self._tag_prefix
1✔
90

91
    @property
1✔
92
    def custom_base_image(self) -> Optional[str]:
1✔
93
        return self._data["custom_base_image"]
1✔
94

95
    @custom_base_image.setter
1✔
96
    def custom_base_image(self, value: str):
1✔
97
        self._data["custom_base_image"] = value
1✔
98

99
    @property
1✔
100
    def custom_holoscan_sdk(self) -> Optional[str]:
1✔
101
        return self._data["custom_holoscan_sdk"]
1✔
102

103
    @custom_holoscan_sdk.setter
1✔
104
    def custom_holoscan_sdk(self, value: str):
1✔
105
        self._data["custom_holoscan_sdk"] = value
1✔
106

107
    @property
1✔
108
    def custom_monai_deploy_sdk(self) -> Optional[str]:
1✔
109
        return self._data["custom_monai_deploy_sdk"]
1✔
110

111
    @custom_monai_deploy_sdk.setter
1✔
112
    def custom_monai_deploy_sdk(self, value: str):
1✔
113
        self._data["custom_monai_deploy_sdk"] = value
1✔
114

115
    @property
1✔
116
    def base_image(self) -> Optional[str]:
1✔
117
        return self._data["base_image"]
1✔
118

119
    @base_image.setter
1✔
120
    def base_image(self, value: str):
1✔
121
        self._data["base_image"] = value
1✔
122

123
    @property
1✔
124
    def build_image(self) -> Optional[str]:
1✔
125
        return self._data["build_image"]
1✔
126

127
    @build_image.setter
1✔
128
    def build_image(self, value: str):
1✔
129
        self._data["build_image"] = value
1✔
130

131
    @property
1✔
132
    def holoscan_sdk_file(self) -> Optional[Path]:
1✔
133
        return self._data["holoscan_sdk_file"]
1✔
134

135
    @holoscan_sdk_file.setter
1✔
136
    def holoscan_sdk_file(self, value: Path):
1✔
137
        self._data["holoscan_sdk_file"] = value
1✔
138
        if value is not None and hasattr(value, "name"):
1✔
139
            self._data["holoscan_sdk_filename"] = value.name
1✔
140
        elif value is not None:
1✔
141
            self._data["holoscan_sdk_filename"] = value
1✔
142

143
    @property
1✔
144
    def monai_deploy_sdk_file(self) -> Optional[Path]:
1✔
145
        return self._data["monai_deploy_sdk_file"]
1✔
146

147
    @monai_deploy_sdk_file.setter
1✔
148
    def monai_deploy_sdk_file(self, value: Path):
1✔
149
        self._data["monai_deploy_sdk_file"] = value
1✔
150
        if value is not None and hasattr(value, "name"):
1✔
151
            self._data["monai_deploy_sdk_filename"] = value.name
1✔
152

153
    @property
1✔
154
    def version(self) -> str:
1✔
155
        return self._version
1✔
156

157
    @property
1✔
158
    def health_probe(self) -> Optional[Path]:
1✔
159
        return self._data.get("health_probe", None)
1✔
160

161
    @health_probe.setter
1✔
162
    def health_probe(self, value: Optional[Path]):
1✔
163
        self._data["health_probe"] = value
1✔
164

165
    @property
1✔
166
    def platform_arch(self) -> Arch:
1✔
167
        return self._arch
1✔
168

169
    @property
1✔
170
    def docker_arch(self) -> str:
1✔
171
        return self._arch.value
1✔
172

173
    @property
1✔
174
    def platform(self) -> Platform:
1✔
175
        return self._platform
1✔
176

177
    @property
1✔
178
    def platform_config(self) -> PlatformConfiguration:
1✔
179
        return self._platform_config
1✔
180

181
    @property
1✔
182
    def to_jinja(self) -> dict[str, Any]:
1✔
UNCOV
183
        return self._data
×
184

185
    @property
1✔
186
    def same_arch_as_system(self) -> bool:
1✔
187
        return (platform.machine() == "aarch64" and self._arch == Arch.arm64) or (
1✔
188
            platform.machine() == "x86_64" and self._arch == Arch.amd64
189
        )
190

191
    @property
1✔
192
    def cuda_deb_arch(self) -> str:
1✔
193
        return self._data["cuda_deb_arch"]
1✔
194

195
    @property
1✔
196
    def holoscan_deb_arch(self) -> str:
1✔
197
        return self._data["holoscan_deb_arch"]
1✔
198

199
    @property
1✔
200
    def target_arch(self) -> str:
1✔
201
        return self._data["target_arch"]
1✔
202

203

204
class PlatformBuildResults:
1✔
205
    def __init__(self, parameters: PlatformParameters):
1✔
206
        self._parameters = parameters
1✔
207
        self._docker_tag: Optional[str] = None
1✔
208
        self._tarball_filenaem: Optional[str] = None
1✔
209
        self._succeeded = False
1✔
210
        self._error: Optional[str] = None
1✔
211

212
    @property
1✔
213
    def parameters(self) -> PlatformParameters:
1✔
214
        return self._parameters
×
215

216
    @property
1✔
217
    def error(self) -> Optional[str]:
1✔
218
        return self._error
×
219

220
    @error.setter
1✔
221
    def error(self, value: Optional[str]):
1✔
UNCOV
222
        self._error = value
×
223

224
    @property
1✔
225
    def docker_tag(self) -> Optional[str]:
1✔
226
        return self._docker_tag
×
227

228
    @docker_tag.setter
1✔
229
    def docker_tag(self, value: Optional[str]):
1✔
230
        self._docker_tag = value
1✔
231

232
    @property
1✔
233
    def tarball_filenaem(self) -> Optional[str]:
1✔
234
        return self._tarball_filenaem
1✔
235

236
    @tarball_filenaem.setter
1✔
237
    def tarball_filenaem(self, value: Optional[str]):
1✔
238
        self._tarball_filenaem = value
1✔
239

240
    @property
1✔
241
    def succeeded(self) -> bool:
1✔
242
        return self._succeeded
1✔
243

244
    @succeeded.setter
1✔
245
    def succeeded(self, value: bool):
1✔
246
        self._succeeded = value
1✔
247

248

249
class PackageBuildParameters:
1✔
250
    """
251
    Parameters required for building the Docker image with Jinja template.
252
    """
253

254
    def __init__(self):
1✔
255
        self._logger = logging.getLogger("packager.parameters")
1✔
256
        self._data = {}
1✔
257
        self._data["app_dir"] = DefaultValues.HOLOSCAN_APP_DIR
1✔
258
        self._data["lib_dir"] = DefaultValues.HOLOSCAN_LIB_DIR
1✔
259
        self._data["config_file_path"] = DefaultValues.HOLOSCAN_CONFIG_PATH
1✔
260
        self._data["docs_dir"] = DefaultValues.HOLOSCAN_DOCS_DIR
1✔
261
        self._data["logs_dir"] = DefaultValues.HOLOSCAN_LOGS_DIR
1✔
262
        self._data["full_input_path"] = DefaultValues.WORK_DIR / DefaultValues.INPUT_DIR
1✔
263
        self._data["full_output_path"] = (
1✔
264
            DefaultValues.WORK_DIR / DefaultValues.OUTPUT_DIR
265
        )
266
        self._data["input_dir"] = DefaultValues.INPUT_DIR
1✔
267
        self._data["models_dir"] = DefaultValues.MODELS_DIR
1✔
268
        self._data["output_dir"] = DefaultValues.OUTPUT_DIR
1✔
269
        self._data["timeout"] = DefaultValues.TIMEOUT
1✔
270
        self._data["working_dir"] = DefaultValues.WORK_DIR
1✔
271
        self._data["app_json"] = DefaultValues.APP_MANIFEST_PATH
1✔
272
        self._data["pkg_json"] = DefaultValues.PKG_MANIFEST_PATH
1✔
273
        self._data["username"] = DefaultValues.USERNAME
1✔
274
        self._data["build_cache"] = DefaultValues.BUILD_CACHE_DIR
1✔
275
        self._data["uid"] = os.getuid()
1✔
276
        self._data["gid"] = os.getgid()
1✔
277
        self._data["tarball_output"] = None
1✔
278
        self._data["cmake_args"] = ""
1✔
279
        self._data["includes"] = []
1✔
280
        self._data["additional_lib_paths"] = ""
1✔
281

282
        self._data["application_directory"] = None
1✔
283
        self._data["application_type"] = None
1✔
284
        self._data["application"] = None
1✔
285
        self._data["app_config_file_path"] = None
1✔
286
        self._data["command"] = None
1✔
287
        self._data["no_cache"] = False
1✔
288
        self._data["pip_packages"] = None
1✔
289
        self._data["requirements_file_path"] = None
1✔
290
        self._data["holoscan_sdk_version"] = None
1✔
291
        self._data["monai_deploy_app_sdk_version"] = None
1✔
292
        self._data["title"] = None
1✔
293
        self._data["version"] = None
1✔
294

295
        self._additional_libs = []
1✔
296

297
    @property
1✔
298
    def build_cache(self) -> int:
1✔
299
        return self._data["build_cache"]
1✔
300

301
    @build_cache.setter
1✔
302
    def build_cache(self, value: int):
1✔
303
        self._data["build_cache"] = value
1✔
304

305
    @property
1✔
306
    def full_input_path(self) -> str:
1✔
307
        return self._data["full_input_path"]
1✔
308

309
    @property
1✔
310
    def full_output_path(self) -> str:
1✔
311
        return self._data["full_output_path"]
1✔
312

313
    @property
1✔
314
    def docs_dir(self) -> str:
1✔
315
        return self._data["docs_dir"]
1✔
316

317
    @property
1✔
318
    def logs_dir(self) -> str:
1✔
319
        return self._data["logs_dir"]
1✔
320

321
    @property
1✔
322
    def tarball_output(self) -> int:
1✔
323
        return self._data["tarball_output"]
1✔
324

325
    @tarball_output.setter
1✔
326
    def tarball_output(self, value: int):
1✔
327
        self._data["tarball_output"] = value
1✔
328

329
    @property
1✔
330
    def cmake_args(self) -> str:
1✔
331
        return self._data["cmake_args"]
1✔
332

333
    @cmake_args.setter
1✔
334
    def cmake_args(self, value: str):
1✔
335
        self._data["cmake_args"] = value.strip('"') if value is not None else ""
1✔
336

337
    @property
1✔
338
    def gid(self) -> int:
1✔
339
        return self._data["gid"]
1✔
340

341
    @gid.setter
1✔
342
    def gid(self, value: int):
1✔
343
        self._data["gid"] = value
1✔
344

345
    @property
1✔
346
    def uid(self) -> int:
1✔
347
        return self._data["uid"]
1✔
348

349
    @uid.setter
1✔
350
    def uid(self, value: int):
1✔
351
        self._data["uid"] = value
1✔
352

353
    @property
1✔
354
    def username(self) -> str:
1✔
355
        return self._data["username"]
1✔
356

357
    @username.setter
1✔
358
    def username(self, value: str):
1✔
359
        self._data["username"] = value
1✔
360

361
    @property
1✔
362
    def app_manifest_path(self):
1✔
363
        return self._data["app_json"]
1✔
364

365
    @property
1✔
366
    def package_manifest_path(self):
1✔
367
        return self._data["pkg_json"]
1✔
368

369
    @property
1✔
370
    def title(self):
1✔
371
        return self._data["title"]
1✔
372

373
    @title.setter
1✔
374
    def title(self, value):
1✔
375
        self._data["title"] = value
1✔
376

377
    @property
1✔
378
    def docs(self) -> Path:
1✔
379
        return self._data.get("docs", None)
1✔
380

381
    @docs.setter
1✔
382
    def docs(self, value: Path):
1✔
383
        if value is not None:
1✔
384
            self._data["docs"] = value
1✔
385

386
    @property
1✔
387
    def models(self) -> dict[str, Path]:
1✔
388
        return self._data.get("models", None)
1✔
389

390
    @models.setter
1✔
391
    def models(self, value: dict[str, Path]):
1✔
392
        if value is not None:
1✔
393
            self._data["models"] = value
1✔
394

395
    @property
1✔
396
    def pip_packages(self):
1✔
397
        return self._data["pip_packages"]
1✔
398

399
    @pip_packages.setter
1✔
400
    def pip_packages(self, value):
1✔
401
        self._data["pip_packages"] = value
1✔
402

403
    @property
1✔
404
    def no_cache(self):
1✔
405
        return self._data["no_cache"]
1✔
406

407
    @no_cache.setter
1✔
408
    def no_cache(self, value):
1✔
409
        self._data["no_cache"] = value
1✔
410

411
    @property
1✔
412
    def config_file_path(self):
1✔
413
        return self._data["config_file_path"]
1✔
414

415
    @property
1✔
416
    def app_config_file_path(self):
1✔
417
        return self._data["app_config_file_path"]
1✔
418

419
    @app_config_file_path.setter
1✔
420
    def app_config_file_path(self, value):
1✔
421
        self._data["app_config_file_path"] = value
1✔
422

423
    @property
1✔
424
    def app_dir(self):
1✔
425
        return self._data["app_dir"]
1✔
426

427
    @property
1✔
428
    def application(self) -> Path:
1✔
429
        return self._data["application"]
1✔
430

431
    @application.setter
1✔
432
    def application(self, value: Path):
1✔
433
        self._data["application"] = value
1✔
434
        self._logger.info(f"Application: {self.application}")
1✔
435
        self._application_type = self._detect_application_type()
1✔
436
        self._data["application_type"] = self._application_type.name
1✔
437
        self._logger.info(f"Detected application type: {self.application_type.value}")
1✔
438
        self._data["application_directory"] = (
1✔
439
            self.application
440
            if os.path.isdir(self.application)
441
            else Path(os.path.dirname(self.application))
442
        )
443
        requirements_file_path = self.application_directory / "requirements.txt"
1✔
444
        if os.path.exists(requirements_file_path):
1✔
445
            self._data["requirements_file_path"] = requirements_file_path
1✔
446
        else:
447
            self._data["requirements_file_path"] = None
1✔
448
        self._data["command"] = self._set_app_command()
1✔
449
        self._data["command_filename"] = os.path.basename(self.application)
1✔
450

451
    @property
1✔
452
    def command_filename(self) -> str:
1✔
453
        return self._data["command_filename"]
1✔
454

455
    @property
1✔
456
    def command(self) -> str:
1✔
457
        return self._data["command"]
1✔
458

459
    @property
1✔
460
    def application_directory(self) -> Path:
1✔
461
        return self._data["application_directory"]
1✔
462

463
    @property
1✔
464
    def requirements_file_path(self) -> Path:
1✔
465
        return self._data["requirements_file_path"]
1✔
466

467
    @property
1✔
468
    def version(self) -> str:
1✔
469
        return self._data["version"]
1✔
470

471
    @version.setter
1✔
472
    def version(self, value: str):
1✔
473
        self._data["version"] = value
1✔
474

475
    @property
1✔
476
    def timeout(self) -> int:
1✔
477
        return self._data["timeout"]
1✔
478

479
    @timeout.setter
1✔
480
    def timeout(self, value: int):
1✔
481
        self._data["timeout"] = value
1✔
482

483
    @property
1✔
484
    def working_dir(self) -> Path:
1✔
485
        return self._data["working_dir"]
1✔
486

487
    @property
1✔
488
    def models_dir(self) -> Path:
1✔
489
        return self._data["models_dir"]
1✔
490

491
    @models_dir.setter
1✔
492
    def models_dir(self, value: Path):
1✔
493
        self._data["models_dir"] = value
×
494

495
    @property
1✔
496
    def input_dir(self) -> str:
1✔
497
        return self._data["input_dir"]
1✔
498

499
    @property
1✔
500
    def output_dir(self) -> str:
1✔
501
        return self._data["output_dir"]
1✔
502

503
    @property
1✔
504
    def application_type(self) -> ApplicationType:
1✔
505
        return self._application_type
1✔
506

507
    @property
1✔
508
    def sdk(self) -> SdkType:
1✔
509
        return self._data["sdk"]
1✔
510

511
    @sdk.setter
1✔
512
    def sdk(self, value: SdkType):
1✔
513
        self._data["sdk"] = value
1✔
514
        self._data["sdk_type"] = value.value
1✔
515

516
    @property
1✔
517
    def holoscan_sdk_version(self) -> str:
1✔
518
        return self._data["holoscan_sdk_version"]
1✔
519

520
    @holoscan_sdk_version.setter
1✔
521
    def holoscan_sdk_version(self, value: str):
1✔
522
        self._data["holoscan_sdk_version"] = value
1✔
523

524
    @property
1✔
525
    def monai_deploy_app_sdk_version(self) -> str:
1✔
526
        return self._data["monai_deploy_app_sdk_version"]
1✔
527

528
    @monai_deploy_app_sdk_version.setter
1✔
529
    def monai_deploy_app_sdk_version(self, value: str):
1✔
530
        self._data["monai_deploy_app_sdk_version"] = value
1✔
531

532
    @property
1✔
533
    def includes(self) -> str:
1✔
534
        return self._data["includes"]
1✔
535

536
    @includes.setter
1✔
537
    def includes(self, value: str):
1✔
538
        self._data["includes"] = value
1✔
539

540
    @property
1✔
541
    def additional_lib_paths(self) -> str:
1✔
542
        """
543
        Additional libraries that user wants to include in the package.
544
        Stores the post-processed values to be injected into LD_LIBRARY_PATH and PYTHONPATH in the
545
        Jinja2 template.
546
        """
547
        return self._data["additional_lib_paths"]
1✔
548

549
    @additional_lib_paths.setter
1✔
550
    def additional_lib_paths(self, value: str):
1✔
551
        self._data["additional_lib_paths"] = value
1✔
552

553
    @property
1✔
554
    def additional_libs(self) -> list[str]:
1✔
555
        """
556
        Additional libraries that user wants to include in the package.
557
        Stores paths entered from the command line before processing.
558
        """
559
        return self._additional_libs
1✔
560

561
    @additional_libs.setter
1✔
562
    def additional_libs(self, value: list[str]):
1✔
563
        self._additional_libs = value
1✔
564

565
    @property
1✔
566
    def to_jinja(self) -> dict[str, Any]:
1✔
UNCOV
567
        return self._data
×
568

569
    def _detect_application_type(self) -> ApplicationType:
1✔
570
        if os.path.isdir(self.application):
1✔
571
            if os.path.exists(self.application / Constants.PYTHON_MAIN_FILE):
1✔
572
                return ApplicationType.PythonModule
1✔
573
            elif os.path.exists(self.application / Constants.CPP_CMAKELIST_FILE):
1✔
574
                return ApplicationType.CppCMake
1✔
575
        elif os.path.isfile(self.application):
1✔
576
            if Path(self.application).suffix == ".py":
1✔
577
                return ApplicationType.PythonFile
1✔
578
            elif os.access(self.application, os.X_OK):
1✔
579
                return ApplicationType.Binary
1✔
580

581
        raise UnknownApplicationTypeError(
1✔
582
            f"""\n\nUnable to determine application type. Please ensure the application path
583
            contains one of the following:
584
    \t- Python directory/module with '{Constants.PYTHON_MAIN_FILE}'
585
    \t- Python file
586
    \t- C++ source directory with '{Constants.CPP_CMAKELIST_FILE}'
587
    \t- Binary file"""
588
        )
589

590
    def _set_app_command(self) -> str:
1✔
591
        if self.application_type == ApplicationType.PythonFile:
1✔
592
            return (
1✔
593
                f'["{Constants.PYTHON_EXECUTABLE}", '
594
                + f'"{os.path.join(self._data["app_dir"], os.path.basename(self.application))}"]'
595
            )
596
        elif self.application_type == ApplicationType.PythonModule:
1✔
597
            return f'["{Constants.PYTHON_EXECUTABLE}", "{self._data["app_dir"]}"]'
1✔
598
        elif self.application_type in [
1✔
599
            ApplicationType.CppCMake,
600
            ApplicationType.Binary,
601
        ]:
602
            return f'["{os.path.join(self._data["app_dir"], os.path.basename(self.application))}"]'
1✔
603

604
        raise UnknownApplicationTypeError("Unsupported application type.")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc