https://github.com/python/cpython/commit/34d7351ac770ac49875fc39396b2a97828ba05ad
commit: 34d7351ac770ac49875fc39396b2a97828ba05ad
branch: main
author: Douglas Thor <dougtho...@users.noreply.github.com>
committer: hugovk <1324225+hug...@users.noreply.github.com>
date: 2025-08-08T18:34:02+03:00
summary:

gh-133722: Add Difflib theme to `_colorize` and 'color' option to 
`difflib.unified_diff` (#133725)

files:
A Misc/NEWS.d/next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst
M Doc/library/difflib.rst
M Doc/whatsnew/3.15.rst
M Lib/_colorize.py
M Lib/difflib.py
M Lib/test/test_difflib.py
M Misc/ACKS

diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst
index ec8b575a1ba999..c55ecac340972b 100644
--- a/Doc/library/difflib.rst
+++ b/Doc/library/difflib.rst
@@ -278,7 +278,7 @@ diffs. For comparing directories and files, see also, the 
:mod:`filecmp` module.
       emu
 
 
-.. function:: unified_diff(a, b, fromfile='', tofile='', fromfiledate='', 
tofiledate='', n=3, lineterm='\n')
+.. function:: unified_diff(a, b, fromfile='', tofile='', fromfiledate='', 
tofiledate='', n=3, lineterm='\n', *, color=False)
 
    Compare *a* and *b* (lists of strings); return a delta (a :term:`generator`
    generating the delta lines) in unified diff format.
@@ -297,6 +297,10 @@ diffs. For comparing directories and files, see also, the 
:mod:`filecmp` module.
    For inputs that do not have trailing newlines, set the *lineterm* argument 
to
    ``""`` so that the output will be uniformly newline free.
 
+   Set *color* to ``True`` to enable output in color, similar to
+   :program:`git diff --color`. Even if enabled, it can be
+   :ref:`controlled using environment variables <using-on-controlling-color>`.
+
    The unified diff format normally has a header for filenames and modification
    times.  Any or all of these may be specified using strings for *fromfile*,
    *tofile*, *fromfiledate*, and *tofiledate*.  The modification times are 
normally
@@ -319,6 +323,10 @@ diffs. For comparing directories and files, see also, the 
:mod:`filecmp` module.
 
    See :ref:`difflib-interface` for a more detailed example.
 
+   .. versionchanged:: next
+      Added the *color* parameter.
+
+
 .. function:: diff_bytes(dfunc, a, b, fromfile=b'', tofile=b'', 
fromfiledate=b'', tofiledate=b'', n=3, lineterm=b'\n')
 
    Compare *a* and *b* (lists of bytes objects) using *dfunc*; yield a
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 93f56eed857068..9f01b52f1aff3b 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -229,6 +229,14 @@ dbm
 difflib
 -------
 
+  .. _whatsnew315-color-difflib:
+
+* Introduced the optional *color* parameter to :func:`difflib.unified_diff`,
+  enabling color output similar to :program:`git diff`.
+  This can be controlled by :ref:`environment variables
+  <using-on-controlling-color>`.
+  (Contributed by Douglas Thor in :gh:`133725`.)
+
 * Improved the styling of HTML diff pages generated by the 
:class:`difflib.HtmlDiff`
   class, and migrated the output to the HTML5 standard.
   (Contributed by Jiahao Li in :gh:`134580`.)
diff --git a/Lib/_colorize.py b/Lib/_colorize.py
index 4a310a402358b6..325efed274aed7 100644
--- a/Lib/_colorize.py
+++ b/Lib/_colorize.py
@@ -172,7 +172,18 @@ class Argparse(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
+class Difflib(ThemeSection):
+    """A 'git diff'-like theme for `difflib.unified_diff`."""
+    added: str = ANSIColors.GREEN
+    context: str = ANSIColors.RESET  # context lines
+    header: str = ANSIColors.BOLD  # eg "---" and "+++" lines
+    hunk: str = ANSIColors.CYAN  # the "@@" lines
+    removed: str = ANSIColors.RED
+    reset: str = ANSIColors.RESET
+
+
+@dataclass(frozen=True, kw_only=True)
 class Syntax(ThemeSection):
     prompt: str = ANSIColors.BOLD_MAGENTA
     keyword: str = ANSIColors.BOLD_BLUE
@@ -186,7 +197,7 @@ class Syntax(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
 class Traceback(ThemeSection):
     type: str = ANSIColors.BOLD_MAGENTA
     message: str = ANSIColors.MAGENTA
@@ -198,7 +209,7 @@ class Traceback(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
 class Unittest(ThemeSection):
     passed: str = ANSIColors.GREEN
     warn: str = ANSIColors.YELLOW
@@ -207,7 +218,7 @@ class Unittest(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
 class Theme:
     """A suite of themes for all sections of Python.
 
@@ -215,6 +226,7 @@ class Theme:
     below.
     """
     argparse: Argparse = field(default_factory=Argparse)
+    difflib: Difflib = field(default_factory=Difflib)
     syntax: Syntax = field(default_factory=Syntax)
     traceback: Traceback = field(default_factory=Traceback)
     unittest: Unittest = field(default_factory=Unittest)
@@ -223,6 +235,7 @@ def copy_with(
         self,
         *,
         argparse: Argparse | None = None,
+        difflib: Difflib | None = None,
         syntax: Syntax | None = None,
         traceback: Traceback | None = None,
         unittest: Unittest | None = None,
@@ -234,6 +247,7 @@ def copy_with(
         """
         return type(self)(
             argparse=argparse or self.argparse,
+            difflib=difflib or self.difflib,
             syntax=syntax or self.syntax,
             traceback=traceback or self.traceback,
             unittest=unittest or self.unittest,
@@ -249,6 +263,7 @@ def no_colors(cls) -> Self:
         """
         return cls(
             argparse=Argparse.no_colors(),
+            difflib=Difflib.no_colors(),
             syntax=Syntax.no_colors(),
             traceback=Traceback.no_colors(),
             unittest=Unittest.no_colors(),
diff --git a/Lib/difflib.py b/Lib/difflib.py
index 487936dbf47cdc..fedc85009aa03b 100644
--- a/Lib/difflib.py
+++ b/Lib/difflib.py
@@ -30,6 +30,7 @@
            'Differ','IS_CHARACTER_JUNK', 'IS_LINE_JUNK', 'context_diff',
            'unified_diff', 'diff_bytes', 'HtmlDiff', 'Match']
 
+from _colorize import can_colorize, get_theme
 from heapq import nlargest as _nlargest
 from collections import namedtuple as _namedtuple
 from types import GenericAlias
@@ -1094,7 +1095,7 @@ def _format_range_unified(start, stop):
     return '{},{}'.format(beginning, length)
 
 def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
-                 tofiledate='', n=3, lineterm='\n'):
+                 tofiledate='', n=3, lineterm='\n', *, color=False):
     r"""
     Compare two sequences of lines; generate the delta as a unified diff.
 
@@ -1111,6 +1112,10 @@ def unified_diff(a, b, fromfile='', tofile='', 
fromfiledate='',
     For inputs that do not have trailing newlines, set the lineterm
     argument to "" so that the output will be uniformly newline free.
 
+    Set 'color' to True to enable output in color, similar to
+    'git diff --color'. Even if enabled, it can be
+    controlled using environment variables such as 'NO_COLOR'.
+
     The unidiff format normally has a header for filenames and modification
     times.  Any or all of these may be specified using strings for
     'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'.
@@ -1134,6 +1139,11 @@ def unified_diff(a, b, fromfile='', tofile='', 
fromfiledate='',
      four
     """
 
+    if color and can_colorize():
+        t = get_theme(force_color=True).difflib
+    else:
+        t = get_theme(force_no_color=True).difflib
+
     _check_types(a, b, fromfile, tofile, fromfiledate, tofiledate, lineterm)
     started = False
     for group in SequenceMatcher(None,a,b).get_grouped_opcodes(n):
@@ -1141,25 +1151,25 @@ def unified_diff(a, b, fromfile='', tofile='', 
fromfiledate='',
             started = True
             fromdate = '\t{}'.format(fromfiledate) if fromfiledate else ''
             todate = '\t{}'.format(tofiledate) if tofiledate else ''
-            yield '--- {}{}{}'.format(fromfile, fromdate, lineterm)
-            yield '+++ {}{}{}'.format(tofile, todate, lineterm)
+            yield f'{t.header}--- {fromfile}{fromdate}{lineterm}{t.reset}'
+            yield f'{t.header}+++ {tofile}{todate}{lineterm}{t.reset}'
 
         first, last = group[0], group[-1]
         file1_range = _format_range_unified(first[1], last[2])
         file2_range = _format_range_unified(first[3], last[4])
-        yield '@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm)
+        yield f'{t.hunk}@@ -{file1_range} +{file2_range} @@{lineterm}{t.reset}'
 
         for tag, i1, i2, j1, j2 in group:
             if tag == 'equal':
                 for line in a[i1:i2]:
-                    yield ' ' + line
+                    yield f'{t.context} {line}{t.reset}'
                 continue
             if tag in {'replace', 'delete'}:
                 for line in a[i1:i2]:
-                    yield '-' + line
+                    yield f'{t.removed}-{line}{t.reset}'
             if tag in {'replace', 'insert'}:
                 for line in b[j1:j2]:
-                    yield '+' + line
+                    yield f'{t.added}+{line}{t.reset}'
 
 
 ########################################################################
diff --git a/Lib/test/test_difflib.py b/Lib/test/test_difflib.py
index 6ac584a08d1e86..0eab3f523dc5fe 100644
--- a/Lib/test/test_difflib.py
+++ b/Lib/test/test_difflib.py
@@ -1,5 +1,5 @@
 import difflib
-from test.support import findfile
+from test.support import findfile, force_colorized
 import unittest
 import doctest
 import sys
@@ -355,6 +355,22 @@ def test_range_format_context(self):
         self.assertEqual(fmt(3,6), '4,6')
         self.assertEqual(fmt(0,0), '0')
 
+    @force_colorized
+    def test_unified_diff_colored_output(self):
+        args = [['one', 'three'], ['two', 'three'], 'Original', 'Current',
+            '2005-01-26 23:30:50', '2010-04-02 10:20:52']
+        actual = list(difflib.unified_diff(*args, lineterm='', color=True))
+
+        expect = [
+            "\033[1m--- Original\t2005-01-26 23:30:50\033[0m",
+            "\033[1m+++ Current\t2010-04-02 10:20:52\033[0m",
+             "\033[36m@@ -1,2 +1,2 @@\033[0m",
+             "\033[31m-one\033[0m",
+             "\033[32m+two\033[0m",
+             "\033[0m three\033[0m",
+        ]
+        self.assertEqual(expect, actual)
+
 
 class TestBytes(unittest.TestCase):
     # don't really care about the content of the output, just the fact
diff --git a/Misc/ACKS b/Misc/ACKS
index 745f472474cd9d..dc28ccf8f57eda 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1902,6 +1902,7 @@ Nicolas M. ThiƩry
 James Thomas
 Reuben Thomas
 Robin Thomas
+Douglas Thor
 Brian Thorne
 Christopher Thorne
 Stephen Thorne
diff --git 
a/Misc/NEWS.d/next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst 
b/Misc/NEWS.d/next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst
new file mode 100644
index 00000000000000..86f244412498c4
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst
@@ -0,0 +1,2 @@
+Added a *color* option to :func:`difflib.unified_diff` that colors output
+similar to :program:`git diff`.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: arch...@mail-archive.com

Reply via email to