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

mborsetti / webchanges / 11226808835

08 Oct 2024 01:21AM UTC coverage: 77.666% (-0.1%) from 77.792%
11226808835

push

github

mborsetti
Version 3.26.0rc0

1751 of 2524 branches covered (69.37%)

Branch coverage included in aggregate %.

4477 of 5495 relevant lines covered (81.47%)

4.75 hits per line

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

82.1
/webchanges/differs.py
1
"""Differs."""
2

3
# The code below is subject to the license contained in the LICENSE file, which is part of the source code.
4

5
from __future__ import annotations
6✔
6

7
import base64
6✔
8
import difflib
6✔
9
import html
6✔
10
import json
6✔
11
import logging
6✔
12
import math
6✔
13
import os
6✔
14
import re
6✔
15
import shlex
6✔
16
import subprocess  # noqa: S404 Consider possible security implications associated with the subprocess module.
6✔
17
import tempfile
6✔
18
import traceback
6✔
19
import urllib.parse
6✔
20
import warnings
6✔
21
from base64 import b64encode
6✔
22
from datetime import datetime
6✔
23
from io import BytesIO
6✔
24
from pathlib import Path
6✔
25
from typing import Any, Iterator, Literal, TYPE_CHECKING
6✔
26
from zoneinfo import ZoneInfo
6✔
27

28
import html2text
6✔
29

30
from webchanges.util import linkify, mark_to_html, TrackSubClasses
6✔
31

32
try:
6✔
33
    from deepdiff import DeepDiff
6✔
34
    from deepdiff.model import DiffLevel
6✔
35
except ImportError as e:  # pragma: no cover
36
    DeepDiff = str(e)  # type: ignore[no-redef]
37

38
try:
6✔
39
    import httpx
6✔
40
except ImportError:  # pragma: no cover
41
    httpx = None  # type: ignore[assignment]
42
if httpx is not None:
6!
43
    try:
6✔
44
        import h2
6✔
45
    except ImportError:  # pragma: no cover
46
        h2 = None  # type: ignore[assignment]
47

48
try:
6✔
49
    import numpy as np
6✔
50
except ImportError as e:  # pragma: no cover
51
    np = str(e)  # type: ignore[assignment]
52

53
try:
6✔
54
    from PIL import Image, ImageChops, ImageEnhance, ImageStat
6✔
55
except ImportError as e:  # pragma: no cover
56
    Image = str(e)  # type: ignore[assignment]
57

58
# https://stackoverflow.com/questions/712791
59
try:
6✔
60
    import simplejson as jsonlib
6✔
61
except ImportError:  # pragma: no cover
62
    import json as jsonlib  # type: ignore[no-redef]
63

64
try:
6✔
65
    import xmltodict
6✔
66
except ImportError as e:  # pragma: no cover
67
    xmltodict = str(e)  # type: ignore[no-redef]
68

69
# https://stackoverflow.com/questions/39740632
70
if TYPE_CHECKING:
71
    from webchanges.handler import JobState
72

73

74
logger = logging.getLogger(__name__)
6✔
75

76

77
class DifferBase(metaclass=TrackSubClasses):
6✔
78
    """The base class for differs."""
79

80
    __subclasses__: dict[str, type[DifferBase]] = {}
6✔
81
    __anonymous_subclasses__: list[type[DifferBase]] = []
6✔
82

83
    __kind__: str = ''
6✔
84

85
    __supported_directives__: dict[str, str] = {}  # this must be present, even if empty
6✔
86

87
    css_added_style = 'background-color:#d1ffd1;color:#082b08;'
6✔
88
    css_deltd_style = 'background-color:#fff0f0;color:#9c1c1c;text-decoration:line-through;'
6✔
89

90
    def __init__(self, state: JobState) -> None:
6✔
91
        """
92

93
        :param state: the JobState.
94
        """
95
        self.job = state.job
6✔
96
        self.state = state
6✔
97

98
    @classmethod
6✔
99
    def differ_documentation(cls) -> str:
6✔
100
        """Generates simple differ documentation for use in the --features command line argument.
101

102
        :returns: A string to display.
103
        """
104
        result: list[str] = []
6✔
105
        for sc in TrackSubClasses.sorted_by_kind(cls):
6✔
106
            # default_subdirective = getattr(sc, '__default_subdirective__', None)
107
            result.extend((f'  * {sc.__kind__} - {sc.__doc__}',))
6✔
108
            if hasattr(sc, '__supported_directives__'):
6!
109
                for key, doc in sc.__supported_directives__.items():
6✔
110
                    result.append(f'      {key} ... {doc}')
6✔
111
        result.append('\n[] ... Parameter can be supplied as unnamed value\n')
6✔
112
        return '\n'.join(result)
6✔
113

114
    @classmethod
6✔
115
    def normalize_differ(
6✔
116
        cls,
117
        differ_spec: dict[str, Any] | None,
118
        job_index_number: int | None = None,
119
    ) -> tuple[str, dict[str, Any]]:
120
        """Checks the differ_spec for its validity and applies default values.
121

122
        :param differ_spec: The differ as entered by the user; use "unified" if empty.
123
        :param job_index_number: The job index number.
124
        :returns: A validated differ_kind, subdirectives (where subdirectives is a dict).
125
        """
126
        differ_spec = differ_spec or {'name': 'unified'}
6✔
127
        subdirectives = differ_spec.copy()
6✔
128
        differ_kind = subdirectives.pop('name', '')
6✔
129
        if not differ_kind:
6✔
130
            if list(subdirectives.keys()) == ['command']:
6!
131
                differ_kind = 'command'
6✔
132
            else:
133
                raise ValueError(
×
134
                    f"Job {job_index_number}: Differ directive must have a 'name' sub-directive: {differ_spec}."
135
                )
136

137
        differcls = cls.__subclasses__.get(differ_kind, None)
6✔
138
        if not differcls:
6✔
139
            raise ValueError(f'Job {job_index_number}: No differ named {differ_kind}.')
6✔
140

141
        if hasattr(differcls, '__supported_directives__'):
6!
142
            provided_keys = set(subdirectives.keys())
6✔
143
            allowed_keys = set(differcls.__supported_directives__.keys())
6✔
144
            unknown_keys = provided_keys.difference(allowed_keys)
6✔
145
            if unknown_keys and '<any>' not in allowed_keys:
6✔
146
                raise ValueError(
6✔
147
                    f'Job {job_index_number}: Differ {differ_kind} does not support sub-directive(s) '
148
                    f"{', '.join(unknown_keys)} (supported: {', '.join(sorted(allowed_keys))})."
149
                )
150

151
        return differ_kind, subdirectives
6✔
152

153
    @classmethod
6✔
154
    def process(
6✔
155
        cls,
156
        differ_kind: str,
157
        directives: dict[str, Any],
158
        job_state: JobState,
159
        report_kind: Literal['text', 'markdown', 'html'] = 'text',
160
        tz: ZoneInfo | None = None,
161
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
162
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
163
        """Process the differ.
164

165
        :param differ_kind: The name of the differ.
166
        :param directives: The directives.
167
        :param job_state: The JobState.
168
        :param report_kind: The report kind required.
169
        :param tz: The timezone of the report.
170
        :param _unfiltered_diff: Any previous diffs generated by the same filter, who can be used to generate a diff
171
           for a different report_kind.
172
        :returns: The output of the differ or an error message with traceback if it fails.
173
        """
174
        logger.info(f'Job {job_state.job.index_number}: Applying differ {differ_kind}, directives {directives}')
6✔
175
        differcls: type[DifferBase] | None = cls.__subclasses__.get(differ_kind)  # type: ignore[assignment]
6✔
176
        if differcls:
6✔
177
            try:
6✔
178
                return differcls(job_state).differ(directives, report_kind, _unfiltered_diff, tz)
6✔
179
            except Exception as e:
6✔
180
                # Differ failed
181
                logger.info(
6✔
182
                    f'Job {job_state.job.index_number}: Differ {differ_kind} with {directives=} encountered '
183
                    f'error {e}'
184
                )
185
                # Undo saving of new data since user won't see the diff
186
                job_state.delete_latest()
6✔
187

188
                job_state.exception = e
6✔
189
                job_state.traceback = job_state.job.format_error(e, traceback.format_exc())
6✔
190
                directives_text = ', '.join(f'{key}={value}' for key, value in directives.items()) or 'None'
6✔
191
                return {
6✔
192
                    'text': (
193
                        f'Differ {differ_kind} with directive(s) {directives_text} encountered an '
194
                        f'error:\n\n{job_state.traceback.strip()}'
195
                    ),
196
                    'markdown': (
197
                        f'## Differ {differ_kind} with directive(s) {directives_text} '
198
                        f'encountered an error:\n```\n{job_state.traceback.strip()}\n```\n'
199
                    ),
200
                    'html': (
201
                        f'<span style="color:red;font-weight:bold">Differ {differ_kind} with directive(s) '
202
                        f'{directives_text} encountered an error:<br>\n<br>\n'
203
                        f'<span style="font-family:monospace;white-space:pre-wrap;">{job_state.traceback.strip()}'
204
                        f'</span></span>'
205
                    ),
206
                }
207
        else:
208
            return {}
6✔
209

210
    def differ(
6✔
211
        self,
212
        directives: dict[str, Any],
213
        report_kind: Literal['text', 'markdown', 'html'],
214
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
215
        tz: ZoneInfo | None = None,
216
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
217
        """Create a diff from the data. Since this function could be called by different reporters of multiple report
218
        types ('text', 'markdown', 'html'), the differ outputs a dict with data for the report_kind it generated so
219
        that it can be reused.
220

221
        :param directives: The directives.
222
        :param report_kind: The report_kind for which a diff must be generated (at a minimum).
223
        :param _unfiltered_diff: Any previous diffs generated by the same filter, who can be used to generate a diff
224
           for a different report_kind.
225
        :param tz: The timezone of the report.
226
        :returns: An empty dict if there is no change, otherwise a dict with report_kind as key and diff as value
227
           (as a minimum for the report_kind requested).
228
        :raises RuntimeError: If the external diff tool returns an error.
229
        """
230
        raise NotImplementedError()
231

232
    @staticmethod
6✔
233
    def make_timestamp(
6✔
234
        timestamp: float,
235
        tz: ZoneInfo | None = None,
236
    ) -> str:
237
        """Creates a datetime string in RFC 5322 (email) format with the time zone name (if available) in the
238
        Comments and Folding White Space (CFWS) section.
239

240
        :param timestamp: The timestamp.
241
        :param tz: The IANA timezone of the report.
242
        :returns: A datetime string in RFC 5322 (email) format.
243
        """
244
        if timestamp:
6✔
245
            dt = datetime.fromtimestamp(timestamp).astimezone(tz=tz)
6✔
246
            # add timezone name if known
247
            if dt.strftime('%Z') != dt.strftime('%z')[:3]:
6✔
248
                cfws = f" ({dt.strftime('%Z')})"
6✔
249
            else:
250
                cfws = ''
6✔
251
            return dt.strftime('%a, %d %b %Y %H:%M:%S %z') + cfws
6✔
252
        else:
253
            return 'NEW'
6✔
254

255
    @staticmethod
6✔
256
    def html2text(data: str) -> str:
6✔
257
        """Converts html to text.
258

259
        :param data: the string in html format.
260
        :returns: the string in text format.
261
        """
262
        parser = html2text.HTML2Text()
6✔
263
        parser.unicode_snob = True
6✔
264
        parser.body_width = 0
6✔
265
        parser.ignore_images = True
6✔
266
        parser.single_line_break = True
6✔
267
        parser.wrap_links = False
6✔
268
        return '\n'.join(line.rstrip() for line in parser.handle(data).splitlines())
6✔
269

270
    def raise_import_error(self, package_name: str, error_message: str) -> None:
6✔
271
        """Raise ImportError for missing package.
272

273
        :param package_name: The name of the module/package that could not be imported.
274
        :param error_message: The error message from ImportError.
275

276
        :raises: ImportError.
277
        """
278
        raise ImportError(
6✔
279
            f"Job {self.job.index_number}: Python package '{package_name}' is not installed; cannot use "
280
            f"'differ: {self.__kind__}' ({self.job.get_location()})\n{error_message}"
281
        )
282

283

284
class UnifiedDiffer(DifferBase):
6✔
285
    """(Default) Generates a unified diff."""
286

287
    __kind__ = 'unified'
6✔
288

289
    __supported_directives__ = {
6✔
290
        'context_lines': 'the number of context lines (default: 3)',
291
        'range_info': 'include range information lines (default: true)',
292
        'additions_only': 'keep only addition lines (default: false)',
293
        'deletions_only': 'keep only deletion lines (default: false)',
294
    }
295

296
    def unified_diff_to_html(self, diff: str) -> Iterator[str]:
6✔
297
        """
298
        Generates a colorized HTML table from unified diff, applying styles and processing based on job values.
299

300
        :param diff: the unified diff
301
        """
302

303
        def process_line(line: str, line_num: int, is_markdown: bool, monospace_style: str) -> str:
6✔
304
            """
305
            Processes each line for HTML output, handling special cases and styles.
306

307
            :param line: The line to analyze.
308
            :param line_num: The line number in the document.
309
            :param monospace_style: Additional style string for monospace text.
310

311
            :returns: The line processed into an HTML table row string.
312
            """
313
            # The style= string (or empty string) to add to an HTML tag.
314
            if line_num == 0:
6✔
315
                style = 'font-family:monospace;color:darkred;'
6✔
316
            elif line_num == 1:
6✔
317
                style = 'font-family:monospace;color:darkgreen;'
6✔
318
            elif line[0] == '+':  # addition
6✔
319
                style = f'{monospace_style}{self.css_added_style}'
6✔
320
            elif line[0] == '-':  # deletion
6✔
321
                style = f'{monospace_style}{self.css_deltd_style}'
6✔
322
            elif line[0] == ' ':  # context line
6✔
323
                style = monospace_style
6✔
324
            elif line[0] == '@':  # range information
6✔
325
                style = 'font-family:monospace;background-color:#fbfbfb;'
6✔
326
            elif line[0] == '/':  # informational header added by additions_only or deletions_only filters
6!
327
                style = 'background-color:lightyellow;'
6✔
328
            else:
329
                raise RuntimeError('Unified Diff does not comform to standard!')
×
330
            style = f' style="{style}"' if style else ''
6✔
331

332
            if line_num > 1 and line[0] != '@':  # don't apply to headers or range information
6✔
333
                if is_markdown or line[0] == '/':  # our informational header
6✔
334
                    line = mark_to_html(line[1:], self.job.markdown_padded_tables)
6✔
335
                else:
336
                    line = linkify(line[1:])
6✔
337
            return f'<tr><td{style}>{line}</td></tr>'
6✔
338

339
        table_style = (
6✔
340
            ' style="border-collapse:collapse;font-family:monospace;white-space:pre-wrap;"'
341
            if self.job.monospace
342
            else ' style="border-collapse:collapse;"'
343
        )
344
        yield f'<table{table_style}>'
6✔
345
        is_markdown = self.state.is_markdown()
6✔
346
        monospace_style = 'font-family:monospace;' if self.job.monospace else ''
6✔
347
        for i, line in enumerate(diff.splitlines()):
6✔
348
            yield process_line(line, i, is_markdown, monospace_style)
6✔
349
        yield '</table>'
6✔
350

351
    def differ(
6✔
352
        self,
353
        directives: dict[str, Any],
354
        report_kind: Literal['text', 'markdown', 'html'],
355
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
356
        tz: ZoneInfo | None = None,
357
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
358
        additions_only = directives.get('additions_only') or self.job.additions_only
6✔
359
        deletions_only = directives.get('deletions_only') or self.job.deletions_only
6✔
360
        out_diff: dict[Literal['text', 'markdown', 'html'], str] = {}
6✔
361
        if report_kind == 'html' and _unfiltered_diff is not None and 'text' in _unfiltered_diff:
6✔
362
            diff_text = _unfiltered_diff['text']
6✔
363
        else:
364
            empty_return: dict[Literal['text', 'markdown', 'html'], str] = {'text': '', 'markdown': '', 'html': ''}
6✔
365
            contextlines = directives.get('context_lines', self.job.contextlines)
6✔
366
            if contextlines is None:
6✔
367
                if additions_only or deletions_only:
6✔
368
                    contextlines = 0
6✔
369
                else:
370
                    contextlines = 3
6✔
371
            diff = list(
6✔
372
                difflib.unified_diff(
373
                    str(self.state.old_data).splitlines(),
374
                    str(self.state.new_data).splitlines(),
375
                    '@',
376
                    '@',
377
                    self.make_timestamp(self.state.old_timestamp, tz),
378
                    self.make_timestamp(self.state.new_timestamp, tz),
379
                    contextlines,
380
                    lineterm='',
381
                )
382
            )
383
            if not diff:
6✔
384
                self.state.verb = 'changed,no_report'
6✔
385
                return empty_return
6✔
386
            # replace tabs in header lines
387
            diff[0] = diff[0].replace('\t', ' ')
6✔
388
            diff[1] = diff[1].replace('\t', ' ')
6✔
389

390
            if additions_only:
6✔
391
                if len(self.state.old_data) and len(self.state.new_data) / len(self.state.old_data) <= 0.25:
6✔
392
                    diff = (
6✔
393
                        diff[:2]
394
                        + ['/**Comparison type: Additions only**']
395
                        + ['/**Deletions are being shown as 75% or more of the content has been deleted**']
396
                        + diff[2:]
397
                    )
398
                else:
399
                    head = '---' + diff[0][3:]
6✔
400
                    diff = [line for line in diff if line.startswith('+') or line.startswith('@')]
6✔
401
                    diff = [
6✔
402
                        line1
403
                        for line1, line2 in zip([''] + diff, diff + [''])
404
                        if not (line1.startswith('@') and line2.startswith('@'))
405
                    ][1:]
406
                    diff = diff[:-1] if diff[-1].startswith('@') else diff
6✔
407
                    if len(diff) == 1 or len([line for line in diff if line.lstrip('+').rstrip()]) == 2:
6✔
408
                        self.state.verb = 'changed,no_report'
6✔
409
                        return empty_return
6✔
410
                    diff = [head, diff[0], '/**Comparison type: Additions only**'] + diff[1:]
6✔
411
            elif deletions_only:
6✔
412
                head = '--- @' + diff[1][3:]
6✔
413
                diff = [line for line in diff if line.startswith('-') or line.startswith('@')]
6✔
414
                diff = [
6✔
415
                    line1
416
                    for line1, line2 in zip([''] + diff, diff + [''])
417
                    if not (line1.startswith('@') and line2.startswith('@'))
418
                ][1:]
419
                diff = diff[:-1] if diff[-1].startswith('@') else diff
6✔
420
                if len(diff) == 1 or len([line for line in diff if line.lstrip('-').rstrip()]) == 2:
6✔
421
                    self.state.verb = 'changed,no_report'
6✔
422
                    return empty_return
6✔
423
                diff = [diff[0], head, '/**Comparison type: Deletions only**'] + diff[1:]
6✔
424

425
            # remove range info lines if needed
426
            if directives.get('range_info') is False or (
6✔
427
                directives.get('range_info') is None and additions_only and (len(diff) < 4 or diff[3][0] != '/')
428
            ):
429
                diff = [line for line in diff if not line.startswith('@@ ')]
6✔
430

431
            diff_text = '\n'.join(diff)
6✔
432

433
            out_diff.update(
6✔
434
                {
435
                    'text': diff_text,
436
                    'markdown': diff_text,
437
                }
438
            )
439

440
        if report_kind == 'html':
6✔
441
            out_diff['html'] = '\n'.join(self.unified_diff_to_html(diff_text))
6✔
442

443
        return out_diff
6✔
444

445

446
class TableDiffer(DifferBase):
6✔
447
    """Generates a Python HTML table diff."""
448

449
    __kind__ = 'table'
6✔
450

451
    __supported_directives__ = {
6✔
452
        'tabsize': 'tab stop spacing (default: 8)',
453
    }
454

455
    def differ(
6✔
456
        self,
457
        directives: dict[str, Any],
458
        report_kind: Literal['text', 'markdown', 'html'],
459
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
460
        tz: ZoneInfo | None = None,
461
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
462
        out_diff: dict[Literal['text', 'markdown', 'html'], str] = {}
6✔
463
        if report_kind in {'text', 'markdown'} and _unfiltered_diff is not None and 'html' in _unfiltered_diff:
6✔
464
            table = _unfiltered_diff['html']
6✔
465
        else:
466
            tabsize = int(directives.get('tabsize', 8))
6✔
467
            html_diff = difflib.HtmlDiff(tabsize=tabsize)
6✔
468
            table = html_diff.make_table(
6✔
469
                str(self.state.old_data).splitlines(keepends=True),
470
                str(self.state.new_data).splitlines(keepends=True),
471
                self.make_timestamp(self.state.old_timestamp, tz),
472
                self.make_timestamp(self.state.new_timestamp, tz),
473
                True,
474
                3,
475
            )
476
            # fix table formatting
477
            table = table.replace('<th ', '<th style="font-family:monospace" ')
6✔
478
            table = table.replace('<td ', '<td style="font-family:monospace" ')
6✔
479
            table = table.replace(' nowrap="nowrap"', '')
6✔
480
            table = table.replace('<a ', '<a style="font-family:monospace;color:inherit" ')
6✔
481
            table = table.replace('<span class="diff_add"', '<span style="color:green;background-color:lightgreen"')
6✔
482
            table = table.replace('<span class="diff_sub"', '<span style="color:red;background-color:lightred"')
6✔
483
            table = table.replace('<span class="diff_chg"', '<span style="color:orange;background-color:lightyellow"')
6✔
484
            out_diff['html'] = table
6✔
485

486
        if report_kind in {'text', 'markdown'}:
6✔
487
            diff_text = self.html2text(table)
6✔
488
            out_diff.update(
6✔
489
                {
490
                    'text': diff_text,
491
                    'markdown': diff_text,
492
                }
493
            )
494

495
        return out_diff
6✔
496

497

498
class CommandDiffer(DifferBase):
6✔
499
    """Runs an external command to generate the diff."""
500

501
    __kind__ = 'command'
6✔
502

503
    __supported_directives__ = {
6✔
504
        'command': 'The command to execute',
505
    }
506

507
    re_ptags = re.compile(r'^<p>|</p>$')
6✔
508
    re_htags = re.compile(r'<(/?)h\d>')
6✔
509
    re_tagend = re.compile(r'<(?!.*<).*>+$')
6✔
510

511
    def differ(
6✔
512
        self,
513
        directives: dict[str, Any],
514
        report_kind: Literal['text', 'markdown', 'html'],
515
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
516
        tz: ZoneInfo | None = None,
517
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
518
        out_diff: dict[Literal['text', 'markdown', 'html'], str] = {}
6✔
519
        command = directives['command']
6✔
520
        if (
6✔
521
            report_kind == 'html'
522
            and not command.startswith('wdiff')
523
            and _unfiltered_diff is not None
524
            and 'text' in _unfiltered_diff
525
        ):
526
            diff = _unfiltered_diff['text']
6✔
527
        else:
528
            old_data = self.state.old_data
6✔
529
            new_data = self.state.new_data
6✔
530
            if self.state.is_markdown():
6✔
531
                # protect the link anchor from being split (won't work)
532
                markdown_links_re = re.compile(r'\[(.*?)][(](.*?)[)]')
6✔
533
                old_data = markdown_links_re.sub(
6!
534
                    lambda x: f'[{urllib.parse.quote(x.group(1))}]({x.group(2)})', str(old_data)
535
                )
536
                new_data = markdown_links_re.sub(
6!
537
                    lambda x: f'[{urllib.parse.quote(x.group(1))}]({x.group(2)})', str(new_data)
538
                )
539

540
            # External diff tool
541
            with tempfile.TemporaryDirectory() as tmp_dir:
6✔
542
                tmp_path = Path(tmp_dir)
6✔
543
                old_file_path = tmp_path.joinpath('old_file')
6✔
544
                new_file_path = tmp_path.joinpath('new_file')
6✔
545
                if isinstance(old_data, str):
6!
546
                    old_file_path.write_text(old_data)
6✔
547
                else:
548
                    old_file_path.write_bytes(old_data)
×
549
                if isinstance(new_data, str):
6!
550
                    new_file_path.write_text(new_data)
6✔
551
                else:
552
                    new_file_path.write_bytes(new_data)
×
553
                cmdline = shlex.split(command) + [str(old_file_path), str(new_file_path)]
6✔
554
                proc = subprocess.run(cmdline, capture_output=True, text=True)  # noqa: S603 subprocess call
6✔
555
            if proc.stderr or proc.returncode > 1:
6✔
556
                raise RuntimeError(
6✔
557
                    f"Job {self.job.index_number}: External differ '{directives}' returned '{proc.stderr.strip()}' "
558
                    f'({self.job.get_location()})'
559
                ) from subprocess.CalledProcessError(proc.returncode, cmdline)
560
            if proc.returncode == 0:
6✔
561
                self.state.verb = 'changed,no_report'
6✔
562
                return {'text': '', 'markdown': '', 'html': ''}
6✔
563
            head = '\n'.join(
6✔
564
                [
565
                    f'Using differ "{directives}"',
566
                    f'--- @ {self.make_timestamp(self.state.old_timestamp, tz)}',
567
                    f'+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}',
568
                ]
569
            )
570
            diff = proc.stdout
6✔
571
            if self.state.is_markdown():
6!
572
                # undo the protection of the link anchor from being split
573
                diff = markdown_links_re.sub(lambda x: f'[{urllib.parse.unquote(x.group(1))}]({x.group(2)})', diff)
6!
574
            if command.startswith('wdiff') and self.job.contextlines == 0:
6!
575
                # remove lines that don't have any changes
576
                keeplines = []
×
577
                for line in diff.splitlines(keepends=True):
×
578
                    if any(x in line for x in {'{+', '+}', '[-', '-]'}):
×
579
                        keeplines.append(line)
×
580
                diff = ''.join(keeplines)
×
581
            diff = f'{head}\n{diff}'
6✔
582
            out_diff.update(
6✔
583
                {
584
                    'text': diff,
585
                    'markdown': diff,
586
                }
587
            )
588

589
        if report_kind == 'html':
6✔
590
            if command.startswith('wdiff'):
6!
591
                # colorize output of wdiff
592
                out_diff['html'] = self.wdiff_to_html(diff)
×
593
            else:
594
                out_diff['html'] = html.escape(diff)
6✔
595

596
        return out_diff
6✔
597

598
    def wdiff_to_html(self, diff: str) -> str:
6✔
599
        """
600
        Colorize output of wdiff.
601

602
        :param diff: The output of the wdiff command.
603
        :returns: The colorized HTML output.
604
        """
605
        html_diff = html.escape(diff)
6✔
606
        if self.state.is_markdown():
6✔
607
            # detect and fix multiline additions or deletions
608
            is_add = False
6✔
609
            is_del = False
6✔
610
            new_diff = []
6✔
611
            for line in html_diff.splitlines():
6✔
612
                if is_add:
6✔
613
                    line = '{+' + line
6✔
614
                    is_add = False
6✔
615
                elif is_del:
6✔
616
                    line = '[-' + line
6✔
617
                    is_del = False
6✔
618
                for match in re.findall(r'\[-|-]|{\+|\+}', line):
6✔
619
                    if match == '[-':
6✔
620
                        is_del = True
6✔
621
                    if match == '-]':
6✔
622
                        is_del = False
6✔
623
                    if match == '{+':
6✔
624
                        is_add = True
6✔
625
                    if match == '+}':
6✔
626
                        is_add = False
6✔
627
                if is_add:
6✔
628
                    line += '+}'
6✔
629
                elif is_del:
6✔
630
                    line += '-]'
6✔
631
                new_diff.append(line)
6✔
632
            html_diff = '<br>\n'.join(new_diff)
6✔
633

634
        # wdiff colorization (cannot be done with global CSS class as Gmail overrides it)
635
        html_diff = re.sub(
6✔
636
            r'\{\+(.*?)\+}',
637
            lambda x: f'<span style="{self.css_added_style}">{x.group(1)}</span>',
638
            html_diff,
639
            flags=re.DOTALL,
640
        )
641
        html_diff = re.sub(
6✔
642
            r'\[-(.*?)-]',
643
            lambda x: f'<span style="{self.css_deltd_style}">{x.group(1)}</span>',
644
            html_diff,
645
            flags=re.DOTALL,
646
        )
647
        if self.job.monospace:
6✔
648
            return f'<span style="font-family:monospace;white-space:pre-wrap">{html_diff}</span>'
6✔
649
        else:
650
            return html_diff
6✔
651

652

653
class DeepdiffDiffer(DifferBase):
6✔
654

655
    __kind__ = 'deepdiff'
6✔
656

657
    __supported_directives__ = {
6✔
658
        'data_type': "either 'json' (default) or 'xml'",
659
        'ignore_order': 'Whether to ignore the order in which the items have appeared (default: false)',
660
        'ignore_string_case': 'Whether to be case-sensitive or not when comparing strings (default: false)',
661
        'significant_digits': (
662
            'The number of digits AFTER the decimal point to be used in the comparison (default: ' 'no limit)'
663
        ),
664
    }
665

666
    def differ(
6✔
667
        self,
668
        directives: dict[str, Any],
669
        report_kind: Literal['text', 'markdown', 'html'],
670
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
671
        tz: ZoneInfo | None = None,
672
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
673
        if isinstance(DeepDiff, str):  # pragma: no cover
674
            self.raise_import_error('deepdiff', DeepDiff)
675

676
        span_added = f'<span style="{self.css_added_style}">'
6✔
677
        span_deltd = f'<span style="{self.css_deltd_style}">'
6✔
678

679
        def _pretty_deepdiff(ddiff: DeepDiff, report_kind: Literal['text', 'markdown', 'html']) -> str:
6✔
680
            """
681
            Customized version of deepdiff.serialization.SerializationMixin.pretty method, edited to include the
682
            values deleted or added and an option for colorized HTML output. The pretty human-readable string
683
            output for the diff object regardless of what view was used to generate the diff.
684
            """
685
            if report_kind == 'html':
6✔
686
                PRETTY_FORM_TEXTS = {
6✔
687
                    'type_changes': (
688
                        'Type of {diff_path} changed from {type_t1} to {type_t2} and value changed '
689
                        f'from {span_deltd}{{val_t1}}</span> to {span_added}{{val_t2}}</span>.'
690
                    ),
691
                    'values_changed': (
692
                        f'Value of {{diff_path}} changed from {span_deltd}{{val_t1}}</span> to {span_added}{{val_t2}}'
693
                        '</span>.'
694
                    ),
695
                    'dictionary_item_added': (
696
                        f'Item {{diff_path}} added to dictionary as {span_added}{{val_t2}}</span>.'
697
                    ),
698
                    'dictionary_item_removed': (
699
                        f'Item {{diff_path}} removed from dictionary (was {span_deltd}{{val_t1}}</span>).'
700
                    ),
701
                    'iterable_item_added': f'Item {{diff_path}} added to iterable as {span_added}{{val_t2}}</span>.',
702
                    'iterable_item_removed': (
703
                        f'Item {{diff_path}} removed from iterable (was {span_deltd}{{val_t1}}</span>).'
704
                    ),
705
                    'attribute_added': f'Attribute {{diff_path}} added as {span_added}{{val_t2}}</span>.',
706
                    'attribute_removed': f'Attribute {{diff_path}} removed (was {span_deltd}{{val_t1}}</span>).',
707
                    'set_item_added': f'Item root[{{val_t2}}] added to set as {span_added}{{val_t1}}</span>.',
708
                    'set_item_removed': (
709
                        f'Item root[{{val_t1}}] removed from set (was {span_deltd}{{val_t2}}</span>).'
710
                    ),
711
                    'repetition_change': 'Repetition change for item {diff_path} ({val_t2}).',
712
                }
713
            else:
714
                PRETTY_FORM_TEXTS = {
6✔
715
                    'type_changes': (
716
                        'Type of {diff_path} changed from {type_t1} to {type_t2} and value changed '
717
                        'from {val_t1} to {val_t2}.'
718
                    ),
719
                    'values_changed': 'Value of {diff_path} changed from {val_t1} to {val_t2}.',
720
                    'dictionary_item_added': 'Item {diff_path} added to dictionary as {val_t2}.',
721
                    'dictionary_item_removed': 'Item {diff_path} removed from dictionary (was {val_t1}).',
722
                    'iterable_item_added': 'Item {diff_path} added to iterable as {val_t2}.',
723
                    'iterable_item_removed': 'Item {diff_path} removed from iterable (was {val_t1}).',
724
                    'attribute_added': 'Attribute {diff_path} added as {val_t2}.',
725
                    'attribute_removed': 'Attribute {diff_path} removed (was {val_t1}).',
726
                    'set_item_added': 'Item root[{val_t2}] added to set as {val_t1}.',
727
                    'set_item_removed': 'Item root[{val_t1}] removed from set (was {val_t2}).',
728
                    'repetition_change': 'Repetition change for item {diff_path} ({val_t2}).',
729
                }
730

731
            def _pretty_print_diff(ddiff: DiffLevel) -> str:
6✔
732
                """
733
                Customized version of deepdiff.serialization.pretty_print_diff() function, edited to include the
734
                values deleted or added.
735
                """
736
                type_t1 = type(ddiff.t1).__name__
6✔
737
                type_t2 = type(ddiff.t2).__name__
6✔
738

739
                val_t1 = (
6✔
740
                    f'"{ddiff.t1}"'
741
                    if type_t1 in {'str', 'int', 'float'}
742
                    else (
743
                        jsonlib.dumps(ddiff.t1, ensure_ascii=False, indent=2)
744
                        if type_t1 in {'dict', 'list'}
745
                        else str(ddiff.t1)
746
                    )
747
                )
748
                val_t2 = (
6✔
749
                    f'"{ddiff.t2}"'
750
                    if type_t2 in {'str', 'int', 'float'}
751
                    else (
752
                        jsonlib.dumps(ddiff.t2, ensure_ascii=False, indent=2)
753
                        if type_t2 in {'dict', 'list'}
754
                        else str(ddiff.t2)
755
                    )
756
                )
757

758
                diff_path = ddiff.path()
6✔
759
                return '• ' + PRETTY_FORM_TEXTS.get(ddiff.report_type, '').format(
6✔
760
                    diff_path=diff_path,
761
                    type_t1=type_t1,
762
                    type_t2=type_t2,
763
                    val_t1=val_t1,
764
                    val_t2=val_t2,
765
                )
766

767
            result = []
6✔
768
            for key in ddiff.tree.keys():
6✔
769
                for item_key in ddiff.tree[key]:
6✔
770
                    result.append(_pretty_print_diff(item_key))
6✔
771

772
            return '\n'.join(result)
6✔
773

774
        data_type = directives.get('data_type', 'json')
6✔
775
        old_data = ''
6✔
776
        new_data = ''
6✔
777
        if data_type == 'json':
6✔
778
            try:
6✔
779
                old_data = jsonlib.loads(self.state.old_data)
6✔
780
            except jsonlib.JSONDecodeError:
6✔
781
                old_data = ''
6✔
782
            try:
6✔
783
                new_data = jsonlib.loads(self.state.new_data)
6✔
784
            except jsonlib.JSONDecodeError as e:
6✔
785
                self.state.exception = e
6✔
786
                self.state.traceback = self.job.format_error(e, traceback.format_exc())
6✔
787
                logger.error(f'Job {self.job.index_number}: New data is invalid JSON: {e} ({self.job.get_location()})')
6✔
788
                logger.info(f'Job {self.job.index_number}: {self.state.new_data!r}')
6✔
789
                return {
6✔
790
                    'text': f'Differ {self.__kind__} ERROR: New data is invalid JSON\n{e}',
791
                    'markdown': f'Differ {self.__kind__} **ERROR: New data is invalid JSON**\n{e}',
792
                    'html': f'Differ {self.__kind__} <b>ERROR: New data is invalid JSON</b>\n{e}',
793
                }
794
        elif data_type == 'xml':
6✔
795
            if isinstance(xmltodict, str):  # pragma: no cover
796
                self.raise_import_error('xmltodict', xmltodict)
797

798
            old_data = xmltodict.parse(self.state.old_data)
6✔
799
            new_data = xmltodict.parse(self.state.new_data)
6✔
800

801
        ignore_order = directives.get('ignore_order')
6✔
802
        ignore_string_case = directives.get('ignore_string_case')
6✔
803
        significant_digits = directives.get('significant_digits')
6✔
804
        ddiff = DeepDiff(
6✔
805
            old_data,
806
            new_data,
807
            cache_size=500,
808
            cache_purge_level=0,
809
            cache_tuning_sample_size=500,
810
            ignore_order=ignore_order,
811
            ignore_string_type_changes=True,
812
            ignore_numeric_type_changes=True,
813
            ignore_string_case=ignore_string_case,
814
            significant_digits=significant_digits,
815
            verbose_level=min(2, max(0, math.ceil(3 - logger.getEffectiveLevel() / 10))),
816
        )
817
        diff_text = _pretty_deepdiff(ddiff, report_kind)
6✔
818
        if not diff_text:
6✔
819
            self.state.verb = 'changed,no_report'
6✔
820
            return {'text': '', 'markdown': '', 'html': ''}
6✔
821

822
        self.job.set_to_monospace()
6✔
823
        if report_kind == 'html':
6✔
824
            html_diff = (
6✔
825
                f'<span style="font-family:monospace;white-space:pre-wrap;">'
826
                # f'Differ: {self.__kind__} for {data_type}\n'
827
                f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>\n'
828
                f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>\n'
829
                + diff_text[:-1].replace('][', ']<wbr>[')
830
                + '</span>'
831
            )
832
            return {'html': html_diff}
6✔
833
        else:
834
            text_diff = (
6✔
835
                # f'Differ: {self.__kind__} for {data_type}\n'
836
                f'--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\n'
837
                f'+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\n'
838
                f'{diff_text}'
839
            )
840
            return {'text': text_diff, 'markdown': text_diff}
6✔
841

842

843
class ImageDiffer(DifferBase):
6✔
844
    """Compares two images providing an image outlining areas that have changed."""
845

846
    __kind__ = 'image'
6✔
847

848
    __supported_directives__ = {
6✔
849
        'data_type': (
850
            "'url' (to retrieve an image), 'ascii85' (Ascii85 data), 'base64' (Base64 data) or 'filename' (the path "
851
            "to an image file) (default: 'url')"
852
        ),
853
        'mse_threshold': (
854
            'the minimum mean squared error (MSE) between two images to consider them changed if numpy in installed '
855
            '(default: 2.5)'
856
        ),
857
    }
858

859
    def differ(
6✔
860
        self,
861
        directives: dict[str, Any],
862
        report_kind: Literal['text', 'markdown', 'html'],
863
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
864
        tz: ZoneInfo | None = None,
865
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
866
        warnings.warn(
2✔
867
            f'Job {self.job.index_number}: Using differ {self.__kind__}, which is BETA, may have bugs, and may '
868
            f'change in the future. Please report any problems or suggestions at '
869
            f'https://github.com/mborsetti/webchanges/discussions.',
870
            RuntimeWarning,
871
        )
872
        if isinstance(Image, str):  # pragma: no cover
873
            self.raise_import_error('pillow', Image)
874
        if isinstance(httpx, str):  # pragma: no cover
875
            self.raise_import_error('httpx', httpx)
876

877
        def load_image_from_web(url: str) -> Image.Image:
2✔
878
            """Fetches the image from an url."""
879
            logging.debug(f'Retrieving image from {url}')
2✔
880
            with httpx.stream('GET', url, timeout=10) as response:
2✔
881
                response.raise_for_status()
2✔
882
                return Image.open(BytesIO(b''.join(response.iter_bytes())))
2✔
883

884
        def load_image_from_file(filename: str) -> Image.Image:
2✔
885
            """Load an image from a file."""
886
            logging.debug(f'Reading image from {filename}')
2✔
887
            return Image.open(filename)
2✔
888

889
        def load_image_from_base64(base_64: str) -> Image.Image:
2✔
890
            """Load an image from an encoded bytes object."""
891
            logging.debug('Retrieving image from a base64 string')
2✔
892
            return Image.open(BytesIO(base64.b64decode(base_64)))
2✔
893

894
        def load_image_from_ascii85(ascii85: str) -> Image.Image:
2✔
895
            """Load an image from an encoded bytes object."""
896
            logging.debug('Retrieving image from an ascii85 string')
2✔
897
            return Image.open(BytesIO(base64.a85decode(ascii85)))
2✔
898

899
        def compute_diff_image(img1: Image.Image, img2: Image.Image) -> tuple[Image.Image, np.float64]:
2✔
900
            """Compute the difference between two images."""
901
            # Compute the absolute value of the pixel-by-pixel difference between the two images.
902
            diff_image = ImageChops.difference(img1, img2)
2✔
903

904
            # Compute the mean squared error between the images
905
            if not isinstance(np, str):
2✔
906
                diff_array = np.array(diff_image)
2✔
907
                mse_value = np.mean(np.square(diff_array))
2✔
908
            else:  # pragma: no cover
909
                mse_value = None
910

911
            # Create the diff image by overlaying this difference on a darkened greyscale background
912
            back_image = img1.convert('L')
2✔
913
            back_image_brightness = ImageStat.Stat(back_image).rms[0]
2✔
914
            back_image = ImageEnhance.Brightness(back_image).enhance(back_image_brightness / 225)
2✔
915

916
            # Convert the 'L' image to 'RGB' using a matrix that applies to yellow tint
917
            # The matrix has 12 elements: 4 for Red, 4 for Green, and 4 for Blue.
918
            # For yellow, we want Red and Green to copy the L values (1.0) and Blue to be zero.
919
            # The matrix is: [R, G, B, A] for each of the three output channels
920
            yellow_tint_matrix = (
2✔
921
                1.0,
922
                0.0,
923
                0.0,
924
                0.0,  # Red = 100% of the grayscale value
925
                1.0,
926
                0.0,
927
                0.0,
928
                0.0,  # Green = 100% of the grayscale value
929
                0.0,
930
                0.0,
931
                0.0,
932
                0.0,  # Blue = 0% of the grayscale value
933
            )
934

935
            # Apply the conversion
936
            diff_colored = diff_image.convert('RGB').convert('RGB', matrix=yellow_tint_matrix)
2✔
937

938
            final_img = ImageChops.add(back_image.convert('RGB'), diff_colored)
2✔
939

940
            return final_img, mse_value
2✔
941

942
        data_type = directives.get('data_type', 'url')
2✔
943
        mse_threshold = directives.get('mse_threshold', 2.5)
2✔
944
        if not isinstance(self.state.old_data, str):
2!
945
            raise ValueError('old_data is not a string')
×
946
        if not isinstance(self.state.new_data, str):
2!
947
            raise ValueError('new_data is not a string')
×
948
        if data_type == 'url':
2✔
949
            old_image = load_image_from_web(self.state.old_data)
2✔
950
            new_image = load_image_from_web(self.state.new_data)
2✔
951
            old_data = f' (<a href="{self.state.old_data}">Old image</a>)'
2✔
952
            new_data = f' (<a href="{self.state.new_data}">New image</a>)'
2✔
953
        elif data_type == 'ascii85':
2✔
954
            old_image = load_image_from_ascii85(self.state.old_data)
2✔
955
            new_image = load_image_from_ascii85(self.state.new_data)
2✔
956
            old_data = ''
2✔
957
            new_data = ''
2✔
958
        elif data_type == 'base64':
2✔
959
            old_image = load_image_from_base64(self.state.old_data)
2✔
960
            new_image = load_image_from_base64(self.state.new_data)
2✔
961
            old_data = ''
2✔
962
            new_data = ''
2✔
963
        else:  # 'filename'
964
            old_image = load_image_from_file(self.state.old_data)
2✔
965
            new_image = load_image_from_file(self.state.new_data)
2✔
966
            old_data = f' (<a href="file://{self.state.old_data}">Old image</a>)'
2✔
967
            new_data = f' (<a href="file://{self.state.new_data}">New image</a>)'
2✔
968

969
        # Check formats  TODO: is it needed? under which circumstances?
970
        # if new_image.format != old_image.format:
971
        #     logger.info(f'Image formats do not match: {old_image.format} vs {new_image.format}')
972
        # else:
973
        #     logger.debug(f'image format is {old_image.format}')
974

975
        # If needed, shrink the larger image
976
        if new_image.size != old_image.size:
2✔
977
            if new_image.size > old_image.size:
2✔
978
                logging.debug(f'Job {self.job.index_number}: Shrinking the new image')
2✔
979
                img_format = new_image.format
2✔
980
                new_image = new_image.resize(old_image.size, Image.Resampling.LANCZOS)
2✔
981
                new_image.format = img_format
2✔
982

983
            else:
984
                logging.debug(f'Job {self.job.index_number}: Shrinking the old image')
2✔
985
                img_format = old_image.format
2✔
986
                old_image = old_image.resize(new_image.size, Image.Resampling.LANCZOS)
2✔
987
                old_image.format = img_format
2✔
988

989
        if old_image == new_image:
2✔
990
            logger.info(f'Job {self.job.index_number}: New image is identical to the old one')
2✔
991
            self.state.verb = 'unchanged'
2✔
992
            return {'text': '', 'markdown': '', 'html': ''}
2✔
993

994
        diff_image, mse_value = compute_diff_image(old_image, new_image)
2✔
995
        if mse_value:
2!
996
            logger.debug(f'Job {self.job.index_number}: MSE value {mse_value:.2f}')
2✔
997

998
        if mse_value and mse_value < mse_threshold:
2✔
999
            logger.info(
2✔
1000
                f'Job {self.job.index_number}: MSE value {mse_value:.2f} below the threshold of {mse_threshold}; '
1001
                f'considering changes not worthy of a report'
1002
            )
1003
            self.state.verb = 'changed,no_report'
2✔
1004
            return {'text': '', 'markdown': '', 'html': ''}
2✔
1005

1006
        # Convert the difference image to a base64 object
1007
        output_stream = BytesIO()
2✔
1008
        diff_image.save(output_stream, format=new_image.format)
2✔
1009
        encoded_diff = b64encode(output_stream.getvalue()).decode()
2✔
1010

1011
        # Convert the new image to a base64 object
1012
        output_stream = BytesIO()
2✔
1013
        new_image.save(output_stream, format=new_image.format)
2✔
1014
        encoded_new = b64encode(output_stream.getvalue()).decode()
2✔
1015

1016
        # Prepare HTML output
1017
        htm = [
2✔
1018
            f'<span style="font-family:monospace">'
1019
            # f'Differ: {self.__kind__} for {data_type}',
1020
            f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}{old_data}</span>',
1021
            f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}{new_data}'
1022
            '</span>',
1023
            '</span>',
1024
            'New image:',
1025
        ]
1026
        if data_type == 'url':
2✔
1027
            htm.append(f'<img src="{self.state.old_data}" style="max-width: 100%; display: block;">')
2✔
1028
        else:
1029
            htm.append(
2✔
1030
                f'<img src="data:image/{(new_image.format or "").lower()};base64,{encoded_new}" '
1031
                'style="max-width: 100%; display: block;">'
1032
            )
1033
        htm.extend(
2✔
1034
            [
1035
                'Differences from old (in yellow):',
1036
                f'<img src="data:image/{(old_image.format or "").lower()};base64,{encoded_diff}" '
1037
                'style="max-width: 100%; display: block;">',
1038
            ]
1039
        )
1040

1041
        return {
2✔
1042
            'text': 'The image has changed; please see an HTML report for the visualization.',
1043
            'markdown': 'The image has changed; please see an HTML report for the visualization.',
1044
            'html': '<br>\n'.join(htm),
1045
        }
1046

1047

1048
class AIGoogleDiffer(DifferBase):
6✔
1049
    """(Default) Generates a summary using Google Generative AI (Gemini models).
1050

1051
    Calls Google Gemini APIs; documentation at https://ai.google.dev/api/rest and tutorial at
1052
    https://ai.google.dev/tutorials/rest_quickstart
1053

1054
    """
1055

1056
    __kind__ = 'ai_google'
6✔
1057

1058
    __supported_directives__ = {
6✔
1059
        'model': (
1060
            'model name from https://ai.google.dev/gemini-api/docs/models/gemini (default: gemini-1.5-flash-latest)'
1061
        ),
1062
        'task': 'summarize_new to summarize anything added (including as a result of a change)',
1063
        'system_instructions': (
1064
            'Optional tone and style instructions for the model (default: see documentation at'
1065
            'https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff)'
1066
        ),
1067
        'prompt': 'a custom prompt - {unified_diff}, {unified_diff_new}, {old_text} and {new_text} will be replaced',
1068
        'prompt_ud_context_lines': 'the number of context lines for {unified_diff} (default: 9999)',
1069
        'timeout': 'the number of seconds before timing out the API call (default: 300)',
1070
        'max_output_tokens': "the maximum number of tokens returned by the model (default: None, i.e. model's default)",
1071
        'temperature': "the model's Temperature parameter (default: 0.0)",
1072
        'top_p': "the model's TopP parameter (default: None, i.e. model's default",
1073
        'top_k': "the model's TopK parameter (default: None, i.e. model's default",
1074
        'token_limit': (
1075
            "the maximum number of tokens, if different from model's default (default: None, i.e. model's default)"
1076
        ),
1077
    }
1078
    __default_subdirective__ = 'model'
6✔
1079

1080
    def differ(
6✔
1081
        self,
1082
        directives: dict[str, Any],
1083
        report_kind: Literal['text', 'markdown', 'html'],
1084
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
1085
        tz: ZoneInfo | None = None,
1086
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
1087
        logger.info(f'Job {self.job.index_number}: Running the {self.__kind__} differ from hooks.py')
6✔
1088
        warnings.warn(
6✔
1089
            f'Job {self.job.index_number}: Using differ {self.__kind__}, which is BETA, may have bugs, and may '
1090
            f'change in the future. Please report any problems or suggestions at '
1091
            f'https://github.com/mborsetti/webchanges/discussions.',
1092
            RuntimeWarning,
1093
        )
1094

1095
        def get_ai_summary(prompt: str, system_instructions: str) -> str:
6✔
1096
            """Generate AI summary from unified diff, or an error message"""
1097
            GOOGLE_AI_API_KEY = os.environ.get('GOOGLE_AI_API_KEY', '').rstrip()
6✔
1098
            if len(GOOGLE_AI_API_KEY) != 39:
6✔
1099
                logger.error(
6✔
1100
                    f'Job {self.job.index_number}: Environment variable GOOGLE_AI_API_KEY not found or is of the '
1101
                    f'incorrect length {len(GOOGLE_AI_API_KEY)} ({self.job.get_location()})'
1102
                )
1103
                return (
6✔
1104
                    f'## ERROR in summarizing changes using {self.__kind__}:\n'
1105
                    f'Environment variable GOOGLE_AI_API_KEY not found or is of the incorrect length '
1106
                    f'{len(GOOGLE_AI_API_KEY)}.\n'
1107
                )
1108

1109
            _models_token_limits = {  # from https://ai.google.dev/gemini-api/docs/models/gemini
6✔
1110
                'gemini-1.5-pro': 2097152,
1111
                'gemini-1.5-flash': 1048576,
1112
                'gemini-1.0': 30720,
1113
                'gemini-pro': 30720,  # legacy naming
1114
                'gemma-2': 8192,
1115
            }
1116
            if 'model' not in directives:
6!
1117
                directives['model'] = 'gemini-1.5-flash-latest'  # also for footer
×
1118
            model = directives['model']
6✔
1119
            token_limit = directives.get('token_limit')
6✔
1120
            if not token_limit:
6✔
1121
                for _model, _token_limit in _models_token_limits.items():
6!
1122
                    if model.startswith(_model):
6✔
1123
                        token_limit = _token_limit
6✔
1124
                        break
6✔
1125
                if not token_limit:
6!
1126
                    logger.error(
×
1127
                        f"Job {self.job.index_number}: Differ '{self.__kind__}' does not know `model: {model}` "
1128
                        f"(supported models starting with: {', '.join(sorted(list(_models_token_limits.keys())))}) "
1129
                        f'({self.job.get_location()})'
1130
                    )
1131
                    return f'## ERROR in summarizing changes using {self.__kind__}:\n' f'Unknown model {model}.\n'
×
1132

1133
            if '{unified_diff' in prompt:  # matches unified_diff or unified_diff_new
6✔
1134
                default_context_lines = 9999 if '{unified_diff}' in prompt else 0  # none if only unified_diff_new
6✔
1135
                context_lines = directives.get('prompt_ud_context_lines', default_context_lines)
6✔
1136
                unified_diff = '\n'.join(
6✔
1137
                    difflib.unified_diff(
1138
                        str(self.state.old_data).splitlines(),
1139
                        str(self.state.new_data).splitlines(),
1140
                        # '@',
1141
                        # '@',
1142
                        # self.make_timestamp(self.state.old_timestamp, tz),
1143
                        # self.make_timestamp(self.state.new_timestamp, tz),
1144
                        n=context_lines,
1145
                    )
1146
                )
1147
                if not unified_diff:
6!
1148
                    # no changes
1149
                    return ''
×
1150
            else:
1151
                unified_diff = ''
6✔
1152

1153
            if '{unified_diff_new}' in prompt:
6!
1154
                unified_diff_new_lines = []
×
1155
                for line in unified_diff.splitlines():
×
1156
                    if line.startswith('+'):
×
1157
                        unified_diff_new_lines.append(line[1:])
×
1158
                unified_diff_new = '\n'.join(unified_diff_new_lines)
×
1159
            else:
1160
                unified_diff_new = ''
6✔
1161

1162
            def _send_to_model(model_prompt: str, system_instructions: str) -> str:
6✔
1163
                """Creates the summary request to the model"""
1164
                api_version = '1beta'
×
1165
                max_output_tokens = directives.get('max_output_tokens')
×
1166
                temperature = directives.get('temperature', 0.0)
×
1167
                top_p = directives.get('top_p')
×
1168
                top_k = directives.get('top_k')
×
1169
                data = {
×
1170
                    'system_instruction': {'parts': [{'text': system_instructions}]},
1171
                    'contents': [{'parts': [{'text': model_prompt}]}],
1172
                    'generation_config': {
1173
                        'max_output_tokens': max_output_tokens,
1174
                        'temperature': temperature,
1175
                        'top_p': top_p,
1176
                        'top_k': top_k,
1177
                    },
1178
                }
1179
                logger.info(f'Job {self.job.index_number}: Making summary request to Google model {model}')
×
1180
                try:
×
1181
                    timeout = directives.get('timeout', 300)
×
1182
                    r = httpx.Client(http2=True).post(  # noqa: S113 Call to httpx without timeout
×
1183
                        f'https://generativelanguage.googleapis.com/v{api_version}/models/{model}:generateContent?'
1184
                        f'key={GOOGLE_AI_API_KEY}',
1185
                        json=data,
1186
                        headers={'Content-Type': 'application/json'},
1187
                        timeout=timeout,
1188
                    )
1189
                    if r.is_success:
×
1190
                        result = r.json()
×
1191
                        candidate = result['candidates'][0]
×
1192
                        logger.info(
×
1193
                            f"Job {self.job.index_number}: AI generation finished by {candidate['finishReason']}"
1194
                        )
1195
                        if 'content' in candidate:
×
1196
                            summary = candidate['content']['parts'][0]['text'].rstrip()
×
1197
                        else:
1198
                            summary = (
×
1199
                                f'AI summary unavailable: Model did not return any candidate output:\n'
1200
                                f'{json.dumps(result, ensure_ascii=True, indent=2)}'
1201
                            )
1202
                    elif r.status_code == 400:
×
1203
                        summary = (
×
1204
                            f'AI summary unavailable: Received error from {r.url.host}: '
1205
                            f"{r.json().get('error', {}).get('message') or ''}"
1206
                        )
1207
                    else:
1208
                        summary = (
×
1209
                            f'AI summary unavailable: Received error {r.status_code} {r.reason_phrase} from '
1210
                            f'{r.url.host}'
1211
                        )
1212
                        if r.content:
×
1213
                            summary += f": {r.json().get('error', {}).get('message') or ''}"
×
1214

1215
                except httpx.HTTPError as e:
×
1216
                    summary = (
×
1217
                        f'AI summary unavailable: HTTP client error: {e} when requesting data from '
1218
                        f'{e.request.url.host}'
1219
                    )
1220

1221
                return summary
×
1222

1223
            # check if data is different (same data is sent during testing)
1224
            if '{old_text}' in prompt and '{new_text}' in prompt and self.state.old_data == self.state.new_data:
6✔
1225
                return ''
6✔
1226

1227
            model_prompt = prompt.format(
6✔
1228
                unified_diff=unified_diff,
1229
                unified_diff_new=unified_diff_new,
1230
                old_text=self.state.old_data,
1231
                new_text=self.state.new_data,
1232
            )
1233

1234
            if len(model_prompt) / 4 < token_limit:
6!
1235
                summary = _send_to_model(model_prompt, system_instructions)
×
1236
            elif '{unified_diff}' in prompt:
6!
1237
                logger.info(
6✔
1238
                    f'Job {self.job.index_number}: Model prompt with full diff is too long: '
1239
                    f'({len(model_prompt) / 4:,.0f} est. tokens exceeds limit of {token_limit:,.0f} tokens); '
1240
                    f'recomputing with default contextlines'
1241
                )
1242
                unified_diff = '\n'.join(
6✔
1243
                    difflib.unified_diff(
1244
                        str(self.state.old_data).splitlines(),
1245
                        str(self.state.new_data).splitlines(),
1246
                        # '@',
1247
                        # '@',
1248
                        # self.make_timestamp(self.state.old_timestamp, tz),
1249
                        # self.make_timestamp(self.state.new_timestamp, tz),
1250
                    )
1251
                )
1252
                model_prompt = prompt.format(
6✔
1253
                    unified_diff=unified_diff,
1254
                    unified_diff_new=unified_diff_new,
1255
                    old_text=self.state.old_data,
1256
                    new_text=self.state.new_data,
1257
                )
1258
                if len(model_prompt) / 4 < token_limit:
6!
1259
                    summary = _send_to_model(model_prompt, system_instructions)
×
1260
                else:
1261
                    summary = (
6✔
1262
                        f'AI summary unavailable (model prompt with unified diff is too long: '
1263
                        f'{len(model_prompt) / 4:,.0f} est. tokens exceeds maximum of {token_limit:,.0f})'
1264
                    )
1265
            else:
1266
                logger.info(
×
1267
                    f'The model prompt may be too long: {len(model_prompt) / 4:,.0f} est. tokens exceeds '
1268
                    f'limit of {token_limit:,.0f} tokens'
1269
                )
1270
                summary = _send_to_model(model_prompt, system_instructions)
×
1271
            return summary
6✔
1272

1273
        if directives.get('task') == 'summarize_new':
6!
1274
            default_system_instructions = (
×
1275
                'You are a skilled journalist. You will be provided a text. Provide a summary of this text in a clear '
1276
                'and concise manner.\n Format your output in Markdown, using headings, bullet points, and other '
1277
                'Markdown elements when they are helpful in creating a well-structured and easily readable summary.'
1278
            )
1279
            default_prompt = '{unified_diff_new}'
×
1280
        else:
1281
            default_system_instructions = (
6✔
1282
                'You are a skilled journalist. You will be provided with two versions of a text, encased within '
1283
                'specific tags. The old version of the text will be enclosed within <old_version> and </old_version> '
1284
                'tags, and the new version will be enclosed within <new_version> and </new_version> tags.\n'
1285
                'Please:\n'
1286
                # '* Compare the old and new versions of the text to identify areas where the meaning differ
1287
                # 'significantly. Focus specifically on meaningful changes (including additions or removals) rather than
1288
                # 'just surface-level changes in wording or sentence structure.\n'
1289
                # '* Identify and summarize all the differences, and do so in a clear and concise manner, explaining
1290
                # 'how the meaning has shifted or evolved in the new version compared to the old version.\n'
1291
                '* Compare the old and new versions of the text to identify areas where the meaning differs, including '
1292
                'additions or removals.\n'
1293
                '* Provide a summary of all the differences, and do so in a clear and concise manner, explaining how '
1294
                'the meaning has shifted or evolved in the new version compared to the old version.\n'
1295
                '* Ignore any content where the meaning remains essentially the same, even if the wording has been '
1296
                'altered. Your output should focus exclusively on changes in the intended message or interpretation.\n'
1297
                '* Only reference information in the provided texts in your response.\n'
1298
                'You are writing the summary for someone who is already familiar with the content of the text.\n'
1299
                'Format your output in Markdown, using headings, bullet points, and other Markdown elements when they '
1300
                'are helpful in creating a well-structured and easily readable summary.'
1301
            )
1302
            default_prompt = '<old_version>\n{old_text}\n</old_version>\n\n<new_version>\n{new_text}\n</new_version>'
6✔
1303
        system_instructions = directives.get('system_instructions', default_system_instructions)
6✔
1304
        prompt = directives.get('prompt', default_prompt).replace('\\n', '\n')
6✔
1305
        summary = get_ai_summary(prompt, system_instructions)
6✔
1306
        if not summary:
6✔
1307
            self.state.verb = 'changed,no_report'
6✔
1308
            return {'text': '', 'markdown': '', 'html': ''}
6✔
1309
        newline = '\n'  # For Python < 3.12 f-string compatibility
6✔
1310
        back_n = '\\n'  # For Python < 3.12 f-string compatibility
6✔
1311
        directives_text = (
6✔
1312
            ', '.join(f'{key}={str(value).replace(newline, back_n)}' for key, value in directives.items()) or 'None'
1313
        )
1314
        footer = f'Summary generated by Google Generative AI (differ directive(s): {directives_text})'
6✔
1315
        temp_unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] = {}
6✔
1316
        for rep_kind in ['text', 'html']:  # markdown is same as text
6✔
1317
            unified_report = DifferBase.process(
6✔
1318
                'unified',
1319
                directives.get('unified') or {},  # type: ignore[arg-type]
1320
                self.state,
1321
                rep_kind,  # type: ignore[arg-type]
1322
                tz,
1323
                temp_unfiltered_diff,
1324
            )
1325
        return {
6✔
1326
            'text': summary + '\n\n' + unified_report['text'] + '\n------------\n' + footer,
1327
            'markdown': summary + '\n\n' + unified_report['markdown'] + '\n* * *\n' + footer,
1328
            'html': '\n'.join(
1329
                [
1330
                    mark_to_html(summary, extras={'tables'}).replace('<h2>', '<h3>').replace('</h2>', '</h3>'),
1331
                    '<br>',
1332
                    '<br>',
1333
                    unified_report['html'],
1334
                    '-----<br>',
1335
                    f'<i><small>{footer}</small></i>',
1336
                ]
1337
            ),
1338
        }
1339

1340

1341
class WdiffDiffer(DifferBase):
6✔
1342
    __kind__ = 'wdiff'
6✔
1343

1344
    __supported_directives__: dict[str, str] = {
6✔
1345
        'context_lines': 'the number of context lines (default: 3)',
1346
        'range_info': 'include range information lines (default: true)',
1347
    }
1348

1349
    def differ(
6✔
1350
        self,
1351
        directives: dict[str, Any],
1352
        report_kind: Literal['text', 'markdown', 'html'],
1353
        _unfiltered_diff: dict[Literal['text', 'markdown', 'html'], str] | None = None,
1354
        tz: ZoneInfo | None = None,
1355
    ) -> dict[Literal['text', 'markdown', 'html'], str]:
1356
        warnings.warn(
6✔
1357
            f'Job {self.job.index_number}: Differ {self.__kind__} is WORK IN PROGRESS and has KNOWN bugs which '
1358
            "are being worked on. DO NOT USE AS THE RESULTS WON'T BE CORRECT.",
1359
            RuntimeWarning,
1360
        )
1361
        if not isinstance(self.state.old_data, str):
6!
1362
            raise ValueError
×
1363
        if not isinstance(self.state.new_data, str):
6!
1364
            raise ValueError
×
1365

1366
        # Split the texts into words tokenizing newline
1367
        if self.state.is_markdown():
6!
1368
            # Don't split spaces in link text, tokenize space as </s>
1369
            old_data = re.sub(r'\[(.*?)\]', lambda x: '[' + x.group(1).replace(' ', '</s>') + ']', self.state.old_data)
6✔
1370
            words1 = old_data.replace('\n', ' <\\n> ').split(' ')
6✔
1371
            new_data = re.sub(r'\[(.*?)\]', lambda x: '[' + x.group(1).replace(' ', '</s>') + ']', self.state.new_data)
6✔
1372
            words2 = new_data.replace('\n', ' <\\n> ').split(' ')
6✔
1373
        else:
1374
            words1 = self.state.old_data.replace('\n', ' <\\n> ').split(' ')
×
1375
            words2 = self.state.new_data.replace('\n', ' <\\n> ').split(' ')
×
1376

1377
        # Create a Differ object
1378
        import difflib
6✔
1379

1380
        d = difflib.Differ()
6✔
1381

1382
        # Generate a difference list
1383
        diff = list(d.compare(words1, words2))
6✔
1384

1385
        add_html = '<span style="background-color:#d1ffd1;color:#082b08;">'
6✔
1386
        rem_html = '<span style="background-color:#fff0f0;color:#9c1c1c;text-decoration:line-through;">'
6✔
1387

1388
        head_text = (
6✔
1389
            # f'Differ: wdiff\n'
1390
            f'\033[91m--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\033[0m\n'
1391
            f'\033[92m+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\033[0m\n'
1392
        )
1393
        head_html = '<br>\n'.join(
6✔
1394
            [
1395
                '<span style="font-family:monospace;">'
1396
                # 'Differ: wdiff',
1397
                f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>',
1398
                f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>'
1399
                f'</span>',
1400
                '',
1401
            ]
1402
        )
1403
        # Process the diff output to make it more wdiff-like
1404
        result_text = []
6✔
1405
        result_html = []
6✔
1406
        prev_word_text = ''
6✔
1407
        prev_word_html = ''
6✔
1408
        next_text = ''
6✔
1409
        next_html = ''
6✔
1410
        add = False
6✔
1411
        rem = False
6✔
1412

1413
        for word_text in diff + ['  ']:
6✔
1414
            if word_text[0] == '?':  # additional context line
6✔
1415
                continue
6✔
1416
            word_html = word_text
6✔
1417
            pre_text = [next_text] if next_text else []
6✔
1418
            pre_html = [next_html] if next_html else []
6✔
1419
            next_text = ''
6✔
1420
            next_html = ''
6✔
1421

1422
            if word_text[0] == '+' and not add:  # Beginning of additions
6✔
1423
                if rem:
6✔
1424
                    prev_word_html += '</span>'
6✔
1425
                    rem = False
6✔
1426
                if word_text[2:] == '<\\n>':
6!
1427
                    next_text = '\033[92m'
×
1428
                    next_html = add_html
×
1429
                else:
1430
                    pre_text.append('\033[92m')
6✔
1431
                    pre_html.append(add_html)
6✔
1432
                add = True
6✔
1433
            elif word_text[0] == '-' and not rem:  # Beginning of deletions
6✔
1434
                if add:
6✔
1435
                    prev_word_html += '</span>'
6✔
1436
                    add = False
6✔
1437
                if word_text[2:] == '<\\n>':
6!
1438
                    next_text = '\033[91m'
×
1439
                    next_html = rem_html
×
1440
                else:
1441
                    pre_text.append('\033[91m')
6✔
1442
                    pre_html.append(rem_html)
6✔
1443
                rem = True
6✔
1444
            elif word_text[0] == ' ' and (add or rem):  # Unchanged word
6✔
1445
                if prev_word_text == '<\\n>':
6!
1446
                    prev_word_text = '\033[0m<\\n>'
×
1447
                    prev_word_html = '</span><\\n>'
×
1448
                else:
1449
                    prev_word_text += '\033[0m'
6✔
1450
                    prev_word_html += '</span>'
6✔
1451
                add = False
6✔
1452
                rem = False
6✔
1453
            elif word_text[2:] == '<\\n>':  # New line
6✔
1454
                if add:
6!
1455
                    word_text = '  \033[0m<\\n>'
×
1456
                    word_html = '  </span><\\n>'
×
1457
                    add = False
×
1458
                elif rem:
6!
1459
                    word_text = '  \033[0m<\\n>'
×
1460
                    word_html = '  </span><\\n>'
×
1461
                    rem = False
×
1462

1463
            result_text.append(prev_word_text)
6✔
1464
            result_html.append(prev_word_html)
6✔
1465
            pre_text.append(word_text[2:])
6✔
1466
            pre_html.append(word_html[2:])
6✔
1467
            prev_word_text = ''.join(pre_text)
6✔
1468
            prev_word_html = ''.join(pre_html)
6✔
1469
        if add or rem:
6!
1470
            result_text[-1] += '\033[0m'
×
1471
            result_html[-1] += '</span>'
×
1472

1473
        # rebuild the text from words, replacing the newline token
1474
        diff_text = ' '.join(result_text[1:]).replace('<\\n> ', '\n').replace('<\\n>', '\n')
6✔
1475
        diff_html = ' '.join(result_html[1:]).replace('<\\n> ', '\n').replace('<\\n>', '\n')
6✔
1476

1477
        # build contextlines
1478
        contextlines = directives.get('context_lines', self.job.contextlines)
6✔
1479
        # contextlines = 999
1480
        if contextlines is None:
6!
1481
            contextlines = 3
6✔
1482
        range_info = directives.get('range_info', True)
6✔
1483
        if contextlines < len(diff_text.splitlines()):
6!
1484
            lines_with_changes = []
×
1485
            for i, line in enumerate(diff_text.splitlines()):
×
1486
                if '\033[9' in line:
×
1487
                    lines_with_changes.append(i)
×
1488
            if contextlines:
×
1489
                lines_to_keep: set[int] = set()
×
1490
                for i in lines_with_changes:
×
1491
                    lines_to_keep.update(r for r in range(i - contextlines, i + contextlines + 1))
×
1492
            else:
1493
                lines_to_keep = set(lines_with_changes)
×
1494
            new_diff_text = []
×
1495
            new_diff_html = []
×
1496
            last_line = 0
×
1497
            skip = False
×
1498
            i = 0
×
1499
            for i, (line_text, line_html) in enumerate(zip(diff_text.splitlines(), diff_html.splitlines())):
×
1500
                if i in lines_to_keep:
×
1501
                    if range_info and skip:
×
1502
                        new_diff_text.append(f'@@ {last_line + 1}...{i} @@')
×
1503
                        new_diff_html.append(f'@@ {last_line + 1}...{i} @@')
×
1504
                        skip = False
×
1505
                    new_diff_text.append(line_text)
×
1506
                    new_diff_html.append(line_html)
×
1507
                    last_line = i + 1
×
1508
                else:
1509
                    skip = True
×
1510
            if (i + 1) != last_line:
×
1511
                if range_info and skip:
×
1512
                    new_diff_text.append(f'@@ {last_line + 1}...{i + 1} @@')
×
1513
                    new_diff_html.append(f'@@ {last_line + 1}...{i + 1} @@')
×
1514
            diff_text = '\n'.join(new_diff_text)
×
1515
            diff_html = '\n'.join(new_diff_html)
×
1516

1517
        if self.state.is_markdown():
6!
1518
            diff_text = diff_text.replace('</s>', ' ')
6✔
1519
            diff_html = diff_html.replace('</s>', ' ')
6✔
1520
            diff_html = mark_to_html(diff_html, self.job.markdown_padded_tables).replace('<p>', '').replace('</p>', '')
6✔
1521

1522
        if self.job.monospace:
6!
1523
            diff_html = f'<span style="font-family:monospace;white-space:pre-wrap">{diff_html}</span>'
×
1524
        else:
1525
            diff_html = diff_html.replace('\n', '<br>\n')
6✔
1526

1527
        return {
6✔
1528
            'text': head_text + diff_text,
1529
            'markdown': head_text + diff_text,
1530
            'html': head_html + diff_html,
1531
        }
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