Skip to content

Commit 3eda146

Browse files
authored
gh-74598: add fnmatch.filterfalse for excluding names matching a patern (#121185)
1 parent ee36572 commit 3eda146

File tree

5 files changed

+102
-40
lines changed

5 files changed

+102
-40
lines changed

Doc/library/fnmatch.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ functions: :func:`fnmatch`, :func:`fnmatchcase`, :func:`.filter`.
9090
but implemented more efficiently.
9191

9292

93+
.. function:: filterfalse(names, pat)
94+
95+
Construct a list from those elements of the :term:`iterable` of filename
96+
strings *names* that do not match the pattern string *pat*.
97+
It is the same as ``[n for n in names if not fnmatch(n, pat)]``,
98+
but implemented more efficiently.
99+
100+
.. versionadded:: next
101+
102+
93103
.. function:: translate(pat)
94104

95105
Return the shell-style pattern *pat* converted to a regular expression for

Doc/whatsnew/3.14.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,13 @@ errno
677677
(Contributed by James Roy in :gh:`126585`.)
678678

679679

680+
fnmatch
681+
-------
682+
683+
* Added :func:`fnmatch.filterfalse` for excluding names matching a pattern.
684+
(Contributed by Bénédikt Tran in :gh:`74598`.)
685+
686+
680687
fractions
681688
---------
682689

Lib/fnmatch.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
The function translate(PATTERN) returns a regular expression
1010
corresponding to PATTERN. (It does not compile it.)
1111
"""
12+
13+
import functools
14+
import itertools
1215
import os
1316
import posixpath
1417
import re
15-
import functools
1618

17-
__all__ = ["filter", "fnmatch", "fnmatchcase", "translate"]
19+
__all__ = ["filter", "filterfalse", "fnmatch", "fnmatchcase", "translate"]
20+
1821

1922
def fnmatch(name, pat):
2023
"""Test whether FILENAME matches PATTERN.
@@ -35,6 +38,7 @@ def fnmatch(name, pat):
3538
pat = os.path.normcase(pat)
3639
return fnmatchcase(name, pat)
3740

41+
3842
@functools.lru_cache(maxsize=32768, typed=True)
3943
def _compile_pattern(pat):
4044
if isinstance(pat, bytes):
@@ -45,6 +49,7 @@ def _compile_pattern(pat):
4549
res = translate(pat)
4650
return re.compile(res).match
4751

52+
4853
def filter(names, pat):
4954
"""Construct a list from those elements of the iterable NAMES that match PAT."""
5055
result = []
@@ -61,6 +66,22 @@ def filter(names, pat):
6166
result.append(name)
6267
return result
6368

69+
70+
def filterfalse(names, pat):
71+
"""Construct a list from those elements of the iterable NAMES that do not match PAT."""
72+
pat = os.path.normcase(pat)
73+
match = _compile_pattern(pat)
74+
if os.path is posixpath:
75+
# normcase on posix is NOP. Optimize it away from the loop.
76+
return list(itertools.filterfalse(match, names))
77+
78+
result = []
79+
for name in names:
80+
if match(os.path.normcase(name)) is None:
81+
result.append(name)
82+
return result
83+
84+
6485
def fnmatchcase(name, pat):
6586
"""Test whether FILENAME matches PATTERN, including case.
6687
@@ -80,9 +101,11 @@ def translate(pat):
80101
parts, star_indices = _translate(pat, '*', '.')
81102
return _join_translated_parts(parts, star_indices)
82103

104+
83105
_re_setops_sub = re.compile(r'([&~|])').sub
84106
_re_escape = functools.lru_cache(maxsize=512)(re.escape)
85107

108+
86109
def _translate(pat, star, question_mark):
87110
res = []
88111
add = res.append

Lib/test/test_fnmatch.py

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Test cases for the fnmatch module."""
22

3-
import unittest
43
import os
54
import string
5+
import unittest
66
import warnings
7+
from fnmatch import fnmatch, fnmatchcase, translate, filter, filterfalse
8+
9+
10+
IGNORECASE = os.path.normcase('P') == os.path.normcase('p')
11+
NORMSEP = os.path.normcase('\\') == os.path.normcase('/')
712

8-
from fnmatch import fnmatch, fnmatchcase, translate, filter
913

1014
class FnmatchTestCase(unittest.TestCase):
1115

@@ -77,35 +81,32 @@ def test_bytes(self):
7781
self.check_match(b'foo\nbar', b'foo*')
7882

7983
def test_case(self):
80-
ignorecase = os.path.normcase('ABC') == os.path.normcase('abc')
8184
check = self.check_match
8285
check('abc', 'abc')
83-
check('AbC', 'abc', ignorecase)
84-
check('abc', 'AbC', ignorecase)
86+
check('AbC', 'abc', IGNORECASE)
87+
check('abc', 'AbC', IGNORECASE)
8588
check('AbC', 'AbC')
8689

8790
def test_sep(self):
88-
normsep = os.path.normcase('\\') == os.path.normcase('/')
8991
check = self.check_match
9092
check('usr/bin', 'usr/bin')
91-
check('usr\\bin', 'usr/bin', normsep)
92-
check('usr/bin', 'usr\\bin', normsep)
93+
check('usr\\bin', 'usr/bin', NORMSEP)
94+
check('usr/bin', 'usr\\bin', NORMSEP)
9395
check('usr\\bin', 'usr\\bin')
9496

9597
def test_char_set(self):
96-
ignorecase = os.path.normcase('ABC') == os.path.normcase('abc')
9798
check = self.check_match
9899
tescases = string.ascii_lowercase + string.digits + string.punctuation
99100
for c in tescases:
100101
check(c, '[az]', c in 'az')
101102
check(c, '[!az]', c not in 'az')
102103
# Case insensitive.
103104
for c in tescases:
104-
check(c, '[AZ]', (c in 'az') and ignorecase)
105-
check(c, '[!AZ]', (c not in 'az') or not ignorecase)
105+
check(c, '[AZ]', (c in 'az') and IGNORECASE)
106+
check(c, '[!AZ]', (c not in 'az') or not IGNORECASE)
106107
for c in string.ascii_uppercase:
107-
check(c, '[az]', (c in 'AZ') and ignorecase)
108-
check(c, '[!az]', (c not in 'AZ') or not ignorecase)
108+
check(c, '[az]', (c in 'AZ') and IGNORECASE)
109+
check(c, '[!az]', (c not in 'AZ') or not IGNORECASE)
109110
# Repeated same character.
110111
for c in tescases:
111112
check(c, '[aa]', c == 'a')
@@ -120,8 +121,6 @@ def test_char_set(self):
120121
check('[!]', '[!]')
121122

122123
def test_range(self):
123-
ignorecase = os.path.normcase('ABC') == os.path.normcase('abc')
124-
normsep = os.path.normcase('\\') == os.path.normcase('/')
125124
check = self.check_match
126125
tescases = string.ascii_lowercase + string.digits + string.punctuation
127126
for c in tescases:
@@ -131,11 +130,11 @@ def test_range(self):
131130
check(c, '[!b-dx-z]', c not in 'bcdxyz')
132131
# Case insensitive.
133132
for c in tescases:
134-
check(c, '[B-D]', (c in 'bcd') and ignorecase)
135-
check(c, '[!B-D]', (c not in 'bcd') or not ignorecase)
133+
check(c, '[B-D]', (c in 'bcd') and IGNORECASE)
134+
check(c, '[!B-D]', (c not in 'bcd') or not IGNORECASE)
136135
for c in string.ascii_uppercase:
137-
check(c, '[b-d]', (c in 'BCD') and ignorecase)
138-
check(c, '[!b-d]', (c not in 'BCD') or not ignorecase)
136+
check(c, '[b-d]', (c in 'BCD') and IGNORECASE)
137+
check(c, '[!b-d]', (c not in 'BCD') or not IGNORECASE)
139138
# Upper bound == lower bound.
140139
for c in tescases:
141140
check(c, '[b-b]', c == 'b')
@@ -144,7 +143,7 @@ def test_range(self):
144143
check(c, '[!-#]', c not in '-#')
145144
check(c, '[!--.]', c not in '-.')
146145
check(c, '[^-`]', c in '^_`')
147-
if not (normsep and c == '/'):
146+
if not (NORMSEP and c == '/'):
148147
check(c, '[[-^]', c in r'[\]^')
149148
check(c, r'[\-^]', c in r'\]^')
150149
check(c, '[b-]', c in '-b')
@@ -160,47 +159,45 @@ def test_range(self):
160159
check(c, '[d-bx-z]', c in 'xyz')
161160
check(c, '[!d-bx-z]', c not in 'xyz')
162161
check(c, '[d-b^-`]', c in '^_`')
163-
if not (normsep and c == '/'):
162+
if not (NORMSEP and c == '/'):
164163
check(c, '[d-b[-^]', c in r'[\]^')
165164

166165
def test_sep_in_char_set(self):
167-
normsep = os.path.normcase('\\') == os.path.normcase('/')
168166
check = self.check_match
169167
check('/', r'[/]')
170168
check('\\', r'[\]')
171-
check('/', r'[\]', normsep)
172-
check('\\', r'[/]', normsep)
169+
check('/', r'[\]', NORMSEP)
170+
check('\\', r'[/]', NORMSEP)
173171
check('[/]', r'[/]', False)
174172
check(r'[\\]', r'[/]', False)
175173
check('\\', r'[\t]')
176-
check('/', r'[\t]', normsep)
174+
check('/', r'[\t]', NORMSEP)
177175
check('t', r'[\t]')
178176
check('\t', r'[\t]', False)
179177

180178
def test_sep_in_range(self):
181-
normsep = os.path.normcase('\\') == os.path.normcase('/')
182179
check = self.check_match
183-
check('a/b', 'a[.-0]b', not normsep)
180+
check('a/b', 'a[.-0]b', not NORMSEP)
184181
check('a\\b', 'a[.-0]b', False)
185-
check('a\\b', 'a[Z-^]b', not normsep)
182+
check('a\\b', 'a[Z-^]b', not NORMSEP)
186183
check('a/b', 'a[Z-^]b', False)
187184

188-
check('a/b', 'a[/-0]b', not normsep)
185+
check('a/b', 'a[/-0]b', not NORMSEP)
189186
check(r'a\b', 'a[/-0]b', False)
190187
check('a[/-0]b', 'a[/-0]b', False)
191188
check(r'a[\-0]b', 'a[/-0]b', False)
192189

193190
check('a/b', 'a[.-/]b')
194-
check(r'a\b', 'a[.-/]b', normsep)
191+
check(r'a\b', 'a[.-/]b', NORMSEP)
195192
check('a[.-/]b', 'a[.-/]b', False)
196193
check(r'a[.-\]b', 'a[.-/]b', False)
197194

198195
check(r'a\b', r'a[\-^]b')
199-
check('a/b', r'a[\-^]b', normsep)
196+
check('a/b', r'a[\-^]b', NORMSEP)
200197
check(r'a[\-^]b', r'a[\-^]b', False)
201198
check('a[/-^]b', r'a[\-^]b', False)
202199

203-
check(r'a\b', r'a[Z-\]b', not normsep)
200+
check(r'a\b', r'a[Z-\]b', not NORMSEP)
204201
check('a/b', r'a[Z-\]b', False)
205202
check(r'a[Z-\]b', r'a[Z-\]b', False)
206203
check('a[Z-/]b', r'a[Z-\]b', False)
@@ -332,18 +329,41 @@ def test_mix_bytes_str(self):
332329
self.assertRaises(TypeError, filter, [b'test'], '*')
333330

334331
def test_case(self):
335-
ignorecase = os.path.normcase('P') == os.path.normcase('p')
336332
self.assertEqual(filter(['Test.py', 'Test.rb', 'Test.PL'], '*.p*'),
337-
['Test.py', 'Test.PL'] if ignorecase else ['Test.py'])
333+
['Test.py', 'Test.PL'] if IGNORECASE else ['Test.py'])
338334
self.assertEqual(filter(['Test.py', 'Test.rb', 'Test.PL'], '*.P*'),
339-
['Test.py', 'Test.PL'] if ignorecase else ['Test.PL'])
335+
['Test.py', 'Test.PL'] if IGNORECASE else ['Test.PL'])
340336

341337
def test_sep(self):
342-
normsep = os.path.normcase('\\') == os.path.normcase('/')
343338
self.assertEqual(filter(['usr/bin', 'usr', 'usr\\lib'], 'usr/*'),
344-
['usr/bin', 'usr\\lib'] if normsep else ['usr/bin'])
339+
['usr/bin', 'usr\\lib'] if NORMSEP else ['usr/bin'])
345340
self.assertEqual(filter(['usr/bin', 'usr', 'usr\\lib'], 'usr\\*'),
346-
['usr/bin', 'usr\\lib'] if normsep else ['usr\\lib'])
341+
['usr/bin', 'usr\\lib'] if NORMSEP else ['usr\\lib'])
342+
343+
344+
class FilterFalseTestCase(unittest.TestCase):
345+
346+
def test_filterfalse(self):
347+
actual = filterfalse(['Python', 'Ruby', 'Perl', 'Tcl'], 'P*')
348+
self.assertListEqual(actual, ['Ruby', 'Tcl'])
349+
actual = filterfalse([b'Python', b'Ruby', b'Perl', b'Tcl'], b'P*')
350+
self.assertListEqual(actual, [b'Ruby', b'Tcl'])
351+
352+
def test_mix_bytes_str(self):
353+
self.assertRaises(TypeError, filterfalse, ['test'], b'*')
354+
self.assertRaises(TypeError, filterfalse, [b'test'], '*')
355+
356+
def test_case(self):
357+
self.assertEqual(filterfalse(['Test.py', 'Test.rb', 'Test.PL'], '*.p*'),
358+
['Test.rb'] if IGNORECASE else ['Test.rb', 'Test.PL'])
359+
self.assertEqual(filterfalse(['Test.py', 'Test.rb', 'Test.PL'], '*.P*'),
360+
['Test.rb'] if IGNORECASE else ['Test.py', 'Test.rb',])
361+
362+
def test_sep(self):
363+
self.assertEqual(filterfalse(['usr/bin', 'usr', 'usr\\lib'], 'usr/*'),
364+
['usr'] if NORMSEP else ['usr', 'usr\\lib'])
365+
self.assertEqual(filterfalse(['usr/bin', 'usr', 'usr\\lib'], 'usr\\*'),
366+
['usr'] if NORMSEP else ['usr/bin', 'usr'])
347367

348368

349369
if __name__ == "__main__":
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :func:`fnmatch.filterfalse` for excluding names matching a pattern.
2+
Patch by Bénédikt Tran.

0 commit comments

Comments
 (0)