Skip to content

SequenceMatcher.get_grouped_opcodes() changes the result of later get_opcodes() calls #161973

Description

@Broyojo

Bug report

SequenceMatcher.get_opcodes() caches its result and returns the same list on every call. get_grouped_opcodes() takes that list and overwrites its first and last entries to trim the context, so after one call the matcher's opcodes no longer cover the whole input, and a later get_grouped_opcodes() with a larger n shows less context than it should.

This was mentioned in gh-54194 back in 2010, but that issue was closed after only the get_matching_blocks() part was fixed (gh-65834); the get_grouped_opcodes() part is still there.

import difflib

old = ["line %d\n" % i for i in range(10)]
new = old[:]
new[5] = "changed\n"

sm = difflib.SequenceMatcher(None, old, new)
for group in sm.get_grouped_opcodes(1):
    pass  # show the hunks with one line of context
print(sm.get_opcodes())

Actual:

[('equal', 4, 5, 4, 5), ('replace', 5, 6, 5, 6), ('equal', 6, 7, 6, 7)]

Expected (what get_opcodes() returns before the get_grouped_opcodes() call):

[('equal', 0, 5, 0, 5), ('replace', 5, 6, 5, 6), ('equal', 6, 10, 6, 10)]

unified_diff() and context_diff() are not affected because they make a new matcher each time.

CPython versions tested on:

3.15, CPython main branch

Operating systems tested on:

Linux


Found while building a Lean 4 model of CPython (https://broyojo.com/python-formalization/). AI tools (Claude) helped investigate the findings and prepare this report. The examples were rerun on CPython 3.15.2 and main, and the report received technical review. Record: textlibs-difflib-grouped-opcodes-mutates-cache

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    stdlibStandard Library Python modules in the Lib/ directory
    on Nov 14, 2026
  2. brunnojob commented on Nov 14, 2026

    @brunnojob

    I confirmed the alias in current Lib/difflib.py: get_opcodes() returns self.opcodes directly, then the get_grouped_opcodes() generator binds codes to that same list and replaces codes[0]/codes[-1] while trimming equal spans. Copying before those fixups (codes = self.get_opcodes().copy()) should isolate the mutation; the opcode tuples themselves are immutable.

    For regression coverage, consume list(sm.get_grouped_opcodes(1)), then assert sm.get_opcodes() still equals the full original opcodes. On the same matcher, compare get_grouped_opcodes(3) with a fresh matcher so a later request for wider context cannot inherit the earlier trimming.

  3. tanveer-arch commented on Nov 14, 2026

    @tanveer-arch

    I'd like to take this on. The bug is clear from the report: get_opcodes() returns the cached self.opcodes list, and get_grouped_opcodes() mutates its first and last entries while trimming context, so a later get_opcodes() or a wider n sees the trimmed list instead of the full one. I'm working on a fix that copies the list before trimming, plus a regression test.

  4. Broyojo commented on Nov 14, 2026

    @Broyojo
    ContributorAuthor

    Thanks for confirming! I had a fix with a regression test ready, so I've opened #162012.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    stdlibStandard Library Python modules in the Lib/ directorytype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions