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

mborsetti / webchanges / 23037221107

13 Mar 2026 04:53AM UTC coverage: 72.977% (-0.2%) from 73.134%
23037221107

push

github

mborsetti
Version 3.34.1

1400 of 2316 branches covered (60.45%)

Branch coverage included in aggregate %.

3031 of 3954 new or added lines in 38 files covered. (76.66%)

5022 of 6484 relevant lines covered (77.45%)

11.11 hits per line

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

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

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

5
from __future__ import annotations
15✔
6

7
import base64
15✔
8
import difflib
15✔
9
import html
15✔
10
import logging
15✔
11
import math
15✔
12
import os
15✔
13
import re
15✔
14
import shlex
15✔
15
import subprocess
15✔
16
import sys
15✔
17
import tempfile
15✔
18
import traceback
15✔
19
import urllib.parse
15✔
20
import warnings
15✔
21
from base64 import b64encode
15✔
22
from concurrent.futures import ThreadPoolExecutor
15✔
23
from datetime import datetime
15✔
24
from io import BytesIO
15✔
25
from pathlib import Path
15✔
26
from typing import TYPE_CHECKING, Any, Iterator, Literal, TypedDict
15✔
27
from xml.parsers.expat import ExpatError
15✔
28

29
import html2text
15✔
30
import yaml
15✔
31

32
from webchanges.util import TrackSubClasses, linkify, mark_to_html
15✔
33

34
if TYPE_CHECKING:
35
    from zoneinfo import ZoneInfo
36

37
    from webchanges.jobs import JobBase
38

39

40
try:
15✔
41
    from deepdiff import DeepDiff
15✔
42

43
    if TYPE_CHECKING:
44
        from deepdiff.model import DiffLevel
45
except ImportError as e:  # pragma: no cover
46
    DeepDiff = str(e)  # type: ignore[assignment,misc]
47

48

49
try:
15✔
50
    import httpx
15✔
51
except ImportError:  # pragma: no cover
52
    httpx = None  # type: ignore[assignment]
53
if httpx is not None:
15!
54
    try:
15✔
55
        import h2
15✔
56
    except ImportError:  # pragma: no cover
57
        h2 = None  # type: ignore[assignment]
58

59
try:
15✔
60
    import numpy as np
15✔
61
except ImportError as e:  # pragma: no cover
62
    np = str(e)  # type: ignore[assignment]
63

64
try:
15✔
65
    from PIL import Image, ImageChops, ImageEnhance, ImageStat
15✔
66
except ImportError as e:  # pragma: no cover
67
    Image = str(e)  # type: ignore[assignment]
68

69
import json
15✔
70

71
try:
15✔
72
    import xmltodict
15✔
73
except ImportError as e:  # pragma: no cover
74
    xmltodict = str(e)  # type: ignore[assignment]
75

76
# https://stackoverflow.com/questions/39740632
77
if TYPE_CHECKING:
78
    from webchanges.handler import JobState
79
    from webchanges.storage import _ConfigDifferDefaults
80

81

82
logger = logging.getLogger(__name__)
15✔
83

84
AiGoogleDirectives = TypedDict(
15✔
85
    'AiGoogleDirectives',
86
    {
87
        'model': str,
88
        'additions_only': str,
89
        'system_instructions': str,
90
        'prompt': str,
91
        'prompt_ud_context_lines': int,
92
        'timeout': int,
93
        'max_output_tokens': int | None,
94
        'media_resolution': Literal[
95
            'media_resolution_low', 'media_resolution_medium', 'media_resolution_high', 'media_resolution_ultra_high'
96
        ],
97
        'thinking_level': Literal['low', 'medium', 'high'],
98
        'temperature': float | None,
99
        'top_p': float | None,
100
        'top_k': float | None,
101
        'thinking_budget': float | None,
102
        'tools': list[Any],
103
    },
104
    total=False,
105
)
106

107
ReportKind = Literal['plain', 'markdown', 'html']
15✔
108

109

110
class DifferBase(metaclass=TrackSubClasses):
15✔
111
    """The base class for differs.
112

113
    A differ generates a textual diff representation of data changes in the textual format requested, which can be
114
    either plain, markdown, or html.
115

116
    The diff text (before any filtering) is memoized to prevent unneeded resource wastage when running multiple
117
    reporters, or running reporters that require multiple formats, such as HTML smtp email (requires text in both html
118
    and plain formats).
119
    """
120

121
    __subclasses__: dict[str, type[DifferBase]] = {}
15✔
122
    __anonymous_subclasses__: list[type[DifferBase]] = []
15✔
123

124
    __kind__: str = ''
15✔
125

126
    __supported_directives__: dict[str, str] = {}  # this must be present, even if empty
15✔
127

128
    css_added_style = 'background-color:#d1ffd1;color:#082b08;'
15✔
129
    css_deltd_style = 'background-color:#fff0f0;color:#9c1c1c;text-decoration:line-through;'
15✔
130
    css_remvd_style = 'text-decoration:line-through;'
15✔
131

132
    def __init__(self, state: JobState) -> None:
15✔
133
        """:param state: the JobState."""
134
        self.job = state.job
15✔
135
        self.state = state
15✔
136

137
    @classmethod
15✔
138
    def differ_documentation(cls) -> str:
15✔
139
        """Generates simple differ documentation for use in the --features command line argument.
140

141
        :returns: A string to display.
142
        """
143
        result: list[str] = []
15✔
144
        for sc in TrackSubClasses.sorted_by_kind(cls):
15✔
145
            # default_directive = getattr(sc, '__default_directive__', None)
146
            result.extend((f'  * {sc.__kind__} - {sc.__doc__}',))
15✔
147
            if hasattr(sc, '__supported_directives__'):
15!
148
                for key, doc in sc.__supported_directives__.items():
15✔
149
                    result.append(f'      {key} ... {doc}')
15✔
150
        result.append('\n[] ... Parameter can be supplied as unnamed value\n')
15✔
151
        return '\n'.join(result)
15✔
152

153
    @staticmethod
15✔
154
    def debugger_attached() -> bool:
15✔
155
        """Checks if the code is currently running within an external debugger (e.g. IDE).
156

157
        :returns: True if an external debugger is attached, False otherwise.
158
        """
159
        return sys.breakpointhook.__module__ != 'sys'
15✔
160

161
    @classmethod
15✔
162
    def normalize_differ(
15✔
163
        cls,
164
        differ_spec: dict[str, Any] | None,
165
        job_index_number: int | None = None,
166
        differ_defaults: _ConfigDifferDefaults | None = None,
167
    ) -> tuple[str, dict[str, Any]]:
168
        """Checks the differ_spec for its validity and applies default values.
169

170
        :param differ_spec: The differ as entered by the user; use "unified" if empty.
171
        :param job_index_number: The job index number.
172
        :returns: A validated differ_kind, directives tuple.
173
        """
174

175
        def directives_with_defaults(
15✔
176
            differ_spec: str, directives: dict[str, Any], differ_defaults: _ConfigDifferDefaults | None = None
177
        ) -> dict[str, Any]:
178
            """Obtain differ subdirectives that also contains defaults from the configuration.
179

180
            :param differ_spec: The differ as entered by the user; use "unified" if empty.
181
            :param directives: The differ directives as stated in the job.
182
            :param config: The configuration.
183
            :returns: directives inclusive of configuration defaults.
184
            """
185
            if differ_defaults is None:
15✔
186
                logger.info('No configuration object found to look for differ defaults')
15✔
187
                return directives
15✔
188

189
            differ_default = differ_defaults.get(differ_spec, {})
15✔
190
            if isinstance(differ_default, dict):
15!
191
                # merge defaults from configuration (including dicts) into differ directives without overwriting them
192
                for key, value in differ_default.items():
15!
193
                    if key in directives:
×
194
                        if directives[key] is None:  # for speed
×
195
                            directives[key] = value
×
196
                        elif isinstance(value, dict) and isinstance(
×
197
                            directives[key],
198
                            dict,
199
                        ):
200
                            for subkey, subvalue in value.items():
×
201
                                if key in directives and subkey not in directives[key]:
×
202
                                    directives[key][subkey] = subvalue
×
203
                        # elif isinstance(differ_default[key], list) and isinstance(directives[key], list):
204
                        #     directives[key] = list(set(directives[key] + differ_default[key]))
205
                    else:
206
                        directives[key] = value
×
207

208
            return directives
15✔
209

210
        differ_spec = differ_spec or {'name': 'unified'}
15✔
211
        directives = differ_spec.copy()
15✔
212
        differ_kind = directives.pop('name', '')
15✔
213
        if not differ_kind:
15✔
214
            if list(directives.keys()) == ['command']:
15!
215
                differ_kind = 'command'
15✔
216
            else:
217
                raise ValueError(
×
218
                    f"Job {job_index_number}: Differ directive must have a 'name' sub-directive: {differ_spec}."
219
                )
220

221
        differcls: DifferBase | None = cls.__subclasses__.get(differ_kind, None)  # type: ignore[assignment]
15✔
222
        if not differcls:
15✔
223
            raise ValueError(f'Job {job_index_number}: No differ named {differ_kind}.')
15✔
224

225
        directives = directives_with_defaults(differ_kind, directives, differ_defaults)
15✔
226

227
        if hasattr(differcls, '__supported_directives__'):
15!
228
            provided_keys = set(directives.keys())
15✔
229
            allowed_keys = set(differcls.__supported_directives__.keys())
15✔
230
            unknown_keys = provided_keys.difference(allowed_keys)
15✔
231
            if unknown_keys and '<any>' not in allowed_keys:
15✔
232
                raise ValueError(
15✔
233
                    f'Job {job_index_number}: Differ {differ_kind} does not support sub-directive(s) '
234
                    f'{", ".join(unknown_keys)} (supported: {", ".join(sorted(allowed_keys))}).'
235
                )
236

237
        return differ_kind, directives
15✔
238

239
    @classmethod
15✔
240
    def process(
15✔
241
        cls,
242
        differ_kind: str,
243
        directives: dict[str, Any],
244
        job_state: JobState,
245
        report_kind: ReportKind = 'plain',
246
        tz: ZoneInfo | None = None,
247
        _unfiltered_diff: dict[ReportKind, str] | None = None,
248
    ) -> dict[ReportKind, str]:
249
        """Process the differ.
250

251
        :param differ_kind: The name of the differ.
252
        :param directives: The directives.
253
        :param job_state: The JobState.
254
        :param report_kind: The report kind required.
255
        :param tz: The timezone of the report.
256
        :param _unfiltered_diff: Any previous diffs generated by the same filter, who can be used to generate a diff
257
           for a different report_kind.
258
        :returns: The output of the differ or an error message with traceback if it fails.
259
        """
260
        logger.info(f'Job {job_state.job.index_number}: Applying differ {differ_kind}, directives {directives}')
15✔
261
        differcls: type[DifferBase] | None = cls.__subclasses__.get(differ_kind)
15✔
262
        if differcls:
15✔
263
            try:
15✔
264
                return differcls(job_state).differ(directives, report_kind, _unfiltered_diff, tz)
15✔
265
            except Exception as e:
15✔
266
                # Differ failed
267
                if cls.debugger_attached():
15!
268
                    raise
×
269
                logger.info(
15✔
270
                    f'Job {job_state.job.index_number}: Differ {differ_kind} with {directives=} encountered error {e}'
271
                )
272
                # Undo saving of new data since user won't see the diff
273
                job_state.delete_latest()
15✔
274

275
                job_state.exception = e
15✔
276
                job_state.traceback = job_state.job.format_error(e, traceback.format_exc())
15✔
277
                directives_text = (
15✔
278
                    ', '.join(f'{key}={value}' for key, value in directives.items()) if directives else 'None'
279
                )
280
                return {
15✔
281
                    'plain': (
282
                        f'Differ {differ_kind} with directive(s) {directives_text} encountered an '
283
                        f'error:\n\n{job_state.traceback}'
284
                    ),
285
                    'markdown': (
286
                        f'## Differ {differ_kind} with directive(s) {directives_text} '
287
                        f'encountered an error:\n```\n{job_state.traceback}\n```\n'
288
                    ),
289
                    'html': (
290
                        f'<span style="color:red;font-weight:bold">Differ {differ_kind} with directive(s) '
291
                        f'{directives_text} encountered an error:<br>\n<br>\n'
292
                        f'<span style="font-family:monospace;white-space:pre-wrap;">{job_state.traceback}'
293
                        f'</span></span>'
294
                    ),
295
                }
296
        else:
297
            return {}
15✔
298

299
    def differ(
15✔
300
        self,
301
        directives: dict[str, Any],
302
        report_kind: ReportKind,
303
        _unfiltered_diff: dict[ReportKind, str] | None = None,
304
        tz: ZoneInfo | None = None,
305
    ) -> dict[ReportKind, str]:
306
        """Generate a formatted diff representation of data changes.
307

308
        Creates a diff representation in one or more output formats (text, markdown, or HTML).
309
        At minimum, this function must return output in the format specified by 'report_kind'.
310
        As results are memoized for performance optimization, it can generate up to all three formats simultaneously.
311

312
        :param state: The JobState.
313

314
        :param directives: The directives.
315
        :param report_kind: The report_kind for which a diff must be generated (at a minimum).
316
        :param _unfiltered_diff: Any previous diffs generated by the same filter, who can be used to generate a diff
317
           for a different report_kind.
318
        :param tz: The timezone of the report.
319
        :returns: An empty dict if there is no change, otherwise a dict with report_kind as key and diff as value
320
           (as a minimum for the report_kind requested).
321
        :raises RuntimeError: If the external diff tool returns an error.
322
        """
323
        raise NotImplementedError
324

325
    @staticmethod
15✔
326
    def make_timestamp(
15✔
327
        timestamp: float,
328
        tz: ZoneInfo | None = None,
329
    ) -> str:
330
        """Format a timestamp as an RFC 5322 compliant datetime string.
331

332
        Converts a numeric timestamp to a formatted datetime string following the RFC 5322 (email) standard. When a
333
        timezone is provided, its full name, if known, is appended.
334

335
        :param timestamp: The timestamp.
336
        :param tz: The IANA timezone of the report.
337
        :returns: A datetime string in RFC 5322 (email) format or 'NEW' if timestamp is 0.
338
        """
339
        if timestamp:
15✔
340
            dt = datetime.fromtimestamp(timestamp).astimezone(tz=tz)
15✔
341
            # add timezone name if known
342
            cfws = f' ({dt.strftime("%Z")})' if dt.strftime('%Z') != dt.strftime('%z')[:3] else ''
15✔
343
            return dt.strftime('%a, %d %b %Y %H:%M:%S %z') + cfws
15✔
344
        return 'NEW'
15✔
345

346
    @staticmethod
15✔
347
    def html2text(data: str) -> str:
15✔
348
        """Converts html to text.
349

350
        :param data: the string in html format.
351
        :returns: the string in text format.
352
        """
353
        parser = html2text.HTML2Text()
15✔
354
        parser.unicode_snob = True
15✔
355
        parser.body_width = 0
15✔
356
        parser.ignore_images = True
15✔
357
        parser.single_line_break = True
15✔
358
        parser.wrap_links = False
15✔
359
        return '\n'.join(line.rstrip() for line in parser.handle(data).splitlines())
15✔
360

361
    def raise_import_error(self, package_name: str, error_message: str) -> None:
15✔
362
        """Raise ImportError for missing package.
363

364
        :param package_name: The name of the module/package that could not be imported.
365
        :param error_message: The error message from ImportError.
366

367
        :raises: ImportError.
368
        """
369
        raise ImportError(
15✔
370
            f"Job {self.job.index_number}: Python package '{package_name}' is not installed; cannot use "
371
            f"'differ: {self.__kind__}' ({self.job.get_location()})\n{error_message}"
372
        )
373

374

375
class UnifiedDiffer(DifferBase):
15✔
376
    """(Default) Generates a unified diff."""
377

378
    __kind__ = 'unified'
15✔
379

380
    __supported_directives__: dict[str, str] = {
15✔
381
        'context_lines': 'the number of context lines (default: 3)',
382
        'range_info': 'include range information lines (default: true)',
383
        'additions_only': 'keep only addition lines (default: false)',
384
        'deletions_only': 'keep only deletion lines (default: false)',
385
    }
386

387
    def unified_diff_to_html(self, diff: str) -> Iterator[str]:
15✔
388
        """Generates a colorized HTML table from unified diff, applying styles and processing based on job values.
389

390
        :param diff: the unified diff
391
        """
392

393
        def process_line(line: str, line_num: int, is_markdown: bool, monospace_style: str) -> str:
15✔
394
            """Processes each line for HTML output, handling special cases and styles.
395

396
            :param line: The line to analyze.
397
            :param line_num: The line number in the document.
398
            :param monospace_style: Additional style string for monospace text.
399

400
            :returns: The line processed into an HTML table row string.
401
            """
402
            # The style= string (or empty string) to add to an HTML tag.
403
            if line_num == 0:
15✔
404
                style = 'font-family:monospace;color:darkred;'
15✔
405
            elif line_num == 1:
15✔
406
                style = 'font-family:monospace;color:darkgreen;'
15✔
407
            elif line[0] == '+':  # addition
15✔
408
                style = f'{monospace_style}{self.css_added_style}'
15✔
409
            elif line[0] == '-':  # deletion
15✔
410
                style = f'{monospace_style}{self.css_deltd_style}'
15✔
411
            elif line[0] == ' ':  # context line
15✔
412
                style = monospace_style
15✔
413
            elif line[0] == '@':  # range information
15✔
414
                style = 'font-family:monospace;background-color:#fbfbfb;'
15✔
415
            elif line[0] == '/':  # informational header added by additions_only or deletions_only filters
15!
416
                style = 'background-color:lightyellow;'
15✔
417
            else:
418
                raise RuntimeError('Unified Diff does not comform to standard!')
×
419
            style = f' style="{style}"' if style else ''
15✔
420

421
            if line_num > 1 and line[0] != '@':  # don't apply to headers or range information
15✔
422
                if is_markdown or line[0] == '/':  # our informational header
15✔
423
                    line = mark_to_html(line[1:], self.job.markdown_padded_tables)
15✔
424
                else:
425
                    line = linkify(line[1:])
15✔
426
            return f'<tr><td{style}>{line}</td></tr>'
15✔
427

428
        table_style = ' style="border-collapse:collapse;"'
15✔
429
        # table_style = (
430
        #     ' style="border-collapse:collapse;font-family:monospace;white-space:pre-wrap;"'
431
        #     if self.job.monospace
432
        #     else ' style="border-collapse:collapse;"'
433
        # )
434
        yield f'<table{table_style}>'
15✔
435
        is_markdown = self.state.is_markdown()
15✔
436
        monospace_style = 'font-family:monospace;' if self.job.monospace else ''
15✔
437
        for i, line in enumerate(diff.splitlines()):
15✔
438
            yield process_line(line, i, is_markdown, monospace_style)
15✔
439
        yield '</table>'
15✔
440

441
    def differ(
15✔
442
        self,
443
        directives: dict[str, Any],
444
        report_kind: ReportKind,
445
        _unfiltered_diff: dict[ReportKind, str] | None = None,
446
        tz: ZoneInfo | None = None,
447
    ) -> dict[ReportKind, str]:
448
        additions_only = directives.get('additions_only') or self.job.additions_only
15✔
449
        deletions_only = directives.get('deletions_only') or self.job.deletions_only
15✔
450
        out_diff: dict[ReportKind, str] = {}
15✔
451
        if report_kind == 'html' and _unfiltered_diff is not None and 'plain' in _unfiltered_diff:
15✔
452
            diff_text = _unfiltered_diff['plain']
15✔
453
        else:
454
            empty_return: dict[ReportKind, str] = {'plain': '', 'markdown': '', 'html': ''}
15✔
455
            contextlines = directives.get('context_lines', self.job.contextlines)
15✔
456
            if contextlines is None:
15✔
457
                contextlines = 0 if additions_only or deletions_only else 3
15✔
458
            diff = list(
15✔
459
                difflib.unified_diff(
460
                    str(self.state.old_data).splitlines(),
461
                    str(self.state.new_data).splitlines(),
462
                    '@',
463
                    '@',
464
                    self.make_timestamp(self.state.old_timestamp, tz),
465
                    self.make_timestamp(self.state.new_timestamp, tz),
466
                    contextlines,
467
                    lineterm='',
468
                )
469
            )
470
            if not diff:
15✔
471
                self.state.verb = 'changed,no_report'
15✔
472
                return empty_return
15✔
473
            # replace tabs in header lines
474
            diff[0] = diff[0].replace('\t', ' ')
15✔
475
            diff[1] = diff[1].replace('\t', ' ')
15✔
476

477
            if additions_only:
15✔
478
                if len(self.state.old_data) and len(self.state.new_data) / len(self.state.old_data) <= 0.25:
15✔
479
                    diff = [
15✔
480
                        *diff[:2],
481
                        '/**Comparison type: Additions only**',
482
                        '/**Deletions are being shown as 75% or more of the content has been deleted**',
483
                        *diff[2:],
484
                    ]
485
                else:
486
                    head = '---' + diff[0][3:]
15✔
487
                    diff = [line for line in diff if line.startswith(('+', '@'))]
15✔
488
                    diff = [
15✔
489
                        line1
490
                        for line1, line2 in zip(['', *diff], [*diff, ''], strict=False)
491
                        if not (line1.startswith('@') and line2.startswith('@'))
492
                    ][1:]
493
                    diff = diff[:-1] if diff[-1].startswith('@') else diff
15✔
494
                    if len(diff) == 1 or len([line for line in diff if line.removeprefix('+').rstrip()]) == 2:
15✔
495
                        self.state.verb = 'changed,no_report'
15✔
496
                        return empty_return
15✔
497
                    diff = [head, diff[0], '/**Comparison type: Additions only**', *diff[1:]]
15✔
498
            elif deletions_only:
15✔
499
                head = '--- @' + diff[1][3:]
15✔
500
                diff = [line for line in diff if line.startswith(('-', '@'))]
15✔
501
                diff = [
15✔
502
                    line1
503
                    for line1, line2 in zip(['', *diff], [*diff, ''], strict=False)
504
                    if not (line1.startswith('@') and line2.startswith('@'))
505
                ][1:]
506
                diff = diff[:-1] if diff[-1].startswith('@') else diff
15✔
507
                if len(diff) == 1 or len([line for line in diff if line.removeprefix('-').rstrip()]) == 2:
15✔
508
                    self.state.verb = 'changed,no_report'
15✔
509
                    return empty_return
15✔
510
                diff = [diff[0], head, '/**Comparison type: Deletions only**', *diff[1:]]
15✔
511

512
            # remove range info lines if needed
513
            if directives.get('range_info') is False or (
15✔
514
                directives.get('range_info') is None and additions_only and (len(diff) < 4 or diff[3][0] != '/')
515
            ):
516
                diff = [line for line in diff if not line.startswith('@@ ')]
15✔
517

518
            diff_text = '\n'.join(diff)
15✔
519

520
            out_diff.update(
15✔
521
                {
522
                    'plain': diff_text,
523
                    'markdown': diff_text,
524
                }
525
            )
526

527
        if report_kind == 'html':
15✔
528
            out_diff['html'] = '\n'.join(self.unified_diff_to_html(diff_text))
15✔
529

530
        return out_diff
15✔
531

532

533
class TableDiffer(DifferBase):
15✔
534
    """Generates a Python HTML table diff."""
535

536
    __kind__ = 'table'
15✔
537

538
    __supported_directives__: dict[str, str] = {
15✔
539
        'tabsize': 'tab stop spacing (default: 8)',
540
    }
541

542
    def differ(
15✔
543
        self,
544
        directives: dict[str, Any],
545
        report_kind: ReportKind,
546
        _unfiltered_diff: dict[ReportKind, str] | None = None,
547
        tz: ZoneInfo | None = None,
548
    ) -> dict[ReportKind, str]:
549
        out_diff: dict[ReportKind, str] = {}
15✔
550
        if report_kind in {'plain', 'markdown'} and _unfiltered_diff is not None and 'html' in _unfiltered_diff:
15✔
551
            table = _unfiltered_diff['html']
15✔
552
        else:
553
            tabsize = int(directives.get('tabsize', 8))
15✔
554
            html_diff = difflib.HtmlDiff(tabsize=tabsize)
15✔
555
            table = html_diff.make_table(
15✔
556
                str(self.state.old_data).splitlines(keepends=True),
557
                str(self.state.new_data).splitlines(keepends=True),
558
                self.make_timestamp(self.state.old_timestamp, tz),
559
                self.make_timestamp(self.state.new_timestamp, tz),
560
                True,
561
                3,
562
            )
563
            # fix table formatting
564
            table = table.replace('<th ', '<th style="font-family:monospace" ')
15✔
565
            table = table.replace('<td ', '<td style="font-family:monospace" ')
15✔
566
            table = table.replace(' nowrap="nowrap"', '')
15✔
567
            table = table.replace('<a ', '<a style="font-family:monospace;color:inherit" ')
15✔
568
            table = table.replace('<span class="diff_add"', '<span style="color:green;background-color:lightgreen"')
15✔
569
            table = table.replace('<span class="diff_sub"', '<span style="color:red;background-color:lightred"')
15✔
570
            table = table.replace('<span class="diff_chg"', '<span style="color:orange;background-color:lightyellow"')
15✔
571
            out_diff['html'] = table
15✔
572

573
        if report_kind in {'plain', 'markdown'}:
15✔
574
            diff_text = self.html2text(table)
15✔
575
            out_diff.update(
15✔
576
                {
577
                    'plain': diff_text,
578
                    'markdown': diff_text,
579
                }
580
            )
581

582
        return out_diff
15✔
583

584

585
class CommandDiffer(DifferBase):
15✔
586
    """Runs an external command to generate the diff."""
587

588
    __kind__ = 'command'
15✔
589

590
    __supported_directives__: dict[str, str] = {
15✔
591
        'context_lines': 'the number of context lines if command starts with wdiff (default: 3)',
592
        'command': 'The command to execute',
593
        'is_html': 'Whether the output of the command is HTML',
594
    }
595

596
    re_ptags = re.compile(r'^<p>|</p>$')
15✔
597
    re_htags = re.compile(r'<(/?)h\d>')
15✔
598
    re_tagend = re.compile(r'<(?!.*<).*>+$')
15✔
599

600
    def differ(
15✔
601
        self,
602
        directives: dict[str, Any],
603
        report_kind: ReportKind,
604
        _unfiltered_diff: dict[ReportKind, str] | None = None,
605
        tz: ZoneInfo | None = None,
606
    ) -> dict[ReportKind, str]:
607
        if self.job.monospace:
15!
608
            head_html = '\n'.join(
×
609
                [
610
                    '<span style="font-family:monospace;white-space:pre-wrap;">',
611
                    # f"Using command differ: {directives['command']}",
612
                    f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>',
613
                    f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>',
614
                ]
615
            )
616
        else:
617
            head_html = '<br>\n'.join(
15✔
618
                [
619
                    '<span style="font-family:monospace;">',
620
                    # f"Using command differ: {directives['command']}",
621
                    f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>',
622
                    f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>',
623
                    '</span>',
624
                ]
625
            )
626

627
        out_diff: dict[ReportKind, str] = {}
15✔
628
        command = directives['command']
15✔
629
        if report_kind == 'html' and _unfiltered_diff is not None and 'plain' in _unfiltered_diff:
15✔
630
            diff_text = ''.join(_unfiltered_diff['plain'].splitlines(keepends=True)[2:])
15✔
631
        else:
632
            old_data = self.state.old_data
15✔
633
            new_data = self.state.new_data
15✔
634
            if self.state.is_markdown():
15✔
635
                # protect the link anchor from being split (won't work)
636
                markdown_links_re = re.compile(r'\[(.*?)][(](.*?)[)]')
15✔
637
                old_data = markdown_links_re.sub(
15✔
638
                    lambda x: f'[{urllib.parse.quote(x.group(1))}]({x.group(2)})', str(old_data)
639
                )
640
                new_data = markdown_links_re.sub(
15✔
641
                    lambda x: f'[{urllib.parse.quote(x.group(1))}]({x.group(2)})', str(new_data)
642
                )
643

644
            # External diff tool
645
            with tempfile.TemporaryDirectory() as tmp_dir:
15✔
646
                tmp_path = Path(tmp_dir)
15✔
647
                old_file_path = tmp_path.joinpath('old_file')
15✔
648
                new_file_path = tmp_path.joinpath('new_file')
15✔
649
                if isinstance(old_data, str):
15!
650
                    old_file_path.write_text(old_data)
15✔
651
                else:
652
                    old_file_path.write_bytes(old_data)
×
653
                if isinstance(new_data, str):
15!
654
                    new_file_path.write_text(new_data)
15✔
655
                else:
656
                    new_file_path.write_bytes(new_data)
×
657
                cmdline = [*shlex.split(command), str(old_file_path), str(new_file_path)]
15✔
658
                proc = subprocess.run(cmdline, check=False, capture_output=True, text=True)  # noqa: S603 subprocess call
15✔
659
            if proc.stderr or proc.returncode > 1:
15✔
660
                raise RuntimeError(
15✔
661
                    f"Job {self.job.index_number}: External differ '{directives}' returned '{proc.stderr.strip()}' "
662
                    f'({self.job.get_location()})'
663
                ) from subprocess.CalledProcessError(proc.returncode, cmdline)
664
            if proc.returncode == 0:
15✔
665
                self.state.verb = 'changed,no_report'
10✔
666
                logger.info(
10✔
667
                    f"Job {self.job.index_number}: Command in differ 'command' returned 0 (no report) "
668
                    f'({self.job.get_location()})'
669
                )
670
                return {'plain': '', 'markdown': '', 'html': ''}
10✔
671
            head_text = '\n'.join(
15✔
672
                [
673
                    # f"Using command differ: {directives['command']}",
674
                    f'\033[91m--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\033[0m',
675
                    f'\033[92m+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\033[0m',
676
                    '',
677
                ]
678
            )
679
            diff = proc.stdout
15✔
680
            if self.state.is_markdown():
15!
681
                # undo the protection of the link anchor from being split
682
                diff = markdown_links_re.sub(lambda x: f'[{urllib.parse.unquote(x.group(1))}]({x.group(2)})', diff)
15✔
683
            if command.startswith('wdiff'):
15!
684
                logger.warning(
×
685
                    "Job {self.job.index_number}: Using external wdiff; note that a 'wdiff' differ is now available "
686
                    'within webchanges'
687
                )
688
                if self.job.contextlines == 0:
×
689
                    # remove lines that don't have any changes
690
                    keeplines = [
×
691
                        line
692
                        for line in diff.splitlines(keepends=True)
693
                        if any(x in line for x in ('{+', '+}', '[-', '-]'))
694
                    ]
695
                    diff = ''.join(keeplines)
×
696

697
            if directives.get('is_html'):
15!
698
                diff_text = self.html2text(diff)
×
699
                out_diff.update(
×
700
                    {
701
                        'plain': head_text + diff_text,
702
                        'markdown': head_text + diff_text,
703
                        'html': head_html + diff,
704
                    }
705
                )
706
            else:
707
                diff_text = diff
15✔
708
                out_diff.update(
15✔
709
                    {
710
                        'plain': head_text + diff_text,
711
                        'markdown': head_text + diff_text,
712
                    }
713
                )
714

715
        if report_kind == 'html' and 'html' not in out_diff:
15✔
716
            if command.startswith('wdiff'):
15!
717
                # colorize output of wdiff
718
                out_diff['html'] = head_html + self.wdiff_to_html(diff_text)
×
719
            else:
720
                out_diff['html'] = head_html + html.escape(diff_text)
15✔
721

722
        if self.job.monospace and 'html' in out_diff:
15!
723
            out_diff['html'] += '</span>'
×
724

725
        return out_diff
15✔
726

727
    def wdiff_to_html(self, diff: str) -> str:
15✔
728
        """Colorize output of wdiff.
729

730
        :param diff: The output of the wdiff command.
731
        :returns: The colorized HTML output.
732
        """
733
        html_diff = html.escape(diff)
15✔
734
        if self.state.is_markdown():
15✔
735
            # detect and fix multiline additions or deletions
736
            is_add = False
15✔
737
            is_del = False
15✔
738
            new_diff = []
15✔
739
            for line in html_diff.splitlines():
15✔
740
                if is_add:
15✔
741
                    line = '{+' + line
15✔
742
                    is_add = False
15✔
743
                elif is_del:
15✔
744
                    line = '[-' + line
15✔
745
                    is_del = False
15✔
746
                for match in re.findall(r'\[-|-]|{\+|\+}', line):
15✔
747
                    if match == '[-':
15✔
748
                        is_del = True
15✔
749
                    if match == '-]':
15✔
750
                        is_del = False
15✔
751
                    if match == '{+':
15✔
752
                        is_add = True
15✔
753
                    if match == '+}':
15✔
754
                        is_add = False
15✔
755
                if is_add:
15✔
756
                    line += '+}'
15✔
757
                elif is_del:
15✔
758
                    line += '-]'
15✔
759
                new_diff.append(line)
15✔
760
            html_diff = '<br>\n'.join(new_diff)
15✔
761

762
        # wdiff colorization (cannot be done with global CSS class as Gmail overrides it)
763
        html_diff = re.sub(
15✔
764
            r'\{\+(.*?)\+}',
765
            lambda x: f'<span style="{self.css_added_style}">{x.group(1)}</span>',
766
            html_diff,
767
            flags=re.DOTALL,
768
        )
769
        html_diff = re.sub(
15✔
770
            r'\[-(.*?)-]',
771
            lambda x: f'<span style="{self.css_deltd_style}">{x.group(1)}</span>',
772
            html_diff,
773
            flags=re.DOTALL,
774
        )
775
        if self.job.monospace:
15✔
776
            return f'<span style="font-family:monospace;white-space:pre-wrap">{html_diff}</span>'
15✔
777
        return html_diff
15✔
778

779

780
class DeepdiffDiffer(DifferBase):
15✔
781
    __kind__ = 'deepdiff'
15✔
782

783
    __supported_directives__: dict[str, str] = {
15✔
784
        'data_type': "either 'json' (default), 'yaml', or 'xml'",
785
        'ignore_order': 'Whether to ignore the order in which the items have appeared (default: false)',
786
        'ignore_string_case': 'Whether to be case-sensitive or not when comparing strings (default: false)',
787
        'significant_digits': (
788
            'The number of digits AFTER the decimal point to be used in the comparis: ston (default: no limit)'
789
        ),
790
        'compact': 'Whether to output a compact representation that also ignores changes of types (default: false)',
791
    }
792

793
    def differ(  # noqa: C901 mccabe complexity too high
15✔
794
        self,
795
        directives: dict[str, Any],
796
        report_kind: ReportKind,
797
        _unfiltered_diff: dict[ReportKind, str] | None = None,
798
        tz: ZoneInfo | None = None,
799
    ) -> dict[ReportKind, str]:
800
        if isinstance(DeepDiff, str):  # pragma: no cover
801
            self.raise_import_error('deepdiff', DeepDiff)
802
            raise RuntimeError  # for type checker
803

804
        span_added = f'<span style="{self.css_added_style}">'
15✔
805
        span_deltd = f'<span style="{self.css_deltd_style}">'
15✔
806
        span_remvd = f'<span style="{self.css_remvd_style}">'
15✔
807

808
        def _pretty_deepdiff(
15✔
809
            ddiff: DeepDiff,
810
            report_kind: ReportKind,
811
            compact: bool,
812
        ) -> str:
813
            """Customized version of deepdiff.serialization.SerializationMixin.pretty method, edited to include the
814
            values deleted or added and an option for colorized HTML output. The pretty human-readable string
815
            output for the diff object regardless of what view was used to generate the diff.
816

817
            :param ddiff: The diff object.
818
            :param report_kind: The report kind.
819
            :param compact: Whether to return diff text in compact mode.
820
            """
821
            # Edited strings originally in deepdiff.serialization._get_pretty_form_text
822
            # See https://github.com/seperman/deepdiff/blob/master/deepdiff/serialization.py
823
            if compact:
15✔
824
                root = '⊤'  # noqa: RUF001 DOWN TACK
15✔
825
                if report_kind == 'html':
15✔
826
                    pretty_form_texts = {
15✔
827
                        'type_changes': (
828
                            f'{{diff_path}}: {span_deltd}{{val_t1}}</span> ⮕ {span_added}{{val_t2}}</span>'
829
                        ),
830
                        'values_changed': (
831
                            f'{{diff_path}}: {span_deltd}{{val_t1}}</span> ⮕ {span_added}{{val_t2}}</span>'
832
                        ),
833
                        'dictionary_item_added': f'{{diff_path}}: {span_added}{{val_t2}}</span>',
834
                        'dictionary_item_removed': f'{span_deltd}{{diff_path}}: {{val_t1}}</span>',
835
                        'iterable_item_added': f'{{diff_path}}: {span_added}{{val_t2}}</span>',
836
                        'iterable_item_removed': f'{span_deltd}{{diff_path}}: {{val_t1}}</span>',
837
                        'attribute_added': f'{{diff_path}}: {span_added}{{val_t2}}</span>',
838
                        'attribute_removed': f'{span_remvd}{{diff_path}}</span>: {span_deltd}{{val_t1}}</span>',
839
                        'set_item_added': f'⊤[{{val_t2}}]: {span_added}{{val_t1}}</span>',  # noqa: RUF001 DOWN TACK
840
                        'set_item_removed': (
841
                            f'{span_remvd}⊤[{{val_t1}}]</span>: {span_deltd}{{val_t2}}</span>'  # noqa: RUF001
842
                        ),
843
                        'repetition_change': (
844
                            f'{{diff_path}}: repetition change {span_deltd}{{val_t1}}</span> ⮕ '
845
                            f'{span_added}{{val_t2}}</span>'
846
                        ),
847
                    }
848
                else:
849
                    pretty_form_texts = {
15✔
850
                        'type_changes': '{diff_path}: {val_t1} → {val_t2}',
851
                        'values_changed': '{diff_path}: {val_t1} → {val_t2}',
852
                        'dictionary_item_added': '{diff_path}: new {val_t2}',
853
                        'dictionary_item_removed': '{diff_path}: removed {val_t1}',
854
                        'iterable_item_added': '{diff_path}: new {val_t2}',
855
                        'iterable_item_removed': '{diff_path}: removed {val_t1}',
856
                        'attribute_added': '{diff_path}: new {val_t2}',
857
                        'attribute_removed': '{diff_path}: removed {val_t1}',
858
                        'set_item_added': '⊤[{val_t2}]: new {val_t1}',  # noqa: RUF001 DOWN TACK
859
                        'set_item_removed': '⊤[{val_t1}]: removed {val_t2}',  # noqa: RUF001 DOWN TACK
860
                        'repetition_change': '{diff_path}: repetition change {val_t1} → {val_t2}',
861
                    }
862
            else:  # not compact
863
                root = 'root'
15✔
864
                if report_kind == 'html':
15✔
865
                    pretty_form_texts = {
15✔
866
                        'type_changes': (
867
                            'Type of {diff_path} changed from {type_t1} to {type_t2} and value changed '
868
                            f'from {span_deltd}{{val_t1}}</span> to {span_added}{{val_t2}}</span>.'
869
                        ),
870
                        'values_changed': (
871
                            f'Value of {{diff_path}} changed from {span_deltd}{{val_t1}}</span> to {span_added}'
872
                            '{val_t2}</span>.'
873
                        ),
874
                        'dictionary_item_added': (
875
                            f'Item {{diff_path}} added to dictionary as {span_added}{{val_t2}}</span>.'
876
                        ),
877
                        'dictionary_item_removed': (
878
                            f'Item {{diff_path}} removed from dictionary (was {span_deltd}{{val_t1}}</span>).'
879
                        ),
880
                        'iterable_item_added': (
881
                            f'Item {{diff_path}} added to iterable as {span_added}{{val_t2}}</span>.'
882
                        ),
883
                        'iterable_item_removed': (
884
                            f'Item {{diff_path}} removed from iterable (was {span_deltd}{{val_t1}}</span>).'
885
                        ),
886
                        'attribute_added': f'Attribute {{diff_path}} added as {span_added}{{val_t2}}</span>.',
887
                        'attribute_removed': f'Attribute {{diff_path}} removed (was {span_deltd}{{val_t1}}</span>).',
888
                        'set_item_added': f'Item root[{{val_t2}}] added to set as {span_added}{{val_t1}}</span>.',
889
                        'set_item_removed': (
890
                            f'Item root[{{val_t1}}] removed from set (was {span_deltd}{{val_t2}}</span>).'
891
                        ),
892
                        'repetition_change': (
893
                            f'Repetition change for item {{diff_path}} ({span_deltd}{{val_t2}}</span>).'
894
                        ),
895
                    }
896
                else:
897
                    pretty_form_texts = {
15✔
898
                        'type_changes': (
899
                            'Type of {diff_path} changed from {type_t1} to {type_t2} and value changed '
900
                            'from {val_t1} to {val_t2}.'
901
                        ),
902
                        'values_changed': 'Value of {diff_path} changed from {val_t1} to {val_t2}.',
903
                        'dictionary_item_added': 'Item {diff_path} added to dictionary as {val_t2}.',
904
                        'dictionary_item_removed': 'Item {diff_path} removed from dictionary (was {val_t1}).',
905
                        'iterable_item_added': 'Item {diff_path} added to iterable as {val_t2}.',
906
                        'iterable_item_removed': 'Item {diff_path} removed from iterable (was {val_t1}).',
907
                        'attribute_added': 'Attribute {diff_path} added as {val_t2}.',
908
                        'attribute_removed': 'Attribute {diff_path} removed (was {val_t1}).',
909
                        'set_item_added': 'Item root[{val_t2}] added to set as {val_t1}.',
910
                        'set_item_removed': 'Item root[{val_t1}] removed from set (was {val_t2}).',
911
                        'repetition_change': 'Repetition change for item {diff_path} ({val_t2}).',
912
                    }
913

914
            def _pretty_print_diff(ddiff: DiffLevel) -> str:
15✔
915
                """Customized version of deepdiff.serialization.pretty_print_diff() function, edited to include the
916
                values deleted or added.
917
                """
918

919
                def stringify_value(value: Any, value_type: str) -> str:  # noqa: ANN401 Dynamically typed expressions Any are disallowed
15✔
920
                    if value_type in {'str', 'int', 'float'}:
15✔
921
                        if compact:
15✔
922
                            return f"'{value}'"
15✔
923
                        return f'"{value}"'
15✔
924
                    if value_type in {'dict', 'list'}:
15!
925
                        if compact:
15✔
926
                            value_string = yaml.safe_dump(
15✔
927
                                value,
928
                                default_flow_style=False,
929
                                width=999,
930
                                allow_unicode=True,
931
                                sort_keys=False,
932
                            )
933
                            value_list = value_string.splitlines(keepends=True)
15✔
934
                            if len(value_list) < 2:
15!
935
                                return value_string
×
936
                            value_string = '\n    ' + '    '.join(value_list)
15✔
937
                            return value_string.rstrip()
15✔
938
                        return json.dumps(value, ensure_ascii=False, indent=2)
15✔
939
                    return str(value)
×
940

941
                type_t1 = type(ddiff.t1).__name__
15✔
942
                val_t1 = stringify_value(ddiff.t1, type_t1)
15✔
943
                type_t2 = type(ddiff.t2).__name__
15✔
944
                val_t2 = stringify_value(ddiff.t2, type_t2)
15✔
945

946
                diff_path = ddiff.path(root=root)
15✔
947
                return '• ' + pretty_form_texts.get(
15✔
948
                    ddiff.report_type or '',
949
                    '',
950
                ).format(
951
                    diff_path=diff_path,
952
                    type_t1=type_t1,
953
                    type_t2=type_t2,
954
                    val_t1=val_t1,
955
                    val_t2=val_t2,
956
                )
957

958
            def _pretty_print_diff_markdown_to_html(ddiff: DiffLevel) -> str:
15✔
959
                """Customized version of deepdiff.serialization.pretty_print_diff() function, edited to include the
960
                values deleted or added and to convert markdown into html.
961
                """
962

963
                def stringify_value(value: Any, value_type: str) -> str:  # noqa: ANN401 Dynamically typed expressions Any are disallowed
×
964
                    if value_type in {'str', 'int', 'float'}:
×
965
                        return f"'{mark_to_html(str(value))}'"
×
966
                    if value_type in {'dict', 'list'}:
×
967
                        if compact:
×
968
                            value_string = yaml.safe_dump(
×
969
                                value,
970
                                default_flow_style=False,
971
                                width=999,
972
                                allow_unicode=True,
973
                                sort_keys=False,
974
                            )
975
                            value_list = value_string.splitlines(keepends=True)
×
976
                            if len(value_list) < 2:
×
977
                                return value_string
×
978
                            value_string = mark_to_html('\n    ' + '    '.join(value_list))
×
979
                            return value_string.rstrip()
×
NEW
980
                        return mark_to_html(json.dumps(value, ensure_ascii=False, indent=2))
×
981
                    return mark_to_html(str(value))
×
982

983
                type_t1 = type(ddiff.t1).__name__
×
984
                val_t1 = stringify_value(ddiff.t1, type_t1)
×
985
                type_t2 = type(ddiff.t2).__name__
×
986
                val_t2 = stringify_value(ddiff.t2, type_t2)
×
987

988
                diff_path = ddiff.path(root=root)
×
989
                return '• ' + pretty_form_texts.get(
×
990
                    ddiff.report_type or '',
991
                    '',
992
                ).format(
993
                    diff_path=diff_path,
994
                    type_t1=type_t1,
995
                    type_t2=type_t2,
996
                    val_t1=val_t1,
997
                    val_t2=val_t2,
998
                )
999

1000
            result = (
15✔
1001
                [
1002
                    _pretty_print_diff_markdown_to_html(item_key)
1003
                    for tree_item in ddiff.tree.values()
1004
                    for item_key in tree_item
1005
                ]
1006
                if report_kind == 'html' and self.state.is_markdown()
1007
                else [_pretty_print_diff(item_key) for tree_item in ddiff.tree.values() for item_key in tree_item]
1008
            )
1009

1010
            return '\n'.join(result)
15✔
1011

1012
        def _serialize_method(
15✔
1013
            mime_type: str | None, data_label: Literal['Old', 'New']
1014
        ) -> Literal['json', 'yaml', 'xml', 'text'] | None:
1015
            """Parses the media type (formerly known as MIME type) of the data and determine if it's a known
1016
            seralization method.
1017

1018
            Uses data from https://www.iana.org/assignments/media-types/media-types.xhtml as well as various internet
1019
            searches.
1020

1021
            :param mime_type: The media type (formerly known as MIME type) of the data.
1022
            :param data_label: Either old or new, used for error reporting.
1023

1024
            :returns: Known serialization method or None.
1025
            """
1026
            if not mime_type:
15!
1027
                logger.info(
×
1028
                    f"Differ {self.__kind__} data_type for {data_label} data defaulted to 'json' as media type is "
1029
                    'missing.'
1030
                )
1031
                return 'json'
×
1032

1033
            media_type, subtype = mime_type.split('/', 1)
15✔
1034
            subtype = subtype.removeprefix('x-')  # 'x-' is deprecated per RFC6648 and needs to be removed
15✔
1035
            subtype = subtype.split('.')[-1]  # remove facet name; see RFC6838
15✔
1036
            subtype, subtype_suffix = subtype.split('+', 1) if '+' in subtype else (subtype, None)
15✔
1037

1038
            if media_type not in ('text', 'application'):
15!
1039
                return None
×
1040
            if {'yaml', 'yml'} & {subtype, subtype_suffix}:
15✔
1041
                return 'yaml'
15✔
1042
            if 'xml' in (subtype, subtype_suffix):
15✔
1043
                return 'xml'
15✔
1044
            if 'json' in (subtype, subtype_suffix):
15!
1045
                return 'json'
15✔
1046
            if media_type == 'application':
×
1047
                logger.info(
×
1048
                    f'Differ {self.__kind__} could not determine known serialization type of {data_label} data from '
1049
                    f"media type {mime_type}; defaulting to 'json'."
1050
                )
1051
                return 'json'
×
1052
            logger.info(
×
1053
                f'Differ {self.__kind__} could not determine data type of {data_label} data from media '
1054
                f"type {mime_type}; defaulting to 'text'."
1055
            )
1056
            return 'text'
×
1057

1058
        def deserialize_data(
15✔
1059
            data: str | bytes,
1060
            media_type: str | None,
1061
            data_type: Literal['json', 'yaml', 'xml', 'text'] | None,
1062
            data_label: Literal['Old', 'New'],
1063
        ) -> tuple[Any, dict | None]:
1064
            """Deserializes the stored data.
1065

1066
            :param data: The stored data.
1067
            :param mime_type: The media type (formerly MIME type) of the data.
1068
            :param data_type: The value of the data_type sub-parameter (overrides media type)
1069
            :param data_label: Either old or new, used for error reporting
1070

1071
            :returns: The deserialized data, any errors
1072
            """
1073
            if not data:
15✔
1074
                return data, None
15✔
1075
            deserialize_method = data_type or _serialize_method(media_type, data_label)
15✔
1076
            if deserialize_method == 'json':
15✔
1077
                try:
15✔
1078
                    return json.loads(data), None
15✔
1079
                except json.JSONDecodeError as e:
15✔
1080
                    self.state.exception = e
15✔
1081
                    self.state.traceback = self.job.format_error(e, traceback.format_exc())
15✔
1082
                    logger.error(
15✔
1083
                        f'Job {self.job.index_number}: {data_label} data is invalid JSON: {e} '
1084
                        f'({self.job.get_location()})'
1085
                    )
1086
                    logger.info(f'Job {self.job.index_number}: {data!r}')
15✔
1087
                    return None, {
15✔
1088
                        'plain': f'Differ {self.__kind__} ERROR: {data_label} data is invalid JSON\n{e}',
1089
                        'markdown': f'Differ {self.__kind__} **ERROR: {data_label} data is invalid JSON**\n{e}',
1090
                        'html': f'Differ {self.__kind__} <b>ERROR: {data_label} data is invalid JSON</b>\n{e}',
1091
                    }
1092
            if deserialize_method == 'yaml':
15✔
1093
                try:
15✔
1094
                    return yaml.safe_load(data), None
15✔
1095
                except yaml.YAMLError as e:
×
1096
                    self.state.exception = e
×
1097
                    self.state.traceback = self.job.format_error(e, traceback.format_exc())
×
1098
                    logger.error(
×
1099
                        f'Job {self.job.index_number}: {data_label} data is invalid YAML: {e} '
1100
                        f'({self.job.get_location()})'
1101
                    )
1102
                    logger.info(f'Job {self.job.index_number}: {data!r}')
×
1103
                    return None, {
×
1104
                        'plain': f'Differ {self.__kind__} ERROR: {data_label} data is invalid YAML\n{e}',
1105
                        'markdown': f'Differ {self.__kind__} **ERROR: {data_label} data is invalid YAML**\n{e}',
1106
                        'html': f'Differ {self.__kind__} <b>ERROR: {data_label} data is invalid YAML</b>\n{e}',
1107
                    }
1108
            if deserialize_method == 'xml':
15✔
1109
                if isinstance(xmltodict, str):  # pragma: no cover
1110
                    self.raise_import_error('xmltodict', xmltodict)
1111
                    raise RuntimeError  # for type checker
1112
                try:
15✔
1113
                    return xmltodict.parse(data), None
15✔
1114
                except ExpatError as e:
×
1115
                    self.state.exception = e
×
1116
                    self.state.traceback = self.job.format_error(e, traceback.format_exc())
×
1117
                    logger.error(
×
1118
                        f'Job {self.job.index_number}: {data_label} data is invalid XML: {e} '
1119
                        f'({self.job.get_location()})'
1120
                    )
1121
                    logger.info(f'Job {self.job.index_number}: {data!r}')
×
1122
                    return None, {
×
1123
                        'plain': f'Differ {self.__kind__} ERROR: {data_label} data is invalid XML\n{e}',
1124
                        'markdown': f'Differ {self.__kind__} **ERROR: {data_label} data is invalid XML**\n{e}',
1125
                        'html': f'Differ {self.__kind__} <b>ERROR: {data_label} data is invalid XML</b>\n{e}',
1126
                    }
1127
            if deserialize_method == 'text':
×
1128
                return data, None
×
1129
            return None, {
×
1130
                'plain': f'Differ {self.__kind__} ERROR: data_type {data_type} is not supported',
1131
                'markdown': f'Differ {self.__kind__} **ERROR: data_type {data_type} is not supported**',
1132
                'html': f'Differ {self.__kind__} <b>ERROR: data_type {data_type} is not supported</b>',
1133
            }
1134

1135
        old_data, err = deserialize_data(
15✔
1136
            self.state.old_data,
1137
            self.state.old_mime_type,
1138
            directives.get('data_type'),
1139
            'Old',
1140
        )
1141
        if err:
15✔
1142
            return err
15✔
1143
        new_data, err = deserialize_data(
15✔
1144
            self.state.new_data,
1145
            self.state.new_mime_type,
1146
            directives.get('data_type'),
1147
            'New',
1148
        )
1149
        if err:
15!
1150
            return err
×
1151
        ignore_order = bool(directives.get('ignore_order'))
15✔
1152
        ignore_string_case = bool(directives.get('ignore_string_case'))
15✔
1153
        significant_digits = directives.get('significant_digits')
15✔
1154
        compact = bool(directives.get('compact'))
15✔
1155
        ddiff = DeepDiff(
15✔
1156
            old_data,
1157
            new_data,
1158
            cache_purge_level=0,
1159
            cache_size=500,
1160
            cache_tuning_sample_size=500,
1161
            default_timezone=tz,  # ty:ignore[invalid-argument-type]
1162
            ignore_numeric_type_changes=True,
1163
            ignore_order=ignore_order,
1164
            ignore_string_case=ignore_string_case,
1165
            ignore_string_type_changes=True,
1166
            significant_digits=significant_digits,
1167
            verbose_level=min(2, max(0, math.ceil(3 - logger.getEffectiveLevel() / 10))),
1168
        )
1169
        diff_text = _pretty_deepdiff(ddiff, report_kind, compact)
15✔
1170
        if not diff_text:
15✔
1171
            self.state.verb = 'changed,no_report'
15✔
1172
            return {'plain': '', 'markdown': '', 'html': ''}
15✔
1173

1174
        self.job.set_to_monospace()
15✔
1175
        if report_kind == 'html':
15✔
1176
            html_diff = (
15✔
1177
                f'<span style="font-family:monospace;white-space:pre-wrap;">'
1178
                # f'Differ: {self.__kind__} for {data_type}\n'
1179
                f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>\n'
1180
                f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>\n'
1181
                + diff_text.replace('][', ']<wbr>[')
1182
                + '</span>'
1183
            )
1184
            return {'html': html_diff}
15✔
1185
        text_diff = (
15✔
1186
            # f'Differ: {self.__kind__} for {data_type}\n'
1187
            f'--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\n'
1188
            f'+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\n'
1189
            f'{diff_text}'
1190
        )
1191
        return {'plain': text_diff, 'markdown': text_diff}
15✔
1192

1193

1194
class ImageDiffer(DifferBase):
15✔
1195
    """Compares two images providing an image outlining areas that have changed."""
1196

1197
    __kind__ = 'image'
15✔
1198

1199
    __supported_directives__: dict[str, str] = {
15✔
1200
        'data_type': (
1201
            "'url' (to retrieve an image), 'ascii85' (Ascii85 data), 'base64' (Base64 data) or 'filename' (the path "
1202
            "to an image file) (default: 'url')"
1203
        ),
1204
        'mse_threshold': (
1205
            'the minimum mean squared error (MSE) between two images to consider them changed, if numpy in installed '
1206
            '(default: 2.5)'
1207
        ),
1208
        'ai_google': 'Generative AI summary of changes',
1209
    }
1210

1211
    def differ(  # noqa: C901 mccabe complexity too high
15✔
1212
        self,
1213
        directives: dict[str, Any],
1214
        report_kind: ReportKind,
1215
        _unfiltered_diff: dict[ReportKind, str] | None = None,
1216
        tz: ZoneInfo | None = None,
1217
    ) -> dict[ReportKind, str]:
1218
        warnings.warn(
6✔
1219
            f'Job {self.job.index_number}: Using differ {self.__kind__}, which is BETA, may have bugs, and may '
1220
            f'change in the future. Please report any problems or suggestions at '
1221
            f'https://github.com/mborsetti/webchanges/discussions.',
1222
            RuntimeWarning,
1223
            stacklevel=1,
1224
        )
1225
        if isinstance(Image, str):  # pragma: no cover
1226
            self.raise_import_error('pillow', Image)
1227
            raise RuntimeError  # for type checker
1228
        if isinstance(httpx, str):  # pragma: no cover
1229
            self.raise_import_error('httpx', httpx)
1230
            raise RuntimeError  # for type checker
1231

1232
        def load_image_from_web(url: str) -> Image.Image:
6✔
1233
            """Fetches the image from an url."""
1234
            logger.debug(f'Retrieving image from {url}')
6✔
1235
            with httpx.stream('GET', url, timeout=10) as response:
6✔
1236
                response.raise_for_status()
6✔
1237
                return Image.open(BytesIO(b''.join(response.iter_bytes())))
6✔
1238

1239
        def load_image_from_file(filename: str) -> Image.Image:
6✔
1240
            """Load an image from a file."""
1241
            logger.debug(f'Reading image from {filename}')
6✔
1242
            return Image.open(filename)
6✔
1243

1244
        def load_image_from_base64(base_64: str) -> Image.Image:
6✔
1245
            """Load an image from an encoded bytes object."""
1246
            logger.debug('Retrieving image from a base64 string')
6✔
1247
            return Image.open(BytesIO(base64.b64decode(base_64)))
6✔
1248

1249
        def load_image_from_ascii85(ascii85: str) -> Image.Image:
6✔
1250
            """Load an image from an encoded bytes object."""
1251
            logger.debug('Retrieving image from an ascii85 string')
6✔
1252
            return Image.open(BytesIO(base64.a85decode(ascii85)))
6✔
1253

1254
        def compute_diff_image(img1: Image.Image, img2: Image.Image) -> tuple[Image.Image, np.float64 | None]:
6✔
1255
            """Compute the difference between two images."""
1256
            # Compute the absolute value of the pixel-by-pixel difference between the two images.
1257
            diff_image = ImageChops.difference(img1, img2)
6✔
1258

1259
            # Compute the mean squared error between the images
1260
            if not isinstance(np, str):
6✔
1261
                diff_array = np.array(diff_image)
6✔
1262
                mse_value = np.mean(np.square(diff_array))
6✔
1263
            else:  # pragma: no cover
1264
                mse_value = None
1265

1266
            # Create the diff image by overlaying this difference on a darkened greyscale background
1267
            back_image = img1.convert('L')
6✔
1268
            back_image_brightness = ImageStat.Stat(back_image).rms[0]
6✔
1269
            back_image = ImageEnhance.Brightness(back_image).enhance(back_image_brightness / 225)
6✔
1270

1271
            # Convert the 'L' image to 'RGB' using a matrix that applies to yellow tint
1272
            # The matrix has 12 elements: 4 for Red, 4 for Green, and 4 for Blue.
1273
            # For yellow, we want Red and Green to copy the L values (1.0) and Blue to be zero.
1274
            # The matrix is: [R, G, B, A] for each of the three output channels
1275
            yellow_tint_matrix = (
6✔
1276
                1.0,
1277
                0.0,
1278
                0.0,
1279
                0.0,  # Red = 100% of the grayscale value
1280
                1.0,
1281
                0.0,
1282
                0.0,
1283
                0.0,  # Green = 100% of the grayscale value
1284
                0.0,
1285
                0.0,
1286
                0.0,
1287
                0.0,  # Blue = 0% of the grayscale value
1288
            )
1289

1290
            # Apply the conversion
1291
            diff_colored = diff_image.convert('RGB').convert('RGB', matrix=yellow_tint_matrix)
6✔
1292

1293
            final_img = ImageChops.add(back_image.convert('RGB'), diff_colored)
6✔
1294
            final_img.format = img2.format
6✔
1295

1296
            return final_img, mse_value
6✔
1297

1298
        def ai_google(
6✔
1299
            old_image: Image.Image,
1300
            new_image: Image.Image,
1301
            diff_image: Image.Image,
1302
            directives: AiGoogleDirectives,
1303
        ) -> tuple[str, str]:
1304
            """Summarize changes in image using Generative AI (ALPHA).  Returns summary and model name."""
1305
            logger.info(f'Job {self.job.index_number}: Running ai_google for {self.__kind__} differ')
×
1306
            warnings.warn(
×
1307
                f'Job {self.job.index_number}: Using differ {self.__kind__} with ai_google, which is ALPHA, '
1308
                f'may have bugs, and may change in the future. Please report any problems or suggestions at '
1309
                f'https://github.com/mborsetti/webchanges/discussions.',
1310
                RuntimeWarning,
1311
                stacklevel=1,
1312
            )
1313

1314
            api_version = '1beta'
×
1315
            # GOOGLE_AI_API_KEY deprecated end of 2025
1316
            gemini_api_key = os.environ.get('GEMINI_API_KEY', '').rstrip()
×
1317
            if not gemini_api_key:
×
1318
                gemini_api_key = os.environ.get('GOOGLE_AI_API_KEY', '').rstrip()
×
1319
                if gemini_api_key:
×
1320
                    warnings.warn(
×
1321
                        'The environment variable GOOGLE_AI_API_KEY is deprecated; please use GEMINI_API_KEY instead.',
1322
                        DeprecationWarning,
1323
                        stacklevel=1,
1324
                    )
1325
            if len(gemini_api_key) != 39:
×
1326
                logger.error(
×
1327
                    f'Job {self.job.index_number}: Environment variable GEMINI_API_KEY not found or is of the '
1328
                    f'incorrect length {len(gemini_api_key)} ({self.job.get_location()})'
1329
                )
1330
                return (
×
1331
                    f'## ERROR in summarizing changes using Google AI:\n'
1332
                    f'Environment variable GEMINI_API_KEY not found or is of the incorrect length '
1333
                    f'{len(gemini_api_key)}.\n',
1334
                    '',
1335
                )
1336

1337
            def _load_image(img_data: tuple[str, Image.Image]) -> dict[str, dict[str, str] | Exception | str]:
×
1338
                img_name, image = img_data
×
1339
                # Convert image to bytes
1340
                img_byte_arr = BytesIO()
×
1341
                image.save(img_byte_arr, format=image.format)
×
1342
                image_data = img_byte_arr.getvalue()
×
1343
                mime_type = f'image/{image.format.lower()}'  # type: ignore[union-attr]
×
1344

1345
                logger.info(
×
1346
                    f'Job {self.job.index_number}: Loading {img_name} ({image.format}) to Google AI '
1347
                    f'({len(image_data) / 1024:,.0f} kbytes)'
1348
                )
1349

1350
                # Initial resumable upload request
1351
                headers = {
×
1352
                    'X-Goog-Upload-Protocol': 'resumable',
1353
                    'X-Goog-Upload-Command': 'start',
1354
                    'X-Goog-Upload-Header-Content-Length': str(len(image_data)),
1355
                    'X-Goog-Upload-Header-Content-Type': mime_type,
1356
                    'Content-Type': 'application/json',
1357
                }
1358
                data = {'file': {'display_name': 'TEXT'}}
×
1359

1360
                with httpx.Client(http2=h2 is not None, timeout=self.job.timeout) as http_client:
×
1361
                    try:
×
1362
                        response = http_client.post(
×
1363
                            f'https://generativelanguage.googleapis.com/upload/v{api_version}/files?'
1364
                            f'key={gemini_api_key}',
1365
                            headers=headers,
1366
                            json=data,
1367
                        )
1368
                    except httpx.HTTPError as e:
×
1369
                        return {'error': e, 'img_name': img_name}
×
1370
                    upload_url = response.headers['X-Goog-Upload-Url']
×
1371

1372
                    # Upload the image data
1373
                    headers = {
×
1374
                        'Content-Length': str(len(image_data)),
1375
                        'X-Goog-Upload-Offset': '0',
1376
                        'X-Goog-Upload-Command': 'upload, finalize',
1377
                    }
1378
                    try:
×
1379
                        response = http_client.post(upload_url, headers=headers, content=image_data)
×
1380
                    except httpx.HTTPError as e:
×
1381
                        return {'error': e, 'img_name': img_name}
×
1382

1383
                # Extract file URI from response
1384
                file_info = response.json()
×
1385
                file_uri = file_info['file']['uri']
×
1386
                logger.info(f'Job {self.job.index_number}: {img_name.capitalize()} loaded to {file_uri}')
×
1387

1388
                return {
×
1389
                    'file_data': {
1390
                        'mime_type': mime_type,
1391
                        'file_uri': file_uri,
1392
                    }
1393
                }
1394

1395
            # upload to Google
1396
            additional_parts: list[dict[str, dict[str, str]]] = []
×
1397
            executor = ThreadPoolExecutor()
×
1398
            for additional_part in executor.map(
×
1399
                _load_image,
1400
                (
1401
                    ('old image', old_image),
1402
                    ('new image', new_image),
1403
                    # ('differences image', diff_image),
1404
                ),
1405
            ):
1406
                if 'error' not in additional_part:
×
1407
                    additional_parts.append(additional_part)  # type: ignore[arg-type]
×
1408
                else:
1409
                    logger.error(
×
1410
                        f'Job {self.job.index_number}: ai_google for {self.__kind__} HTTP Client error '
1411
                        f'{type(additional_part["error"])} when loading {additional_part["img_name"]} to Google AI: '
1412
                        f'{additional_part["error"]}'
1413
                    )
1414
                    return (
×
1415
                        f'HTTP Client error {type(additional_part["error"])} when loading '
1416
                        f'{additional_part["img_name"]} to Google AI: {additional_part["error"]}',
1417
                        '',
1418
                    )
1419

1420
            # system_instructions = (
1421
            #     'You are a skilled journalist tasked with summarizing the key differences between two versions '
1422
            #     'of the same image. The audience for your summary is already familiar with the image, so you can'
1423
            #     'focus on the most significant changes.'
1424
            # )
1425
            # model_prompt = (
1426
            #     'You are a skilled visual analyst tasked with analyzing two versions of an image and summarizing the '
1427
            #     'key differences between them. The audience for your summary is already familiar with the '
1428
            #     "image's content, so you should focus only on the most significant differences.\n\n"
1429
            #     '**Instructions:**\n\n'
1430
            #     # '1. Carefully examine the yellow areas in the image '
1431
            #     f"{additional_parts[2]['file_data']['file_uri']}, identify the differences, and describe them.\n"
1432
            #     f"2. Refer to the old version of the image {additional_parts[0]['file_data']['file_uri']} and the "
1433
            #     f"new version {additional_parts[1]['file_data']['file_uri']}.\n"
1434
            #     '3. You are only interested in those differences, such as additions, removals, or alterations, that '
1435
            #     'modify the intended message or interpretation.\n'
1436
            #     '4. Summarize the identified differences, except those ignored, in a clear and concise manner, '
1437
            #     'explaining how the meaning has shifted or evolved in the new version compared to the old version '
1438
            #     'only when necessary. Be specific and provide examples to illustrate your points when needed.\n'
1439
            #     '5. If there are only additions to the image, then summarize the additions.\n'
1440
            #     '6. Use Markdown formatting to structure your summary effectively. Use headings, bullet points, '
1441
            #     'and other Markdown elements as needed to enhance readability.\n'
1442
            #     '7. Restrict your analysis and summary to the information provided within these images. Do '
1443
            #     'not introduce external information or assumptions.\n'
1444
            # )
1445
            system_instructions = (
×
1446
                'You are a meticulous visual comparison agent. Your task is to analyze two images: an "old '
1447
                'version" and a "new version". Your entire focus is on identifying and listing the concrete, '
1448
                'factual differences between them.'
1449
            )
1450
            model_prompt = (
×
1451
                '**Instructions:**\n'
1452
                '\n'
1453
                f'1.  **Identify Changes:** Directly compare the "new version" '
1454
                f'{additional_parts[0]["file_data"]["file_uri"]} to the "old version" '
1455
                f'{additional_parts[1]["file_data"]["file_uri"]} and identify all additions, removals, and alterations '
1456
                'of visual elements.\n'
1457
                '\n'
1458
                '2.  **Filter for Significance:** From your initial list of changes, you must filter out any that '
1459
                'are minor or cosmetic. A difference is only significant if it alters the core subject matter or '
1460
                'the main message of the image.\n'
1461
                '    *   **IGNORE:** Minor shifts in layout, small changes in color saturation or brightness, or '
1462
                'other cosmetic adjustments that do not change what the image is depicting.\n'
1463
                '    *   **FOCUS ON:** Tangible changes such as added objects, removed people, or altered text.\n'
1464
                '\n'
1465
                '3.  **Summarize the Differences:**\n'
1466
                '    *   Present the significant differences as a bulleted list under the heading "Summary of '
1467
                'Changes".\n'
1468
                '    *   For each point, state the difference factually and concisely (e.g., "An apple was added '
1469
                "to the table,\" \"The text on the sign was changed from 'Open' to 'Closed'\").\n"
1470
                '    *   Only if a change directly and clearly alters the primary message or interpretation of the '
1471
                'image, you may add a brief, one-sentence explanation of this shift. Do not speculate on deeper '
1472
                'meanings.\n'
1473
                '\n'
1474
                '4.  **No Differences Found:** If you analyze both images and find no significant differences '
1475
                'according to the criteria above, you must respond with only the phrase: "No significant '
1476
                'differences were found between the two images." Do not attempt to find minor differences to report.\n'
1477
                '\n'
1478
                '5.  **Grounding:** Your entire analysis must be based solely on the visual information present in '
1479
                'the two images. Do not make assumptions or introduce any external information.'
1480
            )
1481
            summary, model_version = AIGoogleDiffer._send_to_model(
×
1482
                self.job,
1483
                system_instructions,
1484
                model_prompt,
1485
                additional_parts=additional_parts,  # type: ignore[arg-type]
1486
                directives=directives,
1487
            )
1488

1489
            return summary, model_version
×
1490

1491
        data_type = directives.get('data_type', 'url')
6✔
1492
        mse_threshold = directives.get('mse_threshold', 2.5)
6✔
1493
        if not isinstance(self.state.old_data, str):
6!
1494
            raise ValueError('old_data is not a string')
×
1495
        if not isinstance(self.state.new_data, str):
6!
1496
            raise ValueError('new_data is not a string')
×
1497
        if data_type == 'url':
6✔
1498
            old_image = load_image_from_web(self.state.old_data)
6✔
1499
            new_image = load_image_from_web(self.state.new_data)
6✔
1500
            old_data = f' (<a href="{self.state.old_data}" target="_blank">Old image</a>)'
6✔
1501
            new_data = f' (<a href="{self.state.new_data}" target="_blank">New image</a>)'
6✔
1502
        elif data_type == 'ascii85':
6✔
1503
            old_image = load_image_from_ascii85(self.state.old_data)
6✔
1504
            new_image = load_image_from_ascii85(self.state.new_data)
6✔
1505
            old_data = ''
6✔
1506
            new_data = ''
6✔
1507
        elif data_type == 'base64':
6✔
1508
            old_image = load_image_from_base64(self.state.old_data)
6✔
1509
            new_image = load_image_from_base64(self.state.new_data)
6✔
1510
            old_data = ''
6✔
1511
            new_data = ''
6✔
1512
        else:  # 'filename'
1513
            old_image = load_image_from_file(self.state.old_data)
6✔
1514
            new_image = load_image_from_file(self.state.new_data)
6✔
1515
            old_data = f' (<a href="file://{self.state.old_data}" target="_blank">Old image</a>)'
6✔
1516
            new_data = f' (<a href="file://{self.state.new_data}" target="_blank">New image</a>)'
6✔
1517

1518
        # Check formats  TODO: is it needed? under which circumstances?
1519
        # if new_image.format != old_image.format:
1520
        #     logger.info(f'Image formats do not match: {old_image.format} vs {new_image.format}')
1521
        # else:
1522
        #     logger.debug(f'image format is {old_image.format}')
1523

1524
        # Convert the images to a base64 object for HTML (before shrinking etc.)
1525
        output_stream = BytesIO()
6✔
1526
        old_image.save(output_stream, format=old_image.format)
6✔
1527
        encoded_old = b64encode(output_stream.getvalue()).decode()
6✔
1528
        if data_type == 'url':
6✔
1529
            encoded_new = ''
6✔
1530
        else:
1531
            output_stream = BytesIO()
6✔
1532
            new_image.save(output_stream, format=new_image.format)
6✔
1533
            encoded_new = b64encode(output_stream.getvalue()).decode()
6✔
1534

1535
        # If needed, shrink the larger image
1536
        if new_image.size != old_image.size:
6✔
1537
            if new_image.size > old_image.size:
6✔
1538
                logger.debug(f'Job {self.job.index_number}: Shrinking the new image')
6✔
1539
                img_format = new_image.format
6✔
1540
                new_image = new_image.resize(old_image.size, Image.Resampling.LANCZOS)
6✔
1541
                new_image.format = img_format
6✔
1542

1543
            else:
1544
                logger.debug(f'Job {self.job.index_number}: Shrinking the old image')
6✔
1545
                img_format = old_image.format
6✔
1546
                old_image = old_image.resize(new_image.size, Image.Resampling.LANCZOS)
6✔
1547
                old_image.format = img_format
6✔
1548

1549
        if old_image == new_image:
6✔
1550
            logger.info(f'Job {self.job.index_number}: New image is identical to the old one')
6✔
1551
            self.state.verb = 'unchanged'
6✔
1552
            return {'plain': '', 'markdown': '', 'html': ''}
6✔
1553

1554
        diff_image, mse_value = compute_diff_image(old_image, new_image)
6✔
1555
        if mse_value:
6!
1556
            logger.debug(f'Job {self.job.index_number}: MSE value {mse_value:.2f}')
6✔
1557

1558
        if mse_value and mse_value < mse_threshold:
6✔
1559
            logger.info(
6✔
1560
                f'Job {self.job.index_number}: MSE value {mse_value:.2f} below the threshold of {mse_threshold}; '
1561
                f'considering changes not worthy of a report'
1562
            )
1563
            self.state.verb = 'changed,no_report'
6✔
1564
            return {'plain': '', 'markdown': '', 'html': ''}
6✔
1565

1566
        # prepare AI summary
1567
        summary = ''
6✔
1568
        model_version = ''
6✔
1569
        if 'ai_google' in directives:
6!
1570
            summary, model_version = ai_google(old_image, new_image, diff_image, directives.get('ai_google', {}))
×
1571

1572
        # Prepare HTML output
1573
        htm = [
6✔
1574
            f'<span style="font-family:monospace">'
1575
            # f'Differ: {self.__kind__} for {data_type}',
1576
            f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}{old_data}</span>',
1577
            f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}{new_data}'
1578
            '</span>',
1579
            '</span>',
1580
            'New image:',
1581
        ]
1582
        if data_type == 'url':
6✔
1583
            htm.append(f'<img src="{self.state.new_data}" style="max-width: 100%; display: block;">')
6✔
1584
        else:
1585
            htm.append(
6✔
1586
                f'<img src="data:image/{(new_image.format or "").lower()};base64,{encoded_new}" '
1587
                'style="max-width: 100%; display: block;">'
1588
            )
1589
        # Convert the difference image to a base64 object
1590
        output_stream = BytesIO()
6✔
1591
        diff_image.save(output_stream, format=diff_image.format)
6✔
1592
        encoded_diff = b64encode(output_stream.getvalue()).decode()
6✔
1593
        htm.extend(
6✔
1594
            [
1595
                'Differences from old (in yellow):',
1596
                f'<img src="data:image/{(diff_image.format or "").lower()};base64,{encoded_diff}" '
1597
                'style="max-width: 100%; display: block;">',
1598
                'Old image:',
1599
                f'<img src="data:image/{(old_image.format or "").lower()};base64,{encoded_old}" '
1600
                'style="max-width: 100%; display: block;">',
1601
            ]
1602
        )
1603
        changed_text = 'The image has changed; please see an HTML report for the visualization.'
6✔
1604
        if not summary:
6!
1605
            return {
6✔
1606
                'plain': changed_text,
1607
                'markdown': changed_text,
1608
                'html': '<br>\n'.join(htm),
1609
            }
1610

1611
        newline = '\n'  # For Python < 3.12 f-string {} compatibility
×
1612
        back_n = '\\n'  # For Python < 3.12 f-string {} compatibility
×
1613
        directives_for_str = {key: value for key, value in directives.items() if key != 'model'}
×
1614
        if 'prompt' in directives_for_str:
×
1615
            directives_for_str['prompt'] = '«custom»'
×
1616
        directives_text = (
×
1617
            (
1618
                ' (ai_google directive(s): '
1619
                + ', '.join(f'{key}={str(value).replace(newline, back_n)}' for key, value in directives_for_str.items())
1620
                + ')'
1621
            )
1622
            if directives_for_str
1623
            else ''
1624
        )
1625
        footer = f"Summary by Google Generative AI's model {model_version}{directives_text}."
×
1626
        return {
×
1627
            'plain': (
1628
                f'{summary}\n\n\nA visualization of differences is available in {__package__} HTML reports.'
1629
                f'\n------------\n{footer}'
1630
            ),
1631
            'markdown': (
1632
                f'{summary}\n\n\nA visualization of differences is available in {__package__} HTML reports.'
1633
                f'\n* * *\n{footer}'
1634
            ),
1635
            'html': '<br>\n'.join(
1636
                [
1637
                    mark_to_html(summary, extras={'tables'}).replace('<h2>', '<h3>').replace('</h2>', '</h3>'),
1638
                    '',
1639
                    *htm,
1640
                    '-----',
1641
                    f'<i><small>{footer}</small></i>',
1642
                ]
1643
            ),
1644
        }
1645

1646

1647
class AIGoogleDiffer(DifferBase):
15✔
1648
    """(Default) Generates a summary using Google Generative AI (Gemini models).
1649

1650
    Calls Google Gemini APIs; documentation at https://ai.google.dev/api/rest and tutorial at
1651
    https://ai.google.dev/tutorials/rest_quickstart
1652

1653
    """
1654

1655
    __kind__ = 'ai_google'
15✔
1656

1657
    __supported_directives__: dict[str, str] = {
15✔
1658
        'model': ('model name from https://ai.google.dev/gemini-api/docs/models/gemini (default: gemini-2.0-flash)'),
1659
        'system_instructions': (
1660
            'Optional tone and style instructions for the model (default: see documentation at'
1661
            'https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff)'
1662
        ),
1663
        'prompt': 'a custom prompt - {unified_diff}, {unified_diff_new}, {old_text} and {new_text} will be replaced',
1664
        'additions_only': 'summarizes only added lines (including as a result of a change)',
1665
        'prompt_ud_context_lines': 'the number of context lines for {unified_diff} (default: 9999)',
1666
        'timeout': 'the number of seconds before timing out the API call (default: 300)',
1667
        'max_output_tokens': "the maximum number of tokens returned by the model (default: None, i.e. model's default)",
1668
        'media_resolution': 'a control of the maximum number of tokens allocated per input image or video frame',
1669
        'temperature': "the model's Temperature parameter (default: 0.0)",
1670
        'thinking_budget': "only for Gemini 2.5: The model's thinking budget",
1671
        'thinking_level': (
1672
            "For Gemini 3, the maximum depth of the model's internal reasoning process before it produces a response"
1673
        ),
1674
        'top_p': "the model's TopP parameter (default: None, i.e. model's default",
1675
        'top_k': "the model's TopK parameter (default: None, i.e. model's default",
1676
        'tools': "data passed on to the API's 'tools' field (default: None)",
1677
        'unified': 'directives passed to the unified differ (default: None)',
1678
    }
1679
    __default_directive__ = 'model'
15✔
1680

1681
    @staticmethod
15✔
1682
    def _send_to_model(
15✔
1683
        job: JobBase,
1684
        system_instructions: str,
1685
        model_prompt: str,
1686
        additional_parts: list[dict[str, str | dict[str, str]]] | None = None,
1687
        directives: AiGoogleDirectives | None = None,
1688
    ) -> tuple[str, str]:
1689
        """Creates the summary request to the model; returns the summary and the version of the actual model used."""
1690
        api_version = '1beta'
×
1691
        if directives is None:
×
1692
            directives = {}
×
1693
        model = directives.get('model', 'gemini-2.0-flash')
×
1694
        timeout = directives.get('timeout', 300)
×
1695
        max_output_tokens = directives.get('max_output_tokens')
×
1696
        temperature = directives.get('temperature', 0.0)
×
1697
        top_p = directives.get('top_p', 1.0 if temperature == 0.0 else None)
×
1698
        top_k = directives.get('top_k')
×
1699
        # GOOGLE_AI_API_KEY deprecated end of 2025
1700
        gemini_api_key = os.environ.get('GEMINI_API_KEY', '').rstrip()
×
1701
        if not gemini_api_key:
×
1702
            gemini_api_key = os.environ.get('GOOGLE_AI_API_KEY', '').rstrip()
×
1703
            if gemini_api_key:
×
1704
                warnings.warn(
×
1705
                    'The environment variable GOOGLE_AI_API_KEY is deprecated; please use GEMINI_API_KEY instead.',
1706
                    DeprecationWarning,
1707
                    stacklevel=1,
1708
                )
1709
        if len(gemini_api_key) != 39:
×
1710
            logger.error(
×
1711
                f'Job {job.index_number}: Environment variable GEMINI_API_KEY not found or is of the '
1712
                f'incorrect length {len(gemini_api_key)} ({job.get_location()})'
1713
            )
1714
            return (
×
1715
                f'## ERROR in summarizing changes using Google AI:\n'
1716
                f'Environment variable GEMINI_API_KEY not found or is of the incorrect length '
1717
                f'{len(gemini_api_key)}.',
1718
                '',
1719
            )
1720

1721
        data: dict[str, Any] = {
×
1722
            'system_instruction': {'parts': [{'text': system_instructions}]},
1723
            'contents': [{'parts': [{'text': model_prompt}]}],
1724
            'generationConfig': {
1725
                'maxOutputTokens': max_output_tokens,
1726
                'temperature': temperature,
1727
                'topP': top_p,
1728
                'topK': top_k,
1729
            },
1730
        }
1731
        if additional_parts:
×
1732
            data['contents'][0]['parts'].extend(additional_parts)
×
1733
        if directives.get('media_resolution'):
×
1734
            data['contents'][0]['parts'][0]['mediaResolution'] = {'level': directives['media_resolution']}
×
1735
        if directives.get('tools'):
×
1736
            data['tools'] = directives['tools']
×
1737
        if directives.get('thinking_level'):
×
1738
            data['generationConfig'].update({'thinkingConfig': {'thinkingLevel': directives['thinking_level']}})
×
1739
        elif directives.get('thinking_budget'):
×
1740
            data['generationConfig'].update({'thinkingConfig': {'thinkingBudget': directives['thinking_budget']}})
×
1741
        logger.info(f'Job {job.index_number}: Making the content generation request to Google AI model {model}')
×
1742
        model_version = model  # default
×
1743
        with httpx.Client(http2=h2 is not None) as http_client:
×
1744
            try:
×
1745
                r = http_client.post(
×
1746
                    f'https://generativelanguage.googleapis.com/v{api_version}/models/{model}:generateContent?'
1747
                    f'key={gemini_api_key}',
1748
                    json=data,
1749
                    headers={'Content-Type': 'application/json'},
1750
                    timeout=timeout,
1751
                )
1752
            except httpx.HTTPError as e:
×
1753
                summary = (
×
1754
                    f'## ERROR in summarizing changes using Google AI:\n'
1755
                    f'HTTP client error: {e} when requesting data from {e.request.url.host}'
1756
                )
1757
                return summary, model_version
×
1758

1759
        if r.is_success:
×
1760
            result = r.json()
×
1761
            candidate = result['candidates'][0]
×
1762
            finish_reason = candidate['finishReason']
×
1763
            model_version = result['modelVersion']
×
1764
            logger.info(f'Job {job.index_number}: AI generation finished by {finish_reason} using {model_version}')
×
1765
            logger.debug(
×
1766
                f'Job {job.index_number}: Used {result["usageMetadata"]["totalTokenCount"]:,} tokens, '
1767
                f'{result["usageMetadata"]["totalTokenCount"]:,} of which for the prompt.'
1768
            )
1769
            if 'content' in candidate:
×
1770
                if 'parts' in candidate['content']:
×
1771
                    summary: str = candidate['content']['parts'][0]['text'].rstrip()
×
1772
                else:
1773
                    summary = (
×
1774
                        f'## ERROR in summarizing changes using Google AI:\n'
1775
                        f'Model did not return any candidate output:\n'
1776
                        f'finishReason={finish_reason}'
1777
                        f'{json.dumps(result["usageMetadata"], ensure_ascii=True, indent=2)}'
1778
                    )
1779
            else:
1780
                summary = (
×
1781
                    f'## ERROR in summarizing changes using Google AI:\n'
1782
                    f'Model did not return any candidate output:\n'
1783
                    f'{json.dumps(result, ensure_ascii=True, indent=2)}'
1784
                )
1785

1786
        elif r.status_code == 400:
×
1787
            summary = (
×
1788
                f'## ERROR in summarizing changes using Google AI:\n'
1789
                f'Received error from {r.url.host}: '
1790
                f'{r.json().get("error", {}).get("message") or ""}'
1791
            )
1792
        else:
1793
            summary = (
×
1794
                f'## ERROR in summarizing changes using Google AI:\n'
1795
                f'Received error {r.status_code} {r.reason_phrase} from '
1796
                f'{r.url.host}'
1797
            )
1798
            if r.content:
×
1799
                summary += f': {r.json().get("error", {}).get("message") or ""}'
×
1800

1801
        return summary, model_version
×
1802

1803
    def differ(
15✔
1804
        self,
1805
        directives: AiGoogleDirectives,
1806
        report_kind: ReportKind,
1807
        _unfiltered_diff: dict[ReportKind, str] | None = None,
1808
        tz: ZoneInfo | None = None,
1809
    ) -> dict[ReportKind, str]:
1810
        logger.info(f'Job {self.job.index_number}: Running the {self.__kind__} differ from hooks.py')
15✔
1811
        # warnings.warn(
1812
        #     f'Job {self.job.index_number}: Using differ {self.__kind__}, which is BETA, may have bugs, and may '
1813
        #     f'change in the future. Please report any problems or suggestions at '
1814
        #     f'https://github.com/mborsetti/webchanges/discussions.',
1815
        #     RuntimeWarning,
1816
        #     stacklevel=1,
1817
        # )
1818

1819
        def get_ai_summary(prompt: str, system_instructions: str) -> tuple[str, str]:
15✔
1820
            """Generate AI summary from unified diff, or an error message, plus the model version."""
1821
            # GOOGLE_AI_API_KEY deprecated end of 2025
1822
            gemini_api_key = os.environ.get('GEMINI_API_KEY', '').rstrip()
15✔
1823
            if not gemini_api_key:
15✔
1824
                gemini_api_key = os.environ.get('GOOGLE_AI_API_KEY', '').rstrip()
15✔
1825
                if gemini_api_key:
15!
1826
                    warnings.warn(
×
1827
                        'The environment variable GOOGLE_AI_API_KEY is deprecated; please use GEMINI_API_KEY instead.',
1828
                        DeprecationWarning,
1829
                        stacklevel=1,
1830
                    )
1831
            if len(gemini_api_key) != 39:
15✔
1832
                logger.error(
15✔
1833
                    f'Job {self.job.index_number}: Environment variable GEMINI_API_KEY not found or is of the '
1834
                    f'incorrect length {len(gemini_api_key)} ({self.job.get_location()})'
1835
                )
1836
                return (
15✔
1837
                    f'## ERROR in summarizing changes using Google AI:\n'
1838
                    f'Environment variable GEMINI_API_KEY not found or is of the incorrect length '
1839
                    f'{len(gemini_api_key)}.\n',
1840
                    '',
1841
                )
1842

1843
            if '{unified_diff' in prompt:  # matches unified_diff or unified_diff_new
15!
1844
                default_context_lines = 9999 if '{unified_diff}' in prompt else 0  # none if only unified_diff_new
×
1845
                context_lines = directives.get('prompt_ud_context_lines', default_context_lines)
×
1846
                unified_diff = '\n'.join(
×
1847
                    difflib.unified_diff(
1848
                        str(self.state.old_data).splitlines(),
1849
                        str(self.state.new_data).splitlines(),
1850
                        # '@',
1851
                        # '@',
1852
                        # self.make_timestamp(self.state.old_timestamp, tz),
1853
                        # self.make_timestamp(self.state.new_timestamp, tz),
1854
                        n=context_lines,
1855
                    )
1856
                )
1857
                if not unified_diff:
×
1858
                    # no changes
1859
                    return '', ''
×
1860
            else:
1861
                unified_diff = ''
15✔
1862

1863
            if '{unified_diff_new}' in prompt:
15!
1864
                unified_diff_new_lines = [line[1:] for line in unified_diff.splitlines() if line.startswith('+')]
×
1865
                unified_diff_new = '\n'.join(unified_diff_new_lines)
×
1866
            else:
1867
                unified_diff_new = ''
15✔
1868

1869
            # check if data is different (same data is sent during testing)
1870
            if '{old_text}' in prompt and '{new_text}' in prompt and self.state.old_data == self.state.new_data:
15!
1871
                return '', ''
15✔
1872

1873
            model_prompt = prompt.format(
×
1874
                unified_diff=unified_diff,
1875
                unified_diff_new=unified_diff_new,
1876
                old_text=self.state.old_data,
1877
                new_text=self.state.new_data,
1878
            )
1879

1880
            summary, model_version = self._send_to_model(
×
1881
                self.job,
1882
                system_instructions,
1883
                model_prompt,
1884
                directives=directives,
1885
            )
1886

1887
            return summary, model_version
×
1888

1889
        default_system_instructions = ''
15✔
1890
        if directives.get('additions_only') or self.job.additions_only:
15!
1891
            default_prompt = '\n'.join(
×
1892
                (
1893
                    'You are an expert analyst AI, specializing in the meticulous summarization of change documents. '
1894
                    'Your task is to summarize the provided unified diff in a clear and concise manner with 100% '
1895
                    'fidelity. Restrict your analysis and summary *only* to the diff provided. Do not introduce any '
1896
                    'external information or assumptions.',
1897
                    '',
1898
                    'Format your summary using Markdown. Use headings, bullet points, and other Markdown elements '
1899
                    'where appropriate to create a well-structured and easily readable summary.',
1900
                    '',
1901
                    '{unified_diff_new}',
1902
                )
1903
            )
1904
        else:
1905
            default_prompt = '\n'.join(
15✔
1906
                (
1907
                    'You are an expert analyst AI, specializing in the meticulous comparison of documents. Your task '
1908
                    'is to identify and summarize only the substantive differences between two versions of a text. '
1909
                    'Your audience is already familiar with the original document and needs a concise summary of the '
1910
                    'most significant changes in meaning or information.',
1911
                    '',
1912
                    '**Instructions:**',
1913
                    '',
1914
                    '1.  **Analyze the Texts:** Carefully review the document provided in the `<old_version>` and '
1915
                    '`</old_version>` tags and the one in the `<new_version>` and `</new_version>` tags.',
1916
                    '',
1917
                    '2.  **Identify Substantive Changes:** Compare the two versions to identify all substantive '
1918
                    'changes. A "substantive change" is defined as any modification that alters the core meaning, '
1919
                    'intent, instructions, or factual information presented in the text. This includes, but is not '
1920
                    'limited to:',
1921
                    '*   Additions of new concepts, data, or requirements.',
1922
                    '*   Deletions of existing information, arguments, or clauses.',
1923
                    '*   Alterations to definitions, conclusions, instructions, or key takeaways.',
1924
                    '',
1925
                    '3.  **Exclude Non-Substantive Changes:** You must disregard any changes that are purely cosmetic, '
1926
                    'typographical, or structural and do not alter the substantive meaning of the document. Explicitly '
1927
                    'ignore the following:',
1928
                    '*   Changes in page numbers, section/chapter numbering, or paragraph numbering.',
1929
                    '*   Corrections of spelling, punctuation, or grammatical errors.',
1930
                    '*   Modifications in formatting, layout, or font.',
1931
                    '*   Rewording or rephrasing that does not change the underlying meaning or intent.',
1932
                    '',
1933
                    '4.  **Summarize Material Differences:** Create a summary of the identified substantive changes '
1934
                    'with 100% fidelity. For each change, provide:',
1935
                    '*   A clear heading identifying the relevant section (e.g., "Section 4: User Guidelines" or '
1936
                    '"Chapteron Methodology").',
1937
                    '*   A concise description of the modification, explaining whether it is an addition, deletion, or '
1938
                    'alteration.',
1939
                    '*   A brief analysis of how the change impacts the overall message or instructions, if not '
1940
                    'immediately obvious.',
1941
                    '',
1942
                    '5.  **Output Format:**',
1943
                    '*   Use Markdown for clear and structured presentation (e.g., headings and bullet points).',
1944
                    '*   If no substantive changes are found, state this clearly.',
1945
                    '*   If the changes consist only of additions, summarize the new content.',
1946
                    '',
1947
                    '6.  **Scope Limitation:** Base your analysis strictly on the provided text excerpts. Do not '
1948
                    'infer or introduce any external context or information.',
1949
                    '',
1950
                    '<old_version>',
1951
                    '{old_text}',
1952
                    '</old_version>',
1953
                    '',
1954
                    '<new_version>',
1955
                    '{new_text}',
1956
                    '</new_version>',
1957
                )
1958
            )
1959

1960
        system_instructions = directives.get('system_instructions', default_system_instructions)
15✔
1961
        prompt = directives.get('prompt', default_prompt).replace('\\n', '\n')
15✔
1962
        summary, model_version = get_ai_summary(prompt, system_instructions)
15✔
1963
        if not summary:
15✔
1964
            self.state.verb = 'changed,no_report'
15✔
1965
            return {'plain': '', 'markdown': '', 'html': ''}
15✔
1966
        newline = '\n'  # For Python < 3.12 f-string {} compatibility
15✔
1967
        back_n = '\\n'  # For Python < 3.12 f-string {} compatibility
15✔
1968
        directives_for_str = {key: value for key, value in directives.items() if key != 'model'}
15✔
1969
        if 'prompt' in directives_for_str:
15!
1970
            directives_for_str['prompt'] = '«custom»'
×
1971
        directives_text = (
15✔
1972
            (
1973
                ' (differ directive(s): '
1974
                + ', '.join(f'{key}={str(value).replace(newline, back_n)}' for key, value in directives_for_str.items())
1975
                + ')'
1976
            )
1977
            if directives_for_str
1978
            else ''
1979
        )
1980
        footer = (
15✔
1981
            f"Summary by Google Generative AI's model {model_version}{directives_text}."
1982
            if model_version or directives_text
1983
            else ''
1984
        )
1985
        temp_unfiltered_diff: dict[ReportKind, str] = {}
15✔
1986
        for rep_kind in ('plain', 'html'):  # markdown is same as text
15✔
1987
            unified_report = DifferBase.process(
15✔
1988
                'unified',
1989
                directives.get('unified') or {},
1990
                self.state,
1991
                rep_kind,
1992
                tz,
1993
                temp_unfiltered_diff,
1994
            )
1995
        return {
15✔
1996
            'plain': (f'{summary}\n\n{unified_report["plain"]}' + (f'\n------------\n{footer}' if footer else '')),
1997
            'markdown': (f'{summary}\n\n{unified_report["markdown"]}' + (f'\n* * *\n{footer}' if footer else '')),
1998
            'html': '\n'.join(
1999
                [
2000
                    mark_to_html(summary, extras={'tables'}).replace('<h2>', '<h3>').replace('</h2>', '</h3>'),
2001
                    '<br>',
2002
                    '<br>',
2003
                    unified_report['html'],
2004
                ]
2005
                + (['-----<br>', f'<i><small>{footer}</small></i>'] if footer else [])
2006
            ),
2007
        }
2008

2009

2010
class WdiffDiffer(DifferBase):
15✔
2011
    __kind__ = 'wdiff'
15✔
2012

2013
    __supported_directives__: dict[str, str] = {
15✔
2014
        'context_lines': 'the number of context lines (default: 3)',
2015
        'range_info': 'include range information lines (default: true)',
2016
        'color': 'colorize text output (default: true)',
2017
    }
2018

2019
    @staticmethod
15✔
2020
    def tokenize_markdown(markdown_string: str) -> list[str]:
15✔
2021
        # Escape spaces inside brackets to prevent them being split
2022
        string = re.sub(r'\[(.*?)\]', lambda x: '[' + x.group(1).replace(' ', '</s>') + ']', markdown_string)
15✔
2023
        # Use split with capturing group to keep the whitespace
2024
        tokens = re.split(r'(\s+)', string)
15✔
2025
        return [t.replace('\n', '<\\n>') for t in tokens if t]
15✔
2026
        # # Split by tags, keeping the tags in the resulting list
2027
        # string = re.sub(r'\[(.*?)\]', lambda x: '[' + x.group(1).replace(' ', '</s>') + ']', markdown_string)
2028
        # string = string.replace('\n', ' <\\n> ')
2029
        # return string.split(' ')
2030

2031
    @staticmethod
15✔
2032
    def tokenize_html(html_string: str) -> list[str]:
15✔
2033
        # Split by tags, keeping the tags in the resulting list
2034
        parts = re.split(r'(<[^>]+>)', html_string)
15✔
2035

2036
        tokens = []
15✔
2037
        for part in parts:
15✔
2038
            if not part:
15✔
2039
                continue
15✔
2040
            if part.startswith('<') and part.endswith('>'):
15✔
2041
                # Keep tags as a single token
2042
                tokens.append(part)
15✔
2043
            else:
2044
                # Split text content by whitespace
2045
                tokens.extend(re.split(r'(\s+)', part))
15✔
2046

2047
        return [t.replace('\n', '<\\n>') for t in tokens if t]
15✔
2048

2049
    @staticmethod
15✔
2050
    def tokenize_plain(plain_string: str) -> list[str]:
15✔
2051
        # Split by tags, keeping the tags in the resulting list
2052
        tokens = re.split(r'(\s+)', plain_string)
15✔
2053
        return [t.replace('\n', '<\\n>') for t in tokens if t]
15✔
2054

2055
    def differ(
15✔
2056
        self,
2057
        directives: dict[str, Any],
2058
        report_kind: ReportKind,
2059
        _unfiltered_diff: dict[ReportKind, str] | None = None,
2060
        tz: ZoneInfo | None = None,
2061
    ) -> dict[ReportKind, str]:
2062
        if not isinstance(self.state.old_data, str):
15!
2063
            raise ValueError("The differ 'wdiff' accepts strings only as input")
×
2064
        if not isinstance(self.state.new_data, str):
15!
2065
            raise ValueError
×
2066

2067
        # Split the texts into words tokenizing newline
2068
        if 'markdown' in self.state.old_mime_type:
15✔
2069
            a = self.tokenize_markdown(self.state.old_data)
15✔
2070
        elif 'html' in self.state.old_mime_type:
15✔
2071
            a = self.tokenize_html(self.state.old_data)
15✔
2072
        else:
2073
            a = self.tokenize_plain(self.state.old_data)
15✔
2074
        if 'markdown' in self.state.new_mime_type:
15✔
2075
            b = self.tokenize_markdown(self.state.new_data)
15✔
2076
        elif 'html' in self.state.new_mime_type:
15✔
2077
            b = self.tokenize_html(self.state.new_data)
15✔
2078
        else:
2079
            b = self.tokenize_plain(self.state.new_data)
15✔
2080

2081
        # Create a Differ object
2082
        import difflib
15✔
2083

2084
        # Generate a difference list
2085
        diff = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
15✔
2086

2087
        if directives.get('color', True):
15✔
2088
            text_rem = '\033[91m'
15✔
2089
            text_rem_end = '\033[0m '
15✔
2090
            text_add = '\033[92m'
15✔
2091
            text_add_end = '\033[0m'
15✔
2092
        else:
2093
            text_rem = '[-'
15✔
2094
            text_rem_end = '-] '
15✔
2095
            text_add = '{+'
15✔
2096
            text_add_end = '+}'
15✔
2097

2098
        html_rem = '<span style="background-color:#fff0f0;color:#9c1c1c;text-decoration:line-through;">'
15✔
2099
        html_rem_end = '</span> '
15✔
2100
        html_add = '<span style="background-color:#d1ffd1;color:#082b08;">'
15✔
2101
        html_add_end = '</span>'
15✔
2102

2103
        head_text = '\n'.join(
15✔
2104
            [
2105
                # f'Differ: wdiff',
2106
                f'\033[91m--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\033[0m',
2107
                f'\033[92m+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\033[0m',
2108
                '',
2109
            ]
2110
        )
2111
        head_html = '<br>\n'.join(
15✔
2112
            [
2113
                '<span style="font-family:monospace;">'
2114
                # 'Differ: wdiff',
2115
                f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>',
2116
                f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>'
2117
                f'</span>',
2118
                '',
2119
            ]
2120
        )
2121

2122
        result_text = []
15✔
2123
        result_html = []
15✔
2124

2125
        def append_chunk(tokens: list[str], text_start: str, text_end: str, html_start: str, html_end: str) -> None:
15✔
2126
            # We use an empty string join because tokens now include their own spaces
2127
            segment = ''.join(tokens)
15✔
2128

2129
            # Handle the newline marker replacement without adding extra spaces
2130
            parts = segment.split('<\\n>')
15✔
2131
            for i, part in enumerate(parts):
15✔
2132
                if part:
15✔
2133
                    result_text.append(f'{text_start}{part}{text_end}')
15✔
2134
                    result_html.append(f'{html_start}{part}{html_end}')
15✔
2135

2136
                # If not the last element, it means there was a <\n>
2137
                if i < len(parts) - 1:
15✔
2138
                    result_text.append('\n')
15✔
2139
                    result_html.append('\n')
15✔
2140

2141
        # def append_chunk(tokens: list[str], ansi_color: str | None, html_start: str | None) -> None:
2142
        #     """
2143
        #     Appends tokens to results. If style is provided, wraps tokens in style.
2144
        #     Breaks style application at newlines (<\\n>) to maintain clean output formatting.
2145
        #     """
2146
        #     current_line_tokens = []
2147

2148
        #     def flush() -> None:
2149
        #         if current_line_tokens:
2150
        #             # Join tokens with space for this segment
2151
        #             segment = ' '.join(current_line_tokens)
2152

2153
        #             if ansi_color:
2154
        #                 result_text.append(f'{ansi_color}{segment}{ansi_end}')
2155
        #                 result_html.append(f'{html_start}{segment}{html_end}')
2156
        #             else:
2157
        #                 result_text.append(segment)
2158
        #                 result_html.append(segment)
2159
        #             current_line_tokens.clear()
2160

2161
        #     for token in tokens:
2162
        #         if token == '<\\n>':
2163
        #             flush()
2164
        #             # Append newline marker without styling
2165
        #             result_text.append('<\\n>')
2166
        #             result_html.append('<\\n>')
2167
        #         else:
2168
        #             current_line_tokens.append(token)
2169
        #     flush()
2170

2171
        # Process the diff opcodes
2172
        for tag, i1, i2, j1, j2 in diff.get_opcodes():
15✔
2173
            """
15✔
2174
            The tags are strings, with these meanings:
2175

2176
            'replace':  a[i1:i2] should be replaced by b[j1:j2]
2177
            'delete':   a[i1:i2] should be deleted.
2178
                        Note that j1==j2 in this case.
2179
            'insert':   b[j1:j2] should be inserted at a[i1:i1].
2180
                        Note that i1==i2 in this case.
2181
            'equal':    a[i1:i2] == b[j1:j2]
2182
            """
2183
            if tag == 'equal':
15✔
2184
                append_chunk(a[i1:i2], '', '', '', '')
15✔
2185
            elif tag == 'delete':
15✔
2186
                append_chunk(a[i1:i2], text_rem, text_rem_end, html_rem, html_rem_end)
15✔
2187
            elif tag == 'insert':
15✔
2188
                append_chunk(b[j1:j2], text_add, text_add_end, html_add, html_add_end)
15✔
2189
            elif tag == 'replace':
15!
2190
                append_chunk(a[i1:i2], text_rem, text_rem_end, html_rem, html_rem_end)
15✔
2191
                append_chunk(b[j1:j2], text_add, text_add_end, html_add, html_add_end)
15✔
2192

2193
        # rebuild the text from words, replacing the newline token
2194
        diff_text = ''.join(result_text).rstrip(' ')
15✔
2195
        diff_html = ''.join(result_html).rstrip(' ')
15✔
2196

2197
        # d = difflib.Differ()
2198

2199
        # # Generate a difference list
2200
        # diff = list(d.compare(old_tokens, new_tokens))
2201

2202
        # add_html = '<span style="background-color:#d1ffd1;color:#082b08;">'
2203
        # rem_html = '<span style="background-color:#fff0f0;color:#9c1c1c;text-decoration:line-through;">'
2204

2205
        # head_text = '\n'.join(
2206
        #     [
2207
        #         # f'Differ: wdiff',
2208
        #         f'\033[91m--- @ {self.make_timestamp(self.state.old_timestamp, tz)}\033[0m',
2209
        #         f'\033[92m+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}\033[0m',
2210
        #         '',
2211
        #     ]
2212
        # )
2213
        # head_html = '<br>\n'.join(
2214
        #     [
2215
        #         '<span style="font-family:monospace;">'
2216
        #         # 'Differ: wdiff',
2217
        #         f'<span style="color:darkred;">--- @ {self.make_timestamp(self.state.old_timestamp, tz)}</span>',
2218
        #         f'<span style="color:darkgreen;">+++ @ {self.make_timestamp(self.state.new_timestamp, tz)}</span>'
2219
        #         f'</span>',
2220
        #         '',
2221
        #     ]
2222
        # )
2223
        # # Process the diff output to make it more wdiff-like
2224
        # result_text = []
2225
        # result_html = []
2226
        # prev_word_text = ''
2227
        # prev_word_html = ''
2228
        # next_text = ''
2229
        # next_html = ''
2230
        # add = False
2231
        # rem = False
2232

2233
        # for word_text in [*diff, '  ']:
2234
        #     if word_text[0] == '?':  # additional context line
2235
        #         continue
2236
        #     word_html = word_text
2237
        #     pre_text = [next_text] if next_text else []
2238
        #     pre_html = [next_html] if next_html else []
2239
        #     next_text = ''
2240
        #     next_html = ''
2241

2242
        #     if word_text[0] == '+' and not add:  # Beginning of additions
2243
        #         if rem:
2244
        #             prev_word_html += '</span>'
2245
        #             rem = False
2246
        #         if word_text[2:] == '<\\n>':
2247
        #             next_text = '\033[92m'
2248
        #             next_html = add_html
2249
        #         else:
2250
        #             pre_text.append('\033[92m')
2251
        #             pre_html.append(add_html)
2252
        #         add = True
2253
        #     elif word_text[0] == '-' and not rem:  # Beginning of deletions
2254
        #         if add:
2255
        #             prev_word_html += '</span>'
2256
        #             add = False
2257
        #         if word_text[2:] == '<\\n>':
2258
        #             next_text = '\033[91m'
2259
        #             next_html = rem_html
2260
        #         else:
2261
        #             pre_text.append('\033[91m')
2262
        #             pre_html.append(rem_html)
2263
        #         rem = True
2264
        #     elif word_text[0] == ' ' and (add or rem):  # Unchanged word
2265
        #         if prev_word_text == '<\\n>':
2266
        #             prev_word_text = '\033[0m<\\n>'
2267
        #             prev_word_html = '</span><\\n>'
2268
        #         else:
2269
        #             prev_word_text += '\033[0m'
2270
        #             prev_word_html += '</span>'
2271
        #         add = False
2272
        #         rem = False
2273
        #     elif word_text[2:] == '<\\n>':  # New line
2274
        #         if add:
2275
        #             word_text = '  \033[0m<\\n>'
2276
        #             word_html = '  </span><\\n>'
2277
        #             add = False
2278
        #         elif rem:
2279
        #             word_text = '  \033[0m<\\n>'
2280
        #             word_html = '  </span><\\n>'
2281
        #             rem = False
2282

2283
        #     result_text.append(prev_word_text)
2284
        #     result_html.append(prev_word_html)
2285
        #     pre_text.append(word_text[2:])
2286
        #     pre_html.append(word_html[2:])
2287
        #     prev_word_text = ''.join(pre_text)
2288
        #     prev_word_html = ''.join(pre_html)
2289
        # if add or rem:
2290
        #     result_text[-1] += '\033[0m'
2291
        #     result_html[-1] += '</span>'
2292

2293
        # # rebuild the text from words, replacing the newline token
2294
        # diff_text = ' '.join(result_text[1:]).replace('<\\n> ', '\n').replace('<\\n>', '\n')
2295
        # diff_html = ' '.join(result_html[1:]).replace('<\\n> ', '\n').replace('<\\n>', '\n')
2296

2297
        # build contextlines
2298
        contextlines = directives.get('context_lines', self.job.contextlines)
15✔
2299
        # contextlines = 999
2300
        if contextlines is None:
15!
2301
            contextlines = 3
15✔
2302
        range_info = directives.get('range_info', True)
15✔
2303
        if contextlines < len(diff_text.splitlines()):
15!
2304
            lines_with_changes = []
×
2305
            for i, line in enumerate(diff_text.splitlines()):
×
2306
                if '\033[9' in line:
×
2307
                    lines_with_changes.append(i)
×
2308
            if contextlines:
×
2309
                lines_to_keep: set[int] = set()
×
2310
                for i in lines_with_changes:
×
2311
                    lines_to_keep.update(r for r in range(i - contextlines, i + contextlines + 1))
×
2312
            else:
2313
                lines_to_keep = set(lines_with_changes)
×
2314
            new_diff_text = []
×
2315
            new_diff_html = []
×
2316
            last_line = 0
×
2317
            skip = False
×
2318
            i = 0
×
2319
            for i, (line_text, line_html) in enumerate(
×
2320
                zip(diff_text.splitlines(), diff_html.splitlines(), strict=False)
2321
            ):
2322
                if i in lines_to_keep:
×
2323
                    if range_info and skip:
×
2324
                        new_diff_text.append(f'@@ {last_line + 1}...{i} @@')
×
2325
                        new_diff_html.append(f'@@ {last_line + 1}...{i} @@')
×
2326
                        skip = False
×
2327
                    new_diff_text.append(line_text)
×
2328
                    new_diff_html.append(line_html)
×
2329
                    last_line = i + 1
×
2330
                else:
2331
                    skip = True
×
2332
            if (i + 1) != last_line and range_info and skip:
×
2333
                new_diff_text.append(f'@@ {last_line + 1}...{i + 1} @@')
×
2334
                new_diff_html.append(f'@@ {last_line + 1}...{i + 1} @@')
×
2335
            diff_text = '\n'.join(new_diff_text)
×
2336
            diff_html = '\n'.join(new_diff_html)
×
2337

2338
        if self.state.is_markdown():
15✔
2339
            diff_text = diff_text.replace('</s>', ' ')
15✔
2340
            diff_html = diff_html.replace('</s>', ' ')
15✔
2341
            diff_html = mark_to_html(diff_html, self.job.markdown_padded_tables).replace('<p>', '').replace('</p>', '')
15✔
2342

2343
        if self.job.monospace:
15!
2344
            diff_html = f'<span style="font-family:monospace;white-space:pre-wrap">{diff_html}</span>'
×
2345
        else:
2346
            diff_html = diff_html.replace('\n', '<br>\n')
15✔
2347

2348
        return {
15✔
2349
            'plain': head_text + diff_text,
2350
            'markdown': head_text + diff_text,
2351
            'html': head_html + diff_html,
2352
        }
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