Update difflib.py from 3.14.3

This commit is contained in:
ShaharNaveh authored and Jeong, YunWon committed 2026-02-08 20:36:35 +09:00
1 parent 32b57785c3
commit d54cf8f12e
3 files changed
+148 -93

No files matched your search

+94 -86
View File
@@ -78,8 +78,8 @@ class SequenceMatcher:
sequences. As a rule of thumb, a .ratio() value over 0.6 means the
sequences are close matches:
>>> print(round(s.ratio(), 3))
0.866
>>> print(round(s.ratio(), 2))
0.87
>>>
If you're only interested in where the sequences match,
@@ -908,87 +908,85 @@ class Differ:
+ abcdefGhijkl
? ^ ^ ^
"""
# don't synch up unless the lines have a similarity score of at
# least cutoff; best_ratio tracks the best score seen so far
best_ratio, cutoff = 0.74, 0.75
# Don't synch up unless the lines have a similarity score above
# cutoff. Previously only the smallest pair was handled here,
# and if there are many pairs with the best ratio, recursion
# could grow very deep, and runtime cubic. See:
# https://github.com/python/cpython/issues/119105
#
# Later, more pathological cases prompted removing recursion
# entirely.
cutoff = 0.74999
cruncher = SequenceMatcher(self.charjunk)
eqi, eqj = None, None # 1st indices of equal lines (if any)
crqr = cruncher.real_quick_ratio
cqr = cruncher.quick_ratio
cr = cruncher.ratio
# search for the pair that matches best without being identical
# (identical lines must be junk lines, & we don't want to synch up
# on junk -- unless we have to)
WINDOW = 10
best_i = best_j = None
dump_i, dump_j = alo, blo # smallest indices not yet resolved
for j in range(blo, bhi):
bj = b[j]
cruncher.set_seq2(bj)
for i in range(alo, ahi):
ai = a[i]
if ai == bj:
if eqi is None:
eqi, eqj = i, j
continue
cruncher.set_seq1(ai)
# computing similarity is expensive, so use the quick
# upper bounds first -- have seen this speed up messy
# compares by a factor of 3.
# note that ratio() is only expensive to compute the first
# time it's called on a sequence pair; the expensive part
# of the computation is cached by cruncher
if cruncher.real_quick_ratio() > best_ratio and \
cruncher.quick_ratio() > best_ratio and \
cruncher.ratio() > best_ratio:
best_ratio, best_i, best_j = cruncher.ratio(), i, j
if best_ratio < cutoff:
# no non-identical "pretty close" pair
if eqi is None:
# no identical pair either -- treat it as a straight replace
yield from self._plain_replace(a, alo, ahi, b, blo, bhi)
return
# no close pair, but an identical pair -- synch up on that
best_i, best_j, best_ratio = eqi, eqj, 1.0
else:
# there's a close pair, so forget the identical pair (if any)
eqi = None
cruncher.set_seq2(b[j])
# Search the corresponding i's within WINDOW for rhe highest
# ratio greater than `cutoff`.
aequiv = alo + (j - blo)
arange = range(max(aequiv - WINDOW, dump_i),
min(aequiv + WINDOW + 1, ahi))
if not arange: # likely exit if `a` is shorter than `b`
break
best_ratio = cutoff
for i in arange:
cruncher.set_seq1(a[i])
# Ordering by cheapest to most expensive ratio is very
# valuable, most often getting out early.
if (crqr() > best_ratio
and cqr() > best_ratio
and cr() > best_ratio):
best_i, best_j, best_ratio = i, j, cr()
# a[best_i] very similar to b[best_j]; eqi is None iff they're not
# identical
if best_i is None:
# found nothing to synch on yet - move to next j
continue
# pump out diffs from before the synch point
yield from self._fancy_helper(a, alo, best_i, b, blo, best_j)
# pump out straight replace from before this synch pair
yield from self._fancy_helper(a, dump_i, best_i,
b, dump_j, best_j)
# do intraline marking on the synch pair
aelt, belt = a[best_i], b[best_j]
if aelt != belt:
# pump out a '-', '?', '+', '?' quad for the synched lines
atags = btags = ""
cruncher.set_seqs(aelt, belt)
for tag, ai1, ai2, bj1, bj2 in cruncher.get_opcodes():
la, lb = ai2 - ai1, bj2 - bj1
if tag == 'replace':
atags += '^' * la
btags += '^' * lb
elif tag == 'delete':
atags += '-' * la
elif tag == 'insert':
btags += '+' * lb
elif tag == 'equal':
atags += ' ' * la
btags += ' ' * lb
else:
raise ValueError('unknown tag %r' % (tag,))
yield from self._qformat(aelt, belt, atags, btags)
else:
# the synch pair is identical
yield ' ' + aelt
dump_i, dump_j = best_i + 1, best_j + 1
best_i = best_j = None
# do intraline marking on the synch pair
aelt, belt = a[best_i], b[best_j]
if eqi is None:
# pump out a '-', '?', '+', '?' quad for the synched lines
atags = btags = ""
cruncher.set_seqs(aelt, belt)
for tag, ai1, ai2, bj1, bj2 in cruncher.get_opcodes():
la, lb = ai2 - ai1, bj2 - bj1
if tag == 'replace':
atags += '^' * la
btags += '^' * lb
elif tag == 'delete':
atags += '-' * la
elif tag == 'insert':
btags += '+' * lb
elif tag == 'equal':
atags += ' ' * la
btags += ' ' * lb
else:
raise ValueError('unknown tag %r' % (tag,))
yield from self._qformat(aelt, belt, atags, btags)
else:
# the synch pair is identical
yield ' ' + aelt
# pump out diffs from after the synch point
yield from self._fancy_helper(a, best_i+1, ahi, b, best_j+1, bhi)
# pump out straight replace from after the last synch pair
yield from self._fancy_helper(a, dump_i, ahi,
b, dump_j, bhi)
def _fancy_helper(self, a, alo, ahi, b, blo, bhi):
g = []
if alo < ahi:
if blo < bhi:
g = self._fancy_replace(a, alo, ahi, b, blo, bhi)
g = self._plain_replace(a, alo, ahi, b, blo, bhi)
else:
g = self._dump('-', a, alo, ahi)
elif blo < bhi:
@@ -1040,11 +1038,9 @@ class Differ:
# remaining is that perhaps it was really the case that " volatile"
# was inserted after "private". I can live with that <wink>.
import re
def IS_LINE_JUNK(line, pat=re.compile(r"\s*(?:#\s*)?$").match):
def IS_LINE_JUNK(line, pat=None):
r"""
Return True for ignorable line: iff `line` is blank or contains a single '#'.
Return True for ignorable line: if `line` is blank or contains a single '#'.
Examples:
@@ -1056,6 +1052,11 @@ def IS_LINE_JUNK(line, pat=re.compile(r"\s*(?:#\s*)?$").match):
False
"""
if pat is None:
# Default: match '#' or the empty string
return line.strip() in '#'
# Previous versions used the undocumented parameter 'pat' as a
# match function. Retain this behaviour for compatibility.
return pat(line) is not None
def IS_CHARACTER_JUNK(ch, ws=" \t"):
@@ -1266,6 +1267,12 @@ def _check_types(a, b, *args):
if b and not isinstance(b[0], str):
raise TypeError('lines to compare must be str, not %s (%r)' %
(type(b[0]).__name__, b[0]))
if isinstance(a, str):
raise TypeError('input must be a sequence of strings, not %s' %
type(a).__name__)
if isinstance(b, str):
raise TypeError('input must be a sequence of strings, not %s' %
type(b).__name__)
for arg in args:
if not isinstance(arg, str):
raise TypeError('all arguments must be str, not: %r' % (arg,))
@@ -1628,13 +1635,22 @@ _file_template = """
</html>"""
_styles = """
:root {color-scheme: light dark}
table.diff {font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; border:medium}
.diff_header {background-color:#e0e0e0}
td.diff_header {text-align:right}
.diff_next {background-color:#c0c0c0}
.diff_add {background-color:#aaffaa}
.diff_add {background-color:palegreen}
.diff_chg {background-color:#ffff77}
.diff_sub {background-color:#ffaaaa}"""
.diff_sub {background-color:#ffaaaa}
@media (prefers-color-scheme: dark) {
.diff_header {background-color:#666}
.diff_next {background-color:#393939}
.diff_add {background-color:darkgreen}
.diff_chg {background-color:#847415}
.diff_sub {background-color:darkred}
}"""
_table_template = """
<table class="diff" id="difflib_chg_%(prefix)s_top"
@@ -2014,7 +2030,6 @@ class HtmlDiff(object):
replace('\1','</span>'). \
replace('\t','&nbsp;')
del re
def restore(delta, which):
r"""
@@ -2047,10 +2062,3 @@ def restore(delta, which):
for line in delta:
if line[:2] in prefixes:
yield line[2:]
def _test():
import doctest, difflib
return doctest.testmod(difflib)
if __name__ == "__main__":
_test()
+44 -6
View File
@@ -282,6 +282,26 @@ class TestSFpatches(unittest.TestCase):
self.assertIn('content="text/html; charset=us-ascii"', output)
self.assertIn('&#305;mpl&#305;c&#305;t', output)
class TestDiffer(unittest.TestCase):
def test_close_matches_aligned(self):
# Of the 4 closely matching pairs, we want 1 to match with 3,
# and 2 with 4, to align with a "top to bottom" mental model.
a = ["cat\n", "dog\n", "close match 1\n", "close match 2\n"]
b = ["close match 3\n", "close match 4\n", "kitten\n", "puppy\n"]
m = difflib.Differ().compare(a, b)
self.assertEqual(list(m),
['- cat\n',
'- dog\n',
'- close match 1\n',
'? ^\n',
'+ close match 3\n',
'? ^\n',
'- close match 2\n',
'? ^\n',
'+ close match 4\n',
'? ^\n',
'+ kitten\n',
'+ puppy\n'])
def test_one_insert(self):
m = difflib.Differ().compare('b' * 2, 'a' + 'b' * 2)
@@ -294,7 +314,7 @@ class TestSFpatches(unittest.TestCase):
class TestOutputFormat(unittest.TestCase):
def test_tab_delimiter(self):
args = ['one', 'two', 'Original', 'Current',
args = [['one'], ['two'], 'Original', 'Current',
'2005-01-26 23:30:50', '2010-04-02 10:20:52']
ud = difflib.unified_diff(*args, lineterm='')
self.assertEqual(list(ud)[0:2], [
@@ -306,7 +326,7 @@ class TestOutputFormat(unittest.TestCase):
"--- Current\t2010-04-02 10:20:52"])
def test_no_trailing_tab_on_empty_filedate(self):
args = ['one', 'two', 'Original', 'Current']
args = [['one'], ['two'], 'Original', 'Current']
ud = difflib.unified_diff(*args, lineterm='')
self.assertEqual(list(ud)[0:2], ["--- Original", "+++ Current"])
@@ -446,6 +466,28 @@ class TestBytes(unittest.TestCase):
lineterm=b'')
assertDiff(expect, actual)
class TestInputTypes(unittest.TestCase):
def _assert_type_error(self, msg, generator, *args):
with self.assertRaises(TypeError) as ctx:
list(generator(*args))
self.assertEqual(msg, str(ctx.exception))
def test_input_type_checks(self):
unified = difflib.unified_diff
context = difflib.context_diff
expect = "input must be a sequence of strings, not str"
self._assert_type_error(expect, unified, 'a', ['b'])
self._assert_type_error(expect, context, 'a', ['b'])
self._assert_type_error(expect, unified, ['a'], 'b')
self._assert_type_error(expect, context, ['a'], 'b')
expect = "lines to compare must be str, not NoneType (None)"
self._assert_type_error(expect, unified, ['a'], [None])
self._assert_type_error(expect, context, ['a'], [None])
def test_mixed_types_content(self):
# type of input content must be consistent: all str or all bytes
a = [b'hello']
@@ -494,10 +536,6 @@ class TestBytes(unittest.TestCase):
b = ['bar\n']
list(difflib.unified_diff(a, b, 'a', 'b', datea, dateb))
def _assert_type_error(self, msg, generator, *args):
with self.assertRaises(TypeError) as ctx:
list(generator(*args))
self.assertEqual(msg, str(ctx.exception))
class TestJunkAPIs(unittest.TestCase):
def test_is_line_junk_true(self):
+10 -1
View File
@@ -9,13 +9,22 @@
content="text/html; charset=utf-8" />
<title></title>
<style type="text/css">
:root {color-scheme: light dark}
table.diff {font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; border:medium}
.diff_header {background-color:#e0e0e0}
td.diff_header {text-align:right}
.diff_next {background-color:#c0c0c0}
.diff_add {background-color:#aaffaa}
.diff_add {background-color:palegreen}
.diff_chg {background-color:#ffff77}
.diff_sub {background-color:#ffaaaa}
@media (prefers-color-scheme: dark) {
.diff_header {background-color:#666}
.diff_next {background-color:#393939}
.diff_add {background-color:darkgreen}
.diff_chg {background-color:#847415}
.diff_sub {background-color:darkred}
}
</style>
</head>