In wcmatch, a Python glob and fnmatch library, glob.glob and glob.iglob returned a path twice when two patterns found it, IGNORECASE was set and the path had an upper case letter. I fixed it in PR #247, merged about eight minutes after opening.

When it happens

from wcmatch import glob

# the directory holds a file named Ab
glob.glob(['A*', '*b'], flags=glob.IGNORECASE)
# ['Ab', 'Ab']   want ['Ab']

The docs say glob and iglob return unique paths. Releases 8.1.1 through 11.1 return the path twice for this call; 8.0, 8.0.1 and 8.1 return it once.

How the unique check works

Glob._is_unique in wcmatch/glob.py accepts a path only if its key is not in the set self.seen. The lookup key was path.lower() when self.case_sensitive was false, else path; the stored key was always path. self.case_sensitive says how patterns match: IGNORECASE clears it.

Why the gap existed

Release 6.0.0 added unique results with the same store and a lookup that lowered the path when matching was case sensitive. #165, in 8.1.1, reversed that condition; its changelog line says the unique cache “had inverted logic”. It left the store alone and added no test. In the glob tests, glob.I appears in only two places; in neither did two patterns find one path. The unique rows held names like a and aab, which lower() leaves alone.

The fix

The lookup and the store now share one key, set by the path case rule. The wcmatch/glob.py diff, without context lines:

@@ -817,2 +817,4 @@ class Glob(Generic[AnyStr]):
-        if (path.lower() if not self.case_sensitive else path) not in self.seen:
-            self.seen.add(path)
+        # Paths that only differ by case are the same file only when the file system ignores case.
+        key = path if _wcparse.is_case_sensitive(self.flags) else path.lower()
+        if key not in self.seen:
+            self.seen.add(key)

is_case_sensitive asks os.path.normcase, unless FORCEWIN or FORCEUNIX is set. The match flag changes only pattern matching, so a and A stay two files where the rule keeps case. With IGNORECASE, glob.glob('*') could drop one of them, by listing order; it now returns both.

The test

One row joined Testglob.cases: {E*,*F} with IGNORECASE expects only the existing file EF. Two more classes reuse the table; without the fix the row fails in all three. TestCase.test_case_unique expects both a and A for * and ['a*', 'A*'], and skips itself, like test_case, when the file system holds only one of them.

What the maintainer did

@facelessuser approved and merged it unchanged.

The lesson

  • Compute a set’s key once and use it for the lookup and the store. #165 changed only the lookup line.
  • Test a key function with input it changes: lower() alters only names with an upper case letter.
  • Ask what a key stands for: “the same file”, which the path case rule decides, not a pattern flag.

If a set of yours is read with one key and written with another, reply on X.