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

IBM / unitxt / 15111789724

19 May 2025 11:28AM UTC coverage: 79.639% (-0.1%) from 79.766%
15111789724

Pull #1795

github

web-flow
Merge 6fa0f3c4f into 85c07cfe0
Pull Request #1795: Cards for the Real MM RAG datasets

1653 of 2063 branches covered (80.13%)

Branch coverage included in aggregate %.

10265 of 12902 relevant lines covered (79.56%)

0.8 hits per line

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

82.23
src/unitxt/operator.py
1
from abc import abstractmethod
1✔
2
from dataclasses import field
1✔
3
from typing import Any, Dict, Generator, List, Optional, Union
1✔
4

5
from pkg_resources import DistributionNotFound, VersionConflict, require
1✔
6

7
from .artifact import Artifact
1✔
8
from .dataclass import FinalField, InternalField, NonPositionalField
1✔
9
from .settings_utils import get_constants
1✔
10
from .stream import DynamicStream, EmptyStreamError, MultiStream, Stream
1✔
11

12
constants = get_constants()
1✔
13

14

15
class Operator(Artifact):
1✔
16
    pass
1✔
17

18

19
class PackageRequirementsMixin(Artifact):
1✔
20
    """Base class used to automatically check for the existence of required Python dependencies for an artifact (e.g., Operator or Metric).
21

22
    The _requirements_list is either a list of required packages or a dictionary mapping required packages to installation instructions.
23
    The _requirements_list should be used at class level definition, and the requirements at instance creation.
24

25
    - **List format**: Just specify the package names, optionally with version annotations (e.g., ["torch>=1.2.4", "numpy<1.19"]).
26
    - **Dict format**: Specify package names as keys and installation instructions as values
27
      (e.g., {"torch>=1.2.4": "Install torch with `pip install torch>=1.2.4`"}).
28

29
    When a package version annotation is specified (like `torch>=1.2.4`), the `check_missing_requirements` method
30
    verifies that the installed version meets the specified constraint.
31
    """
32

33
    _requirements_list: Union[List[str], Dict[str, str]] = InternalField(
1✔
34
        default_factory=list
35
    )
36
    requirements: Union[List[str], Dict[str, str]] = FinalField(
1✔
37
        also_positional=False, default_factory=list
38
    )
39

40
    def prepare(self):
1✔
41
        self.check_missing_requirements(self._requirements_list)
1✔
42
        self.check_missing_requirements(self.requirements)
1✔
43
        super().prepare()
1✔
44

45
    def check_missing_requirements(self, requirements=None):
1✔
46
        if requirements is None:
1✔
47
            requirements = self._requirements_list
1✔
48
        if isinstance(requirements, list):
1✔
49
            requirements = {package: "" for package in requirements}
1✔
50

51
        missing_packages = []
1✔
52
        version_mismatched_packages = []
1✔
53
        installation_instructions = []
1✔
54

55
        for package, installation_instruction in requirements.items():
1✔
56
            try:
1✔
57
                # Use pkg_resources.require to verify the package requirement
58
                require(package)
1✔
59
            except DistributionNotFound:
1✔
60
                missing_packages.append(package)
1✔
61
                installation_instructions.append(
1✔
62
                    installation_instruction
63
                    or f"Install {package} with `pip install {package}`"
64
                )
65
            except VersionConflict as e:
1✔
66
                version_mismatched_packages.append(
1✔
67
                    f"{package} (installed: {e.dist.version}, required: {e.req})"
68
                )
69
                installation_instructions.append(
1✔
70
                    installation_instruction
71
                    or f"Update {package} to the required version with `pip install '{package}'`"
72
                )
73

74
        if missing_packages or version_mismatched_packages:
1✔
75
            raise MissingRequirementsError(
1✔
76
                self.__class__.__name__,
77
                missing_packages,
78
                version_mismatched_packages,
79
                installation_instructions,
80
            )
81

82

83
class MissingRequirementsError(Exception):
1✔
84
    def __init__(
1✔
85
        self,
86
        class_name,
87
        missing_packages,
88
        version_mismatched_packages,
89
        installation_instructions,
90
    ):
91
        self.class_name = class_name
1✔
92
        self.missing_packages = missing_packages
1✔
93
        self.version_mismatched_packages = version_mismatched_packages
1✔
94
        self.installation_instructions = installation_instructions
1✔
95

96
        missing_message = (
1✔
97
            f"Missing package(s): {', '.join(self.missing_packages)}."
98
            if self.missing_packages
99
            else ""
100
        )
101
        version_message = (
1✔
102
            f"Version mismatch(es): {', '.join(self.version_mismatched_packages)}."
103
            if self.version_mismatched_packages
104
            else ""
105
        )
106

107
        self.message = (
1✔
108
            f"{self.class_name} requires the following dependencies:\n"
109
            f"{missing_message}\n{version_message}\n"
110
            + "\n".join(self.installation_instructions)
111
        )
112
        super().__init__(self.message)
1✔
113

114

115
class OperatorError(Exception):
1✔
116
    def __init__(self, exception: Exception, operators: List[Operator]):
1✔
117
        super().__init__(
×
118
            "This error was raised by the following operators: "
119
            + ",\n".join([str(operator) for operator in operators])
120
            + "."
121
        )
122
        self.exception = exception
×
123
        self.operators = operators
×
124

125
    @classmethod
1✔
126
    def from_operator_error(cls, exception: Exception, operator: Operator):
1✔
127
        return cls(exception.exception, [*exception.operators, operator])
×
128

129
    @classmethod
1✔
130
    def from_exception(cls, exception: Exception, operator: Operator):
1✔
131
        return cls(exception, [operator])
×
132

133

134
class StreamingOperator(Operator, PackageRequirementsMixin):
1✔
135
    """Base class for all stream operators in the streaming model.
136

137
    Stream operators are a key component of the streaming model and are responsible for processing continuous data streams.
138
    They perform operations such as transformations, aggregations, joins, windowing and more on these streams.
139
    There are several types of stream operators, including source operators, processing operators, etc.
140

141
    As a `StreamingOperator`, this class is responsible for performing operations on a stream, and must be implemented by all other specific types of stream operators in the system.
142
    When called, a `StreamingOperator` must return a MultiStream.
143

144
    As a subclass of `Artifact`, every `StreamingOperator` can be saved in a catalog for further usage or reference.
145

146
    """
147

148
    @abstractmethod
1✔
149
    def __call__(self, streams: Optional[MultiStream] = None) -> MultiStream:
1✔
150
        """Abstract method that performs operations on the stream.
151

152
        Args:
153
            streams (Optional[MultiStream]): The input MultiStream, which can be None.
154

155
        Returns:
156
            MultiStream: The output MultiStream resulting from the operations performed on the input.
157
        """
158

159

160

161
class SideEffectOperator(StreamingOperator):
1✔
162
    """Base class for operators that does not affect the stream."""
163

164
    def __call__(self, streams: Optional[MultiStream] = None) -> MultiStream:
1✔
165
        self.process()
×
166
        return streams
×
167

168
    @abstractmethod
1✔
169
    def process() -> None:
1✔
170
        pass
×
171

172

173
def instance_generator(instance):
1✔
174
    yield instance
1✔
175

176

177
def stream_single(instance: Dict[str, Any]) -> Stream:
1✔
178
    return DynamicStream(
1✔
179
        generator=instance_generator, gen_kwargs={"instance": instance}
180
    )
181

182

183
class MultiStreamOperator(StreamingOperator):
1✔
184
    """A class representing a multi-stream operator in the streaming system.
185

186
    A multi-stream operator is a type of `StreamingOperator` that operates on an entire MultiStream object at once. It takes a `MultiStream` as input and produces a `MultiStream` as output. The `process` method should be implemented by subclasses to define the specific operations to be performed on the input `MultiStream`.
187
    """
188

189
    caching: bool = NonPositionalField(default=None)
1✔
190

191
    def __call__(
1✔
192
        self, multi_stream: Optional[MultiStream] = None, **instance: Dict[str, Any]
193
    ) -> Union[MultiStream, Dict[str, Any]]:
194
        self.before_process_multi_stream()
1✔
195
        if instance:
1✔
196
            if multi_stream is not None:
×
197
                return self.process_instance(instance)
×
198
        result = self._process_multi_stream(multi_stream)
1✔
199
        if self.caching is not None:
1✔
200
            result.set_caching(self.caching)
1✔
201
        return result
1✔
202

203
    def before_process_multi_stream(self):
1✔
204
        pass
1✔
205

206
    def _process_multi_stream(
1✔
207
        self, multi_stream: Optional[MultiStream] = None
208
    ) -> MultiStream:
209
        result = self.process(multi_stream)
1✔
210
        assert isinstance(
1✔
211
            result, MultiStream
212
        ), "MultiStreamOperator must return a MultiStream"
213
        return result
1✔
214

215
    @abstractmethod
1✔
216
    def process(self, multi_stream: MultiStream) -> MultiStream:
1✔
217
        pass
×
218

219
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
220
        instance = self.verify_instance(instance)
1✔
221
        multi_stream = MultiStream({stream_name: stream_single(instance)})
1✔
222
        processed_multi_stream = self(multi_stream)
1✔
223
        return instance_result(processed_multi_stream[stream_name])
1✔
224

225

226
class SourceOperator(MultiStreamOperator):
1✔
227
    """A class representing a source operator in the streaming system.
228

229
    A source operator is responsible for generating the data stream from some source, such as a database or a file.
230
    This is the starting point of a stream processing pipeline.
231
    The ``SourceOperator`` class is a type of ``MultiStreamOperator``, which is a special type of ``StreamingOperator``
232
    that generates an output stream but does not take any input streams.
233

234
    When called, a ``SourceOperator`` invokes its ``process`` method, which should be implemented by all subclasses
235
    to generate the required ``MultiStream``.
236

237
    """
238

239
    def _process_multi_stream(
1✔
240
        self, multi_stream: Optional[MultiStream] = None
241
    ) -> MultiStream:
242
        result = self.process()
1✔
243
        assert isinstance(
1✔
244
            result, MultiStream
245
        ), "MultiStreamOperator must return a MultiStream"
246
        return result
1✔
247

248
    @abstractmethod
1✔
249
    def process(self) -> MultiStream:
1✔
250
        pass
×
251

252

253
    def get_splits(self):
1✔
254
        return list(self.process().keys())
×
255

256
class StreamInitializerOperator(SourceOperator):
1✔
257
    """A class representing a stream initializer operator in the streaming system.
258

259
    A stream initializer operator is a special type of ``SourceOperator`` that is capable
260
    of taking parameters during the stream generation process.
261
    This can be useful in situations where the stream generation process needs to be
262
    customized or configured based on certain parameters.
263

264
    When called, a ``StreamInitializerOperator`` invokes its ``process`` method, passing any supplied
265
    arguments and keyword arguments. The ``process`` method should be implemented by all subclasses
266
    to generate the required ``MultiStream`` based on the given arguments and keyword arguments.
267

268
    """
269

270
    caching: bool = NonPositionalField(default=None)
1✔
271

272
    def __call__(self, *args, **kwargs) -> MultiStream:
1✔
273
        multi_stream = self.process(*args, **kwargs)
1✔
274
        if self.caching is not None:
1✔
275
            multi_stream.set_caching(self.caching)
×
276
        return self.process(*args, **kwargs)
1✔
277

278
    @abstractmethod
1✔
279
    def process(self, *args, **kwargs) -> MultiStream:
1✔
280
        pass
×
281

282

283
def instance_result(result_stream):
1✔
284
    result = list(result_stream)
1✔
285
    if len(result) == 0:
1✔
286
        return None
×
287
    if len(result) == 1:
1✔
288
        return result[0]
1✔
289
    return result
×
290

291

292
class StreamOperator(MultiStreamOperator):
1✔
293
    """A class representing a single-stream operator in the streaming system.
294

295
    A single-stream operator is a type of ``MultiStreamOperator`` that operates on individual
296
    ``Stream`` objects within a ``MultiStream``. It iterates through each ``Stream`` in the ``MultiStream``
297
    and applies the ``process`` method.
298

299
    The ``process`` method should be implemented by subclasses to define the specific operations
300
    to be performed on each ``Stream``.
301

302
    """
303

304
    apply_to_streams: List[str] = NonPositionalField(
1✔
305
        default=None
306
    )  # None apply to all streams
307
    dont_apply_to_streams: List[str] = NonPositionalField(default=None)
1✔
308

309
    def _process_multi_stream(self, multi_stream: MultiStream) -> MultiStream:
1✔
310
        result = {}
1✔
311
        for stream_name, stream in multi_stream.items():
1✔
312
            if self._is_should_be_processed(stream_name):
1✔
313
                stream = self._process_single_stream(stream, stream_name)
1✔
314
            else:
315
                stream = stream
1✔
316
            assert isinstance(stream, Stream), "StreamOperator must return a Stream"
1✔
317
            result[stream_name] = stream
1✔
318

319
        return MultiStream(result)
1✔
320

321
    def _process_single_stream(
1✔
322
        self, stream: Stream, stream_name: Optional[str] = None
323
    ) -> Stream:
324
        return DynamicStream(
1✔
325
            self._process_stream,
326
            gen_kwargs={"stream": stream, "stream_name": stream_name},
327
        )
328

329
    def _is_should_be_processed(self, stream_name):
1✔
330
        if (
1✔
331
            self.apply_to_streams is not None
332
            and self.dont_apply_to_streams is not None
333
            and stream_name in self.apply_to_streams
334
            and stream_name in self.dont_apply_to_streams
335
        ):
336
            raise ValueError(
×
337
                f"Stream '{stream_name}' can be in either apply_to_streams or dont_apply_to_streams not both."
338
            )
339

340
        return (
1✔
341
            self.apply_to_streams is None or stream_name in self.apply_to_streams
342
        ) and (
343
            self.dont_apply_to_streams is None
344
            or stream_name not in self.dont_apply_to_streams
345
        )
346

347
    def _process_stream(
1✔
348
        self, stream: Stream, stream_name: Optional[str] = None
349
    ) -> Generator:
350
        yield from self.process(stream, stream_name)
1✔
351

352
    @abstractmethod
1✔
353
    def process(self, stream: Stream, stream_name: Optional[str] = None) -> Generator:
1✔
354
        pass
×
355

356
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
357
        instance = self.verify_instance(instance)
×
358
        processed_stream = self._process_single_stream(
×
359
            stream_single(instance), stream_name
360
        )
361
        return instance_result(processed_stream)
×
362

363

364
class SingleStreamOperator(StreamOperator):
1✔
365
    pass
1✔
366

367

368
class PagedStreamOperator(StreamOperator):
1✔
369
    """A class representing a paged-stream operator in the streaming system.
370

371
    A paged-stream operator is a type of ``StreamOperator`` that operates on a page of instances
372
    in a ``Stream`` at a time, where a page is a subset of instances.
373
    The ``process`` method should be implemented by subclasses to define the specific operations
374
    to be performed on each page.
375

376
    Args:
377
        page_size (int):
378
            The size of each page in the stream. Defaults to 1000.
379

380
    """
381

382
    page_size: int = 1000
1✔
383

384
    def _process_stream(
1✔
385
        self, stream: Stream, stream_name: Optional[str] = None
386
    ) -> Generator:
387
        page = []
1✔
388
        for instance in stream:
1✔
389
            page.append(instance)
1✔
390
            if len(page) >= self.page_size:
1✔
391
                yield from self.process(page, stream_name)
1✔
392
                page = []
1✔
393
        yield from self._process_page(page, stream_name)
1✔
394

395
    def _process_page(
1✔
396
        self, page: List[Dict], stream_name: Optional[str] = None
397
    ) -> Generator:
398
        yield from self.process(page, stream_name)
1✔
399

400
    @abstractmethod
1✔
401
    def process(self, page: List[Dict], stream_name: Optional[str] = None) -> Generator:
1✔
402
        pass
×
403

404
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
405
        instance = self.verify_instance(instance)
×
406
        processed_stream = self._process_page([instance], stream_name)
×
407
        return instance_result(processed_stream)
×
408

409

410
class SingleStreamReducer(StreamingOperator):
1✔
411
    """A class representing a single-stream reducer in the streaming system.
412

413
    A single-stream reducer is a type of ``StreamingOperator`` that operates on individual
414
    ``Stream`` objects within a ``MultiStream`` and reduces each ``Stream`` to a single output value.
415

416
    The ``process`` method should be implemented by subclasses to define the specific reduction operation
417
    to be performed on each ``Stream``.
418

419
    """
420

421
    def __call__(self, multi_stream: Optional[MultiStream] = None) -> Dict[str, Any]:
1✔
422
        result = {}
×
423
        for stream_name, stream in multi_stream.items():
×
424
            stream = self.process(stream)
×
425
            result[stream_name] = stream
×
426

427
        return result
×
428

429
    @abstractmethod
1✔
430
    def process(self, stream: Stream) -> Stream:
1✔
431
        pass
×
432

433

434
class InstanceOperator(StreamOperator):
1✔
435
    """A class representing a stream instance operator in the streaming system.
436

437
    A stream instance operator is a type of ``StreamOperator`` that operates on individual instances
438
    within a ``Stream``. It iterates through each instance in the ``Stream`` and applies the ``process`` method.
439
    The ``process`` method should be implemented by subclasses to define the specific operations
440
    to be performed on each instance.
441
    """
442

443
    def _process_stream(
1✔
444
        self, stream: Stream, stream_name: Optional[str] = None
445
    ) -> Generator:
446
        try:
1✔
447
            _index = None
1✔
448
            for _index, instance in enumerate(stream):
1✔
449
                yield self._process_instance(instance, stream_name)
1✔
450
        except Exception as e:
1✔
451
            if _index is None:
1✔
452
                raise e
1✔
453
            else:
454
                raise ValueError(
1✔
455
                    f"Error processing instance '{_index}' from stream '{stream_name}' in {self.__class__.__name__} due to the exception above."
456
                ) from e
457

458
    def _process_instance(
1✔
459
        self, instance: Dict[str, Any], stream_name: Optional[str] = None
460
    ) -> Dict[str, Any]:
461
        instance = self.verify_instance(instance)
1✔
462
        return self.process(instance, stream_name)
1✔
463

464
    @abstractmethod
1✔
465
    def process(
1✔
466
        self, instance: Dict[str, Any], stream_name: Optional[str] = None
467
    ) -> Dict[str, Any]:
468
        pass
×
469

470
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
471
        return self._process_instance(instance, stream_name)
1✔
472

473

474
class InstanceOperatorValidator(InstanceOperator):
1✔
475
    """A class representing a stream instance operator validator in the streaming system.
476

477
    A stream instance operator validator is a type of ``InstanceOperator`` that includes a validation step.
478
    It operates on individual instances within a ``Stream`` and validates the result of processing each instance.
479
    """
480

481
    @abstractmethod
1✔
482
    def validate(self, instance):
1✔
483
        pass
×
484

485
    def _process_stream(
1✔
486
        self, stream: Stream, stream_name: Optional[str] = None
487
    ) -> Generator:
488
        iterator = iter(stream)
1✔
489
        try:
1✔
490
            first_instance = next(iterator)
1✔
491
        except StopIteration as e:
×
492
            raise EmptyStreamError(f"Stream '{stream_name}' is empty") from e
×
493
        result = self._process_instance(first_instance, stream_name)
1✔
494
        self.validate(result, stream_name)
1✔
495
        yield result
1✔
496
        yield from (
1✔
497
            self._process_instance(instance, stream_name) for instance in iterator
498
        )
499

500

501
class InstanceOperatorWithMultiStreamAccess(StreamingOperator):
1✔
502
    """A class representing an instance operator with global access in the streaming system.
503

504
    An instance operator with global access is a type of `StreamingOperator` that operates on individual instances within a `Stream` and can also access other streams.
505
    It uses the `accessible_streams` attribute to determine which other streams it has access to.
506
    In order to make this efficient and to avoid qudratic complexity, it caches the accessible streams by default.
507
    """
508

509
    def __call__(
1✔
510
        self, multi_stream: Optional[MultiStream] = None, **instance: Dict[str, Any]
511
    ) -> MultiStream:
512
        if instance:
×
513
            raise NotImplementedError("Instance mode is not supported")
×
514

515
        result = {}
×
516

517
        for stream_name, stream in multi_stream.items():
×
518
            stream = DynamicStream(
×
519
                self.generator,
520
                gen_kwargs={"stream": stream, "multi_stream": multi_stream},
521
            )
522
            result[stream_name] = stream
×
523

524
        return MultiStream(result)
×
525

526
    def generator(self, stream, multi_stream):
1✔
527
        yield from (
×
528
            self.process(self.verify_instance(instance), multi_stream)
529
            for instance in stream
530
        )
531

532
    @abstractmethod
1✔
533
    def process(self, instance: dict, multi_stream: MultiStream) -> dict:
1✔
534
        pass
×
535

536

537
class SequentialMixin(Artifact):
1✔
538
    max_steps: Optional[int] = None
1✔
539
    steps: List[StreamingOperator] = field(default_factory=list)
1✔
540

541
    def num_steps(self) -> int:
1✔
542
        return len(self.steps)
1✔
543

544
    def set_max_steps(self, max_steps):
1✔
545
        assert (
1✔
546
            max_steps <= self.num_steps()
547
        ), f"Max steps requested ({max_steps}) is larger than defined steps {self.num_steps()}"
548
        assert max_steps >= 1, f"Max steps requested ({max_steps}) is less than 1"
1✔
549
        self.max_steps = max_steps
1✔
550

551
    def get_last_step_description(self):
1✔
552
        last_step = (
×
553
            self.max_steps - 1 if self.max_steps is not None else len(self.steps) - 1
554
        )
555
        return self.steps[last_step].__description__
×
556

557
    def _get_max_steps(self):
1✔
558
        return self.max_steps if self.max_steps is not None else len(self.steps)
1✔
559

560

561
class SequentialOperator(MultiStreamOperator, SequentialMixin):
1✔
562
    """A class representing a sequential operator in the streaming system.
563

564
    A sequential operator is a type of `MultiStreamOperator` that applies a sequence of other operators to a
565
    `MultiStream`. It maintains a list of `StreamingOperator`s and applies them in order to the `MultiStream`.
566
    """
567

568
    def process(self, multi_stream: Optional[MultiStream] = None) -> MultiStream:
1✔
569
        for operator in self.steps[0 : self._get_max_steps()]:
1✔
570
            multi_stream = operator(multi_stream)
1✔
571
        return multi_stream
1✔
572

573

574
class SourceSequentialOperator(SourceOperator, SequentialMixin):
1✔
575
    """A class representing a source sequential operator in the streaming system.
576

577
    A source sequential operator is a type of `SequentialOperator` that starts with a source operator.
578
    The first operator in its list of steps is a `SourceOperator`, which generates the initial `MultiStream`
579
    that the other operators then process.
580
    """
581

582
    def process(self, multi_stream: Optional[MultiStream] = None) -> MultiStream:
1✔
583
        assert (
1✔
584
            self.num_steps() > 0
585
        ), "Calling process on a SourceSequentialOperator without any steps"
586
        multi_stream = self.steps[0]()
1✔
587
        for operator in self.steps[1 : self._get_max_steps()]:
1✔
588
            multi_stream = operator(multi_stream)
1✔
589
        return multi_stream
1✔
590

591

592
class SequentialOperatorInitializer(SequentialOperator):
1✔
593
    """A class representing a sequential operator initializer in the streaming system.
594

595
    A sequential operator initializer is a type of `SequntialOperator` that starts with a stream initializer operator. The first operator in its list of steps is a `StreamInitializerOperator`, which generates the initial `MultiStream` based on the provided arguments and keyword arguments.
596
    """
597

598
    def __call__(self, *args, **kwargs) -> MultiStream:
1✔
599
        return self.process(*args, **kwargs)
1✔
600

601
    def process(self, *args, **kwargs) -> MultiStream:
1✔
602
        assert (
1✔
603
            self.num_steps() > 0
604
        ), "Calling process on a SequentialOperatorInitializer without any steps"
605

606
        assert isinstance(
1✔
607
            self.steps[0], StreamInitializerOperator
608
        ), "The first step in a SequentialOperatorInitializer must be a StreamInitializerOperator"
609
        multi_stream = self.steps[0](*args, **kwargs)
1✔
610
        for operator in self.steps[1 : self._get_max_steps()]:
1✔
611
            multi_stream = operator(multi_stream)
1✔
612
        return multi_stream
1✔
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