Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Lib/glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,35 @@ def translate(pat, *, recursive=False, include_hidden=False, seps=None):
if part:
if not include_hidden and part[0] in '*?':
results.append(r'(?!\.)')
results.extend(fnmatch._translate(part, f'{not_sep}*', not_sep)[0])
results.extend(_translate_segment(part, not_sep, seps))
if idx < last_part_idx:
results.append(any_sep)
res = ''.join(results)
return fr'(?s:{res})\z'


def _translate_segment(part, not_sep, seps):
# Wrap each interior `STAR fixed` pair in an atomic group to prevent
# catastrophic backtracking, as fnmatch._join_translated_parts() does.
# A pair whose fixed part can match a separator (a character class) is
# left as it is, since the atomic group could change what it matches.
star = f'{not_sep}*'
parts, star_indices = fnmatch._translate(part, star, not_sep)
if not star_indices:
return parts
res = parts[:star_indices[0]]
for i, j in zip(star_indices, star_indices[1:]):
fixed = parts[i + 1:j]
if any(re.match(p, sep) for p in fixed for sep in seps):
res.extend(parts[i:j])
else:
res.append(f'(?>{star}?')
res.extend(fixed)
res.append(')')
res.extend(parts[star_indices[-1]:])
return res


@functools.lru_cache(maxsize=512)
def _compile_pattern(pat, seps, case_sensitive, recursive=True):
"""Compile given glob pattern to a re.Pattern object (observing case
Expand Down
21 changes: 18 additions & 3 deletions Lib/test/test_glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,21 @@ def test_translate_matching(self):
self.assertIsNotNone(match(os.path.join('foo', 'bar.txt')))
self.assertIsNone(match(os.path.join('foo', '.bar.txt')))

def test_translate_slow(self):
match = re.compile(glob.translate('*a' * 8 + '*b', seps='/')).match
self.assertIsNotNone(match('a' * 100 + 'b'))
# The next "takes forever" if the regexp translation is
# straightforward. See bpo-40480.
self.assertIsNone(match('a' * 100))

def test_translate_negated_class_matches_sep(self):
# A character class can match a separator, so each star is not
# limited to a single segment there.
match = re.compile(glob.translate('*[!x]*x', seps='/')).match
self.assertIsNotNone(match('a/x'))
self.assertIsNotNone(match('ab/x'))
self.assertIsNone(match('a/b/x'))

def test_translate(self):
def fn(pat):
return glob.translate(pat, seps='/')
Expand All @@ -443,7 +458,7 @@ def fn(pat):
self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z')
self.assertEqual(fn('**b'), r'(?s:(?!\.)[^/]*b)\z')
self.assertEqual(fn('/**/*/*.*/**'),
r'(?s:/(?!\.)[^/]*/[^/.][^/]*/(?!\.)[^/]*\.[^/]*/(?!\.)[^/]*)\z')
r'(?s:/(?!\.)[^/]*/[^/.][^/]*/(?!\.)(?>[^/]*?\.)[^/]*/(?!\.)[^/]*)\z')

def test_translate_include_hidden(self):
def fn(pat):
Expand All @@ -462,7 +477,7 @@ def fn(pat):
self.assertEqual(fn('***'), r'(?s:[^/]*)\z')
self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z')
self.assertEqual(fn('**b'), r'(?s:[^/]*b)\z')
self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/[^/]*/[^/]+/[^/]*\.[^/]*/[^/]*)\z')
self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/[^/]*/[^/]+/(?>[^/]*?\.)[^/]*/[^/]*)\z')

def test_translate_recursive(self):
def fn(pat):
Expand All @@ -474,7 +489,7 @@ def fn(pat):
self.assertEqual(fn('***'), r'(?s:[^/]*)\z')
self.assertEqual(fn('a**'), r'(?s:a[^/]*)\z')
self.assertEqual(fn('**b'), r'(?s:[^/]*b)\z')
self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/(?:.+/)?[^/]+/[^/]*\.[^/]*/.*)\z')
self.assertEqual(fn('/**/*/*.*/**'), r'(?s:/(?:.+/)?[^/]+/(?>[^/]*?\.)[^/]*/.*)\z')

def test_translate_seps(self):
def fn(pat):
Expand Down
18 changes: 18 additions & 0 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,16 @@ def test_full_match_case_sensitive(self):
self.assertFalse(P('c:/a/B.Py').full_match('C:/A/*.pY', case_sensitive=True))
self.assertTrue(P('/a/b/c.py').full_match('/A/*/*.Py', case_sensitive=False))

def test_full_match_slow(self):
P = self.cls
pat = '*a' * 8 + '*b'
self.assertTrue(P('a' * 100 + 'b').full_match(pat))
self.assertTrue(P('a' * 100 + 'b').match(pat))
# The next "takes forever" if the regexp translation is
# straightforward. See bpo-40480.
self.assertFalse(P('a' * 100).full_match(pat))
self.assertFalse(P('a' * 100).match(pat))

def test_match_empty(self):
P = self.cls
self.assertRaises(ValueError, P('a').match, '')
Expand Down Expand Up @@ -2973,6 +2983,14 @@ def test_glob_windows(self):
self.assertEqual(set(p.glob("*a\\")), { P(self.base, "dirA/") })
self.assertEqual(set(p.glob("F*a")), { P(self.base, "fileA") })

def test_glob_slow(self):
P = self.cls
p = P(self.base)
q = p / ('a' * 100)
q.touch()
self.assertEqual(list(p.glob('*a' * 8 + '*b')), [])
self.assertEqual(list(p.glob('*a' * 8)), [q])

def test_glob_empty_pattern(self):
p = self.cls('')
with self.assertRaisesRegex(ValueError, 'Unacceptable pattern'):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fix exponential matching time in :func:`glob.translate`,
:meth:`pathlib.PurePath.match`, :meth:`~pathlib.PurePath.full_match` and
:meth:`pathlib.Path.glob` for patterns with several ``*`` wildcards in one
segment. Interior ``*`` wildcards are now translated to atomic groups where
this cannot change which names match, as :func:`fnmatch.translate` does.
Loading