Skip to content

Commit a0eb08f

Browse files
Report every copy of a duplicated block in duplicate-code (#11680)
``Symilar._compute_sims`` discarded a pair whenever one of its chunks was already in a group, so the third and later copies of a block were never reported. Pairs that share a chunk with existing groups now merge into them. Because a merged group can hold several chunks of one file, the message counts distinct files. Closes #11679 Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
1 parent 7f88354 commit a0eb08f

4 files changed

Lines changed: 73 additions & 20 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
``duplicate-code`` now reports every copy of a duplicated block instead of only the
2+
first two. Each message lists all the copies, so there are fewer messages and the
3+
number of duplicated lines in the statistics goes up. The message counts distinct
4+
files, so a group can list several locations in one file.
5+
6+
Closes #11679

‎pylint/checkers/symilar.py‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ def hash_lineset(
230230
return LineSetHashResult(hash_to_index, index_to_lines)
231231

232232

233+
def _count_files(couples: set[LinesChunkLimits_T]) -> int:
234+
"""Count the files in a group, which can hold several chunks of one file."""
235+
return len({lineset for lineset, _, _ in couples})
236+
237+
233238
def remove_successive(all_couples: CplIndexToCplLines_T) -> None:
234239
"""Removes all successive entries in the dictionary in argument.
235240
@@ -398,22 +403,19 @@ def _compute_sims(self) -> list[tuple[int, set[LinesChunkLimits_T]]]:
398403
start_line_2 = commonality.snd_file_start
399404
end_line_2 = commonality.snd_file_end
400405

401-
duplicate = no_duplicates[num]
402-
couples: set[LinesChunkLimits_T]
403-
for couples in duplicate:
404-
if (lineset1, start_line_1, end_line_1) in couples or (
405-
lineset2,
406-
start_line_2,
407-
end_line_2,
408-
) in couples:
409-
break
410-
else:
411-
duplicate.append(
412-
{
413-
(lineset1, start_line_1, end_line_1),
414-
(lineset2, start_line_2, end_line_2),
415-
}
416-
)
406+
chunk_1 = (lineset1, start_line_1, end_line_1)
407+
chunk_2 = (lineset2, start_line_2, end_line_2)
408+
# A pair that shares a chunk with existing groups joins them, so a
409+
# block copied into three files is one group of three, not one pair.
410+
merged = {chunk_1, chunk_2}
411+
kept: list[set[LinesChunkLimits_T]] = []
412+
for couples in no_duplicates[num]:
413+
if chunk_1 in couples or chunk_2 in couples:
414+
merged |= couples
415+
else:
416+
kept.append(couples)
417+
kept.append(merged)
418+
no_duplicates[num] = kept
417419
sims: list[tuple[int, set[LinesChunkLimits_T]]] = []
418420
ensembles: list[set[LinesChunkLimits_T]]
419421
for num, ensembles in no_duplicates.items():
@@ -436,7 +438,7 @@ def _get_similarity_report(
436438
report: str = ""
437439
duplicated_line_number: int = 0
438440
for number, couples in similarities:
439-
report += f"\n{number} similar lines in {len(couples)} files\n"
441+
report += f"\n{number} similar lines in {_count_files(couples)} files\n"
440442
couples_l = sorted(couples)
441443
line_set = start_line = end_line = None
442444
for line_set, start_line, end_line in couples_l:
@@ -866,7 +868,7 @@ def close(self) -> None:
866868
for line in lineset.real_lines[start_line:end_line]:
867869
msg.append(line.rstrip())
868870

869-
self.add_message("R0801", args=(len(couples), "\n".join(msg)))
871+
self.add_message("R0801", args=(_count_files(couples), "\n".join(msg)))
870872
duplicated += num * (len(couples) - 1)
871873
stats.nb_duplicated_lines += int(duplicated)
872874
stats.percent_duplicated_lines += float(total and duplicated * 100.0 / total)

‎tests/checkers/unittest_symilar.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
33
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
44

5+
from collections.abc import Generator
56
from contextlib import redirect_stdout
67
from io import BytesIO, StringIO
78
from pathlib import Path
@@ -574,3 +575,47 @@ def test_hash_bucket_product_limit_fallback(
574575
assert "15 similar lines in 2 files" in out
575576
assert f"=={file_a}:[0:15]" in out
576577
assert f"=={file_b}:[0:15]" in out
578+
579+
580+
def test_three_identical_files_are_reported_together(tmp_path: Path) -> None:
581+
block = "def f(a):\n x = a + 1\n y = x * 2\n z = y - 3\n w = z / 4\n return w\n"
582+
paths = []
583+
for name in ("a.py", "b.py", "c.py"):
584+
path = tmp_path / name
585+
path.write_text(block, encoding="utf-8")
586+
paths.append(str(path))
587+
output = StringIO()
588+
with redirect_stdout(output), pytest.raises(SystemExit) as ex:
589+
symilar.Run(paths)
590+
assert ex.value.code == 0
591+
out = output.getvalue()
592+
assert "6 similar lines in 3 files" in out
593+
for path in paths:
594+
assert f"=={path}:[0:6]" in out
595+
assert "duplicates=12" in out
596+
597+
598+
def test_compute_sims_merges_groups_joined_by_a_later_pair(
599+
monkeypatch: pytest.MonkeyPatch,
600+
) -> None:
601+
"""Pairs (a, b), (c, d) then (b, c) end up as one group of four chunks."""
602+
linesets = [LineSet(name, ["x = 1\n"]) for name in "abcd"]
603+
a, b, c, d = linesets
604+
605+
def fake_iter_sims(_: Symilar) -> Generator[symilar.Commonality]:
606+
for first, second in ((a, b), (c, d), (b, c)):
607+
yield symilar.Commonality(
608+
cmn_lines_nb=5,
609+
fst_lset=first,
610+
fst_file_start=symilar.LineNumber(0),
611+
fst_file_end=symilar.LineNumber(5),
612+
snd_lset=second,
613+
snd_file_start=symilar.LineNumber(0),
614+
snd_file_end=symilar.LineNumber(5),
615+
)
616+
617+
monkeypatch.setattr(Symilar, "_iter_sims", fake_iter_sims)
618+
sims = Symilar()._compute_sims()
619+
assert len(sims) == 1
620+
assert sims[0][0] == 5
621+
assert {lineset for lineset, _, _ in sims[0][1]} == set(linesets)

‎tests/test_similar.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def test_min_similarity_lines_zero_disables_checker(self) -> None:
8585
def test_duplicate_code_raw_strings_all(self) -> None:
8686
"""Test similar lines in 3 similar files."""
8787
path = join(DATA, "raw_strings_all")
88-
expected_output = "Similar lines in 2 files"
88+
expected_output = "Similar lines in 3 files"
8989
self._test_output(
9090
[
9191
path,
@@ -101,7 +101,7 @@ def test_duplicate_code_raw_strings_all(self) -> None:
101101
@pytest.mark.needs_two_cores
102102
def test_duplicate_code_parallel(self) -> None:
103103
path = join(DATA, "raw_strings_all")
104-
expected_output = "Similar lines in 2 files"
104+
expected_output = "Similar lines in 3 files"
105105
self._test_output(
106106
[
107107
path,

0 commit comments

Comments
 (0)