From 5c5b248402637b703c8272cd0a81b2f79d47383e Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Thu, 8 May 2025 19:51:57 -0700 Subject: [PATCH 01/21] Add test case --- Lib/test/test_difflib.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Lib/test/test_difflib.py b/Lib/test/test_difflib.py index 9e217249be7332..56d6702b8e8fb6 100644 --- a/Lib/test/test_difflib.py +++ b/Lib/test/test_difflib.py @@ -355,6 +355,21 @@ def test_range_format_context(self): self.assertEqual(fmt(3,6), '4,6') self.assertEqual(fmt(0,0), '0') + 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[m", + "\033[1m+++ Current\t2010-04-02 10:20:52\033[m", + "\033[36m@@ -1,2 +1,2 @@\033[m", + "\033[31m-one\033[m", + "\033[32m+two\033[m", + " three", + ] + self.assertEqual(expect, actual) + class TestBytes(unittest.TestCase): # don't really care about the content of the output, just the fact From fdc0fa0d90ac0427456b987571745c8beed3b38c Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Thu, 8 May 2025 20:00:28 -0700 Subject: [PATCH 02/21] Add 'color' arg to difflib.unified_diff. Fixes gh-133722. --- Lib/difflib.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Lib/difflib.py b/Lib/difflib.py index f1f4e62514a7bd..e2fcc7bfbe69a2 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1094,7 +1094,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 +1111,9 @@ 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 inject ANSI color codes and make the output look + like what `git diff --color` shows. + 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 +1137,15 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', four """ + # {tag: ANSI color escape code} + colors = { + "delete": "\033[31m", # red + "insert": "\033[32m", # green + "header": "\033[1m", # bold / increased intensity + "hunk": "\033[36m", # cyan + } + reset = "\033[m" + _check_types(a, b, fromfile, tofile, fromfiledate, tofiledate, lineterm) started = False for group in SequenceMatcher(None,a,b).get_grouped_opcodes(n): @@ -1141,13 +1153,18 @@ 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) + _line = '--- {}{}{}'.format(fromfile, fromdate, lineterm) + yield colors["header"] + _line + reset if color else _line + _line = '+++ {}{}{}'.format(tofile, todate, lineterm) + yield colors["header"] + _line + reset if color else _line 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) + _line = '@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm) + if color: + _line = colors["hunk"] + _line + reset + yield _line for tag, i1, i2, j1, j2 in group: if tag == 'equal': @@ -1156,10 +1173,12 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', continue if tag in {'replace', 'delete'}: for line in a[i1:i2]: - yield '-' + line + _line = '-' + line + yield colors["delete"] + _line + reset if color else _line if tag in {'replace', 'insert'}: for line in b[j1:j2]: - yield '+' + line + _line = '+' + line + yield colors["insert"] + _line + reset if color else _line ######################################################################## From fcdd7ab557c7a73d199f8d471879265d28aa15ea Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Thu, 8 May 2025 20:10:20 -0700 Subject: [PATCH 03/21] Update docs and ACKs --- Doc/library/difflib.rst | 9 ++++++++- Misc/ACKS | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst index ce948a6860f02c..85fdaa0292d278 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,9 @@ 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 inject ANSI color codes and make the output look + like what ``git diff --color`` shows. + 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 +322,10 @@ diffs. For comparing directories and files, see also, the :mod:`filecmp` module. See :ref:`difflib-interface` for a more detailed example. + .. versionchanged:: 3.15 + 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/Misc/ACKS b/Misc/ACKS index 610dcf9f4238de..7f26e051a19f36 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1889,6 +1889,7 @@ Nicolas M. ThiƩry James Thomas Reuben Thomas Robin Thomas +Douglas Thor Brian Thorne Christopher Thorne Stephen Thorne From 0e9b0706d5f2a7ecb392613fb264235edae2f932 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Thu, 8 May 2025 20:12:04 -0700 Subject: [PATCH 04/21] blurb --- .../next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst 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..a6228918b8cf56 --- /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 injects ANSI color +codes to mimic ``git diff`` colors. From 7c3174974282c69e07493955be3f78d577e97d13 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Thu, 8 May 2025 20:32:44 -0700 Subject: [PATCH 05/21] fixup to follow convention --- Lib/difflib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/difflib.py b/Lib/difflib.py index e2fcc7bfbe69a2..b0903250651ace 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1162,9 +1162,7 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', file1_range = _format_range_unified(first[1], last[2]) file2_range = _format_range_unified(first[3], last[4]) _line = '@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm) - if color: - _line = colors["hunk"] + _line + reset - yield _line + yield colors["hunk"] + _line + reset if color else _line for tag, i1, i2, j1, j2 in group: if tag == 'equal': From 66475a2c9fd65aa5ad3f24fd72053f93eb861425 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Sat, 10 May 2025 20:51:13 -0700 Subject: [PATCH 06/21] Add 'Difflib' theme --- Lib/_colorize.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 4a310a402358b6..b1e2753448cc11 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -207,6 +207,16 @@ class Unittest(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True) +class Difflib(ThemeSection): + """A 'git diff'-like theme for `difflib.unified_diff`.""" + header: str = ANSIColors.BOLD # eg "---" and "+++" lines + hunk: str = ANSIColors.CYAN # the "@@" lines + insert: str = ANSIColors.GREEN + delete: str = ANSIColors.RED + reset: str = ANSIColors.RESET + + @dataclass(frozen=True) class Theme: """A suite of themes for all sections of Python. @@ -218,6 +228,7 @@ class Theme: syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) + difflib: Difflib = field(default_factory=Difflib) def copy_with( self, @@ -226,6 +237,7 @@ def copy_with( syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, + difflib: Difflib | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -237,6 +249,7 @@ def copy_with( syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, + difflib=difflib or self.difflib, ) @classmethod @@ -252,6 +265,7 @@ def no_colors(cls) -> Self: syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), + difflib=Difflib.no_colors(), ) From dbf0547885e992d4ea2044fef2759f624dc94201 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Sat, 10 May 2025 21:02:24 -0700 Subject: [PATCH 07/21] fixup tests --- Lib/test/test_difflib.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_difflib.py b/Lib/test/test_difflib.py index 56d6702b8e8fb6..9f0428067cce55 100644 --- a/Lib/test/test_difflib.py +++ b/Lib/test/test_difflib.py @@ -361,11 +361,11 @@ def test_unified_diff_colored_output(self): actual = list(difflib.unified_diff(*args, lineterm='', color=True)) expect = [ - "\033[1m--- Original\t2005-01-26 23:30:50\033[m", - "\033[1m+++ Current\t2010-04-02 10:20:52\033[m", - "\033[36m@@ -1,2 +1,2 @@\033[m", - "\033[31m-one\033[m", - "\033[32m+two\033[m", + "\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", " three", ] self.assertEqual(expect, actual) From a72012e17f7da6de0156a9f684963b2623c99edf Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Sat, 10 May 2025 21:02:46 -0700 Subject: [PATCH 08/21] Switch to using themes. So easy! --- Lib/difflib.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/Lib/difflib.py b/Lib/difflib.py index b0903250651ace..30a5f128f0078c 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 @@ -1137,14 +1138,10 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', four """ - # {tag: ANSI color escape code} - colors = { - "delete": "\033[31m", # red - "insert": "\033[32m", # green - "header": "\033[1m", # bold / increased intensity - "hunk": "\033[36m", # cyan - } - reset = "\033[m" + 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 @@ -1153,16 +1150,13 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', started = True fromdate = '\t{}'.format(fromfiledate) if fromfiledate else '' todate = '\t{}'.format(tofiledate) if tofiledate else '' - _line = '--- {}{}{}'.format(fromfile, fromdate, lineterm) - yield colors["header"] + _line + reset if color else _line - _line = '+++ {}{}{}'.format(tofile, todate, lineterm) - yield colors["header"] + _line + reset if color else _line + yield '{}--- {}{}{}{}'.format(t.header, fromfile, fromdate, lineterm, t.reset) + yield '{}+++ {}{}{}{}'.format(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]) - _line = '@@ -{} +{} @@{}'.format(file1_range, file2_range, lineterm) - yield colors["hunk"] + _line + reset if color else _line + yield '{}@@ -{} +{} @@{}{}'.format(t.hunk, file1_range, file2_range, lineterm, t.reset) for tag, i1, i2, j1, j2 in group: if tag == 'equal': @@ -1171,12 +1165,10 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', continue if tag in {'replace', 'delete'}: for line in a[i1:i2]: - _line = '-' + line - yield colors["delete"] + _line + reset if color else _line + yield f'{t.delete}-{line}{t.reset}' if tag in {'replace', 'insert'}: for line in b[j1:j2]: - _line = '+' + line - yield colors["insert"] + _line + reset if color else _line + yield f'{t.insert}+{line}{t.reset}' ######################################################################## From 2a3d818609974b374c617759ee45ecaac2e2e799 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Sat, 10 May 2025 21:17:37 -0700 Subject: [PATCH 09/21] use 'next' in versionchanged docs --- Doc/library/difflib.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst index 85fdaa0292d278..a870c45d8ad5e5 100644 --- a/Doc/library/difflib.rst +++ b/Doc/library/difflib.rst @@ -322,7 +322,7 @@ diffs. For comparing directories and files, see also, the :mod:`filecmp` module. See :ref:`difflib-interface` for a more detailed example. - .. versionchanged:: 3.15 + .. versionchanged:: next Added the *color* parameter. From 252982e3877c971bf5557ab382710cffbff121f3 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Sat, 10 May 2025 21:34:39 -0700 Subject: [PATCH 10/21] turns out 'git diff' adds reset to the start and end of context lines --- Lib/_colorize.py | 1 + Lib/difflib.py | 2 +- Lib/test/test_difflib.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index b1e2753448cc11..82e5cad72c0296 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -212,6 +212,7 @@ class Difflib(ThemeSection): """A 'git diff'-like theme for `difflib.unified_diff`.""" header: str = ANSIColors.BOLD # eg "---" and "+++" lines hunk: str = ANSIColors.CYAN # the "@@" lines + equal: str = ANSIColors.RESET # context lines insert: str = ANSIColors.GREEN delete: str = ANSIColors.RED reset: str = ANSIColors.RESET diff --git a/Lib/difflib.py b/Lib/difflib.py index 30a5f128f0078c..3746eaead45f02 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1161,7 +1161,7 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', for tag, i1, i2, j1, j2 in group: if tag == 'equal': for line in a[i1:i2]: - yield ' ' + line + yield f'{t.equal} {line}{t.reset}' continue if tag in {'replace', 'delete'}: for line in a[i1:i2]: diff --git a/Lib/test/test_difflib.py b/Lib/test/test_difflib.py index 9f0428067cce55..a6d4425120c274 100644 --- a/Lib/test/test_difflib.py +++ b/Lib/test/test_difflib.py @@ -366,7 +366,7 @@ def test_unified_diff_colored_output(self): "\033[36m@@ -1,2 +1,2 @@\033[0m", "\033[31m-one\033[0m", "\033[32m+two\033[0m", - " three", + "\033[0m three\033[0m", ] self.assertEqual(expect, actual) From 3422fa7ade56e41981f325b0859faf7b661557ca Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:11:16 -0700 Subject: [PATCH 11/21] Use GNU unified diff terms --- Lib/_colorize.py | 6 +++--- Lib/difflib.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 82e5cad72c0296..abd108749ef8f8 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -212,9 +212,9 @@ class Difflib(ThemeSection): """A 'git diff'-like theme for `difflib.unified_diff`.""" header: str = ANSIColors.BOLD # eg "---" and "+++" lines hunk: str = ANSIColors.CYAN # the "@@" lines - equal: str = ANSIColors.RESET # context lines - insert: str = ANSIColors.GREEN - delete: str = ANSIColors.RED + context: str = ANSIColors.RESET # context lines + added: str = ANSIColors.GREEN + removed: str = ANSIColors.RED reset: str = ANSIColors.RESET diff --git a/Lib/difflib.py b/Lib/difflib.py index 3746eaead45f02..eb422a6425dd6a 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1161,14 +1161,14 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='', for tag, i1, i2, j1, j2 in group: if tag == 'equal': for line in a[i1:i2]: - yield f'{t.equal} {line}{t.reset}' + yield f'{t.context} {line}{t.reset}' continue if tag in {'replace', 'delete'}: for line in a[i1:i2]: - yield f'{t.delete}-{line}{t.reset}' + yield f'{t.removed}-{line}{t.reset}' if tag in {'replace', 'insert'}: for line in b[j1:j2]: - yield f'{t.insert}+{line}{t.reset}' + yield f'{t.added}+{line}{t.reset}' ######################################################################## From bffdd718d1948fe62273018e9c99f42889a75d5e Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:13:12 -0700 Subject: [PATCH 12/21] move class --- Lib/_colorize.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index abd108749ef8f8..245e31ca3154fe 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -172,6 +172,17 @@ class Argparse(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True) +class Difflib(ThemeSection): + """A 'git diff'-like theme for `difflib.unified_diff`.""" + header: str = ANSIColors.BOLD # eg "---" and "+++" lines + hunk: str = ANSIColors.CYAN # the "@@" lines + context: str = ANSIColors.RESET # context lines + added: str = ANSIColors.GREEN + removed: str = ANSIColors.RED + reset: str = ANSIColors.RESET + + @dataclass(frozen=True) class Syntax(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA @@ -207,17 +218,6 @@ class Unittest(ThemeSection): reset: str = ANSIColors.RESET -@dataclass(frozen=True) -class Difflib(ThemeSection): - """A 'git diff'-like theme for `difflib.unified_diff`.""" - header: str = ANSIColors.BOLD # eg "---" and "+++" lines - hunk: str = ANSIColors.CYAN # the "@@" lines - context: str = ANSIColors.RESET # context lines - added: str = ANSIColors.GREEN - removed: str = ANSIColors.RED - reset: str = ANSIColors.RESET - - @dataclass(frozen=True) class Theme: """A suite of themes for all sections of Python. From 325586685c85bd492b92e4c2946bcc515cc91d34 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:16:29 -0700 Subject: [PATCH 13/21] kw-only the 'color' arg --- Lib/difflib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/difflib.py b/Lib/difflib.py index eb422a6425dd6a..97cffcb4510d3b 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1095,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', color=False): + tofiledate='', n=3, lineterm='\n', *, color=False): r""" Compare two sequences of lines; generate the delta as a unified diff. From c48a6ac443b7713e249e43723f47f6af90328cfc Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:19:41 -0700 Subject: [PATCH 14/21] Doc formatting updates --- Doc/library/difflib.rst | 4 ++-- Lib/difflib.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst index a870c45d8ad5e5..d32f5de44ece26 100644 --- a/Doc/library/difflib.rst +++ b/Doc/library/difflib.rst @@ -297,8 +297,8 @@ 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 inject ANSI color codes and make the output look - like what ``git diff --color`` shows. + Set *color* to ``True`` to inject ANSI color codes and make the output + look like what ``git diff --color`` shows. 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*, diff --git a/Lib/difflib.py b/Lib/difflib.py index 97cffcb4510d3b..f5fbf830628033 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1112,8 +1112,8 @@ 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 inject ANSI color codes and make the output look - like what `git diff --color` shows. + Set *color* to ``True`` to inject ANSI color codes and make the output + look like what ``git diff --color`` shows. The unidiff format normally has a header for filenames and modification times. Any or all of these may be specified using strings for From 8ca50fa22b0d24ffa9a91ffe5759670ea05144bd Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:21:05 -0700 Subject: [PATCH 15/21] Sort the things that are safe to sort without kw_only=True --- Lib/_colorize.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 245e31ca3154fe..fb335b8e067483 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -175,10 +175,10 @@ class Argparse(ThemeSection): @dataclass(frozen=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 - context: str = ANSIColors.RESET # context lines - added: str = ANSIColors.GREEN removed: str = ANSIColors.RED reset: str = ANSIColors.RESET @@ -235,10 +235,10 @@ def copy_with( self, *, argparse: Argparse | None = None, + difflib: Difflib | None = None, syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, - difflib: Difflib | None = None, ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -247,10 +247,10 @@ 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, - difflib=difflib or self.difflib, ) @classmethod @@ -263,10 +263,10 @@ 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(), - difflib=Difflib.no_colors(), ) From eb0e81e6dfd92f4e43c74c0173df5d2e7958cfcb Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:36:27 -0700 Subject: [PATCH 16/21] Update what's new --- Doc/whatsnew/3.15.rst | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 7131eeb697eb69..3517f3406dcac9 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -65,6 +65,7 @@ Summary --- release highlights .. PEP-sized items next. +* :ref:`argparse `, New features @@ -86,10 +87,17 @@ New modules Improved modules ================ -module_name ------------ +difflib +------- + + .. _whatsnew314-color-argparse: + +* Introduced the optional *color* parameter to :func:`difflib.unified_diff`, + enabling colored output similar to what ``git diff`` displays. + This can be controlled by :ref:`environment variables + `. + (Contributed by Douglas Thor in :gh:`133725`.) -* TODO .. Add improved modules above alphabetically, not here at the end. From fb092b03d3b75a0948c76077896d0fefdf565080 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Tue, 13 May 2025 20:58:24 -0700 Subject: [PATCH 17/21] fixup docs --- Doc/library/difflib.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst index d32f5de44ece26..e49e4f2c7721a5 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', color=False) +.. 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. From 734b0bc0ba1f7a1ba155559c21ef16ef85312763 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Mon, 19 May 2025 21:04:44 -0700 Subject: [PATCH 18/21] fixup docs --- Doc/library/difflib.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Doc/library/difflib.rst b/Doc/library/difflib.rst index e49e4f2c7721a5..0250ffd9f799d3 100644 --- a/Doc/library/difflib.rst +++ b/Doc/library/difflib.rst @@ -297,8 +297,9 @@ 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 inject ANSI color codes and make the output - look like what ``git diff --color`` shows. + 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 `. 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*, From 8a10e4057eac8ebe526724685328f2fb855bbf64 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Mon, 19 May 2025 21:05:26 -0700 Subject: [PATCH 19/21] Code review: docs, whatsnew, f-strings, news --- Doc/whatsnew/3.15.rst | 3 +-- Lib/difflib.py | 11 ++++++----- .../2025-05-08-20-03-20.gh-issue-133722.1-B82a.rst | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index cc49aca8aa2d8d..1ddb3ea2d9cebf 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -65,7 +65,6 @@ Summary --- release highlights .. PEP-sized items next. -* :ref:`argparse `, New features @@ -96,7 +95,7 @@ difflib .. _whatsnew315-color-difflib: * Introduced the optional *color* parameter to :func:`difflib.unified_diff`, - enabling colored output similar to what ``git diff`` displays. + enabling color output similar to :program:`git diff`. This can be controlled by :ref:`environment variables `. (Contributed by Douglas Thor in :gh:`133725`.) diff --git a/Lib/difflib.py b/Lib/difflib.py index f5fbf830628033..6a38104d154a0c 100644 --- a/Lib/difflib.py +++ b/Lib/difflib.py @@ -1112,8 +1112,9 @@ 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 inject ANSI color codes and make the output - look like what ``git diff --color`` shows. + 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 `. The unidiff format normally has a header for filenames and modification times. Any or all of these may be specified using strings for @@ -1150,13 +1151,13 @@ 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(t.header, fromfile, fromdate, lineterm, t.reset) - yield '{}+++ {}{}{}{}'.format(t.header, tofile, todate, lineterm, t.reset) + 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(t.hunk, file1_range, file2_range, lineterm, t.reset) + yield f'{t.hunk}@@ -{file1_range} +{file2_range} @@{lineterm}{t.reset}' for tag, i1, i2, j1, j2 in group: if tag == 'equal': 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 index a6228918b8cf56..86f244412498c4 100644 --- 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 @@ -1,2 +1,2 @@ -Added a ``color`` option to :func:`difflib.unified_diff` that injects ANSI color -codes to mimic ``git diff`` colors. +Added a *color* option to :func:`difflib.unified_diff` that colors output +similar to :program:`git diff`. From 57b80d1e15074b511f2bcaae05aff80a574b06d6 Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Mon, 19 May 2025 21:27:55 -0700 Subject: [PATCH 20/21] force_colorized --- Lib/test/test_difflib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_difflib.py b/Lib/test/test_difflib.py index a6d4425120c274..4371536f614079 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,7 @@ 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'] From c235425563a6624c76f4ef95ab7c2e9c9629230f Mon Sep 17 00:00:00 2001 From: Douglas Thor Date: Wed, 28 May 2025 21:56:38 -0700 Subject: [PATCH 21/21] kw_only --- Lib/_colorize.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index fb335b8e067483..325efed274aed7 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -172,7 +172,7 @@ 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 @@ -183,7 +183,7 @@ class Difflib(ThemeSection): reset: str = ANSIColors.RESET -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class Syntax(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA keyword: str = ANSIColors.BOLD_BLUE @@ -197,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 @@ -209,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 @@ -218,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. @@ -226,10 +226,10 @@ 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) - difflib: Difflib = field(default_factory=Difflib) def copy_with( self,