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

IBM / unitxt / 13183980113

06 Feb 2025 05:01PM UTC coverage: 79.029% (+0.1%) from 78.9%
13183980113

Pull #1536

github

web-flow
Merge 294eabf41 into 6549320bf
Pull Request #1536: Cached driven lazy loaders: load the data only when actually needed and keep in cache while in use

1456 of 1832 branches covered (79.48%)

Branch coverage included in aggregate %.

9209 of 11663 relevant lines covered (78.96%)

0.79 hits per line

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

84.21
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
class SideEffectOperator(StreamingOperator):
1✔
161
    """Base class for operators that does not affect the stream."""
162

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

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

171

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

175

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

181

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

185
    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`.
186
    """
187

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

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

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

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

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

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

224

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

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

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

236
    """
237

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

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

251

252
class StreamInitializerOperator(SourceOperator):
1✔
253
    """A class representing a stream initializer operator in the streaming system.
254

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

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

264
    """
265

266
    caching: bool = NonPositionalField(default=None)
1✔
267

268
    def __call__(self, *args, **kwargs) -> MultiStream:
1✔
269
        multi_stream = self.process(*args, **kwargs)
1✔
270
        if self.caching is not None:
1✔
271
            multi_stream.set_caching(self.caching)
×
272
        return self.process(*args, **kwargs)
1✔
273

274
    @abstractmethod
1✔
275
    def process(self, *args, **kwargs) -> MultiStream:
1✔
276
        pass
×
277

278

279
def instance_result(result_stream):
1✔
280
    result = list(result_stream)
1✔
281
    if len(result) == 0:
1✔
282
        return None
×
283
    if len(result) == 1:
1✔
284
        return result[0]
1✔
285
    return result
×
286

287

288
class StreamOperator(MultiStreamOperator):
1✔
289
    """A class representing a single-stream operator in the streaming system.
290

291
    A single-stream operator is a type of ``MultiStreamOperator`` that operates on individual
292
    ``Stream`` objects within a ``MultiStream``. It iterates through each ``Stream`` in the ``MultiStream``
293
    and applies the ``process`` method.
294

295
    The ``process`` method should be implemented by subclasses to define the specific operations
296
    to be performed on each ``Stream``.
297

298
    """
299

300
    apply_to_streams: List[str] = NonPositionalField(
1✔
301
        default=None
302
    )  # None apply to all streams
303
    dont_apply_to_streams: List[str] = NonPositionalField(default=None)
1✔
304

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

315
        return MultiStream(result)
1✔
316

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

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

336
        return (
1✔
337
            self.apply_to_streams is None or stream_name in self.apply_to_streams
338
        ) and (
339
            self.dont_apply_to_streams is None
340
            or stream_name not in self.dont_apply_to_streams
341
        )
342

343
    def _process_stream(
1✔
344
        self, stream: Stream, stream_name: Optional[str] = None
345
    ) -> Generator:
346
        yield from self.process(stream, stream_name)
1✔
347

348
    @abstractmethod
1✔
349
    def process(self, stream: Stream, stream_name: Optional[str] = None) -> Generator:
1✔
350
        pass
×
351

352
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
353
        instance = self.verify_instance(instance)
×
354
        processed_stream = self._process_single_stream(
×
355
            stream_single(instance), stream_name
356
        )
357
        return instance_result(processed_stream)
×
358

359

360
class SingleStreamOperator(StreamOperator):
1✔
361
    pass
1✔
362

363

364
class PagedStreamOperator(StreamOperator):
1✔
365
    """A class representing a paged-stream operator in the streaming system.
366

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

372
    Args:
373
        page_size (int):
374
            The size of each page in the stream. Defaults to 1000.
375

376
    """
377

378
    page_size: int = 1000
1✔
379

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

391
    def _process_page(
1✔
392
        self, page: List[Dict], stream_name: Optional[str] = None
393
    ) -> Generator:
394
        yield from self.process(page, stream_name)
1✔
395

396
    @abstractmethod
1✔
397
    def process(self, page: List[Dict], stream_name: Optional[str] = None) -> Generator:
1✔
398
        pass
×
399

400
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
401
        instance = self.verify_instance(instance)
×
402
        processed_stream = self._process_page([instance], stream_name)
×
403
        return instance_result(processed_stream)
×
404

405

406
class SingleStreamReducer(StreamingOperator):
1✔
407
    """A class representing a single-stream reducer in the streaming system.
408

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

412
    The ``process`` method should be implemented by subclasses to define the specific reduction operation
413
    to be performed on each ``Stream``.
414

415
    """
416

417
    def __call__(self, multi_stream: Optional[MultiStream] = None) -> Dict[str, Any]:
1✔
418
        result = {}
1✔
419
        for stream_name, stream in multi_stream.items():
1✔
420
            stream = self.process(stream)
1✔
421
            result[stream_name] = stream
1✔
422

423
        return result
1✔
424

425
    @abstractmethod
1✔
426
    def process(self, stream: Stream) -> Stream:
1✔
427
        pass
×
428

429

430
class InstanceOperator(StreamOperator):
1✔
431
    """A class representing a stream instance operator in the streaming system.
432

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

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

454
    def _process_instance(
1✔
455
        self, instance: Dict[str, Any], stream_name: Optional[str] = None
456
    ) -> Dict[str, Any]:
457
        instance = self.verify_instance(instance)
1✔
458
        return self.process(instance, stream_name)
1✔
459

460
    @abstractmethod
1✔
461
    def process(
1✔
462
        self, instance: Dict[str, Any], stream_name: Optional[str] = None
463
    ) -> Dict[str, Any]:
464
        pass
×
465

466
    def process_instance(self, instance, stream_name=constants.instance_stream):
1✔
467
        return self._process_instance(instance, stream_name)
1✔
468

469

470
class InstanceOperatorValidator(InstanceOperator):
1✔
471
    """A class representing a stream instance operator validator in the streaming system.
472

473
    A stream instance operator validator is a type of ``InstanceOperator`` that includes a validation step.
474
    It operates on individual instances within a ``Stream`` and validates the result of processing each instance.
475
    """
476

477
    @abstractmethod
1✔
478
    def validate(self, instance):
1✔
479
        pass
×
480

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

496

497
class InstanceOperatorWithMultiStreamAccess(StreamingOperator):
1✔
498
    """A class representing an instance operator with global access in the streaming system.
499

500
    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.
501
    It uses the `accessible_streams` attribute to determine which other streams it has access to.
502
    In order to make this efficient and to avoid qudratic complexity, it caches the accessible streams by default.
503
    """
504

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

511
        result = {}
×
512

513
        for stream_name, stream in multi_stream.items():
×
514
            stream = DynamicStream(
×
515
                self.generator,
516
                gen_kwargs={"stream": stream, "multi_stream": multi_stream},
517
            )
518
            result[stream_name] = stream
×
519

520
        return MultiStream(result)
×
521

522
    def generator(self, stream, multi_stream):
1✔
523
        yield from (
×
524
            self.process(self.verify_instance(instance), multi_stream)
525
            for instance in stream
526
        )
527

528
    @abstractmethod
1✔
529
    def process(self, instance: dict, multi_stream: MultiStream) -> dict:
1✔
530
        pass
×
531

532

533
class SequentialMixin(Artifact):
1✔
534
    max_steps: Optional[int] = None
1✔
535
    steps: List[StreamingOperator] = field(default_factory=list)
1✔
536

537
    def num_steps(self) -> int:
1✔
538
        return len(self.steps)
1✔
539

540
    def set_max_steps(self, max_steps):
1✔
541
        assert (
1✔
542
            max_steps <= self.num_steps()
543
        ), f"Max steps requested ({max_steps}) is larger than defined steps {self.num_steps()}"
544
        assert max_steps >= 1, f"Max steps requested ({max_steps}) is less than 1"
1✔
545
        self.max_steps = max_steps
1✔
546

547
    def get_last_step_description(self):
1✔
548
        last_step = (
×
549
            self.max_steps - 1 if self.max_steps is not None else len(self.steps) - 1
550
        )
551
        return self.steps[last_step].__description__
×
552

553
    def _get_max_steps(self):
1✔
554
        return self.max_steps if self.max_steps is not None else len(self.steps)
1✔
555

556

557
class SequentialOperator(MultiStreamOperator, SequentialMixin):
1✔
558
    """A class representing a sequential operator in the streaming system.
559

560
    A sequential operator is a type of `MultiStreamOperator` that applies a sequence of other operators to a
561
    `MultiStream`. It maintains a list of `StreamingOperator`s and applies them in order to the `MultiStream`.
562
    """
563

564
    def process(self, multi_stream: Optional[MultiStream] = None) -> MultiStream:
1✔
565
        for operator in self.steps[0 : self._get_max_steps()]:
1✔
566
            multi_stream = operator(multi_stream)
1✔
567
        return multi_stream
1✔
568

569

570
class SourceSequentialOperator(SourceOperator, SequentialMixin):
1✔
571
    """A class representing a source sequential operator in the streaming system.
572

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

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

587

588
class SequentialOperatorInitializer(SequentialOperator):
1✔
589
    """A class representing a sequential operator initializer in the streaming system.
590

591
    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.
592
    """
593

594
    def __call__(self, *args, **kwargs) -> MultiStream:
1✔
595
        return self.process(*args, **kwargs)
1✔
596

597
    def process(self, *args, **kwargs) -> MultiStream:
1✔
598
        assert (
1✔
599
            self.num_steps() > 0
600
        ), "Calling process on a SequentialOperatorInitializer without any steps"
601

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