diff --git a/pathspec/_backends/agg.py b/pathspec/_backends/agg.py index 3e9c366..ee0c0a9 100644 --- a/pathspec/_backends/agg.py +++ b/pathspec/_backends/agg.py @@ -100,9 +100,12 @@ def make_pathspec_backend( """ if name == 'best': name = _BEST_BACKEND - if name != 'simple' and has_unsupported_flags(patterns): - # Only the simple backend can keep the compile flags of the patterns it - # was given. + if name != 'simple' and ( + any(not isinstance(pattern, RegexPattern) for pattern in patterns) + or has_unsupported_flags(patterns) + ): + # Only the simple backend supports custom patterns and compile flags that + # cannot be translated. name = 'simple' if name == 'hyperscan': diff --git a/pathspec/pathspec.py b/pathspec/pathspec.py index fa11546..0f79e58 100644 --- a/pathspec/pathspec.py +++ b/pathspec/pathspec.py @@ -76,6 +76,8 @@ def __init__( matching backend to use. Default is :data:`None` for "best" to use the best available backend. Priority of backends is: "re2", "hyperscan", "simple". The "simple" backend is always available. + Automatic selection uses "simple" when any pattern is not a + :class:`.RegexPattern`. Native backends only support regular expressions. """ if isinstance(patterns, Sequence): use_patterns = patterns diff --git a/tests/test_05_pathspec.py b/tests/test_05_pathspec.py index 97771a0..3226724 100644 --- a/tests/test_05_pathspec.py +++ b/tests/test_05_pathspec.py @@ -1410,3 +1410,59 @@ def test_3_character_after_slash(self): self.assertTrue(spec.match_file(name)) self.assertFalse(spec.match_file('foo/')) self.assertFalse(spec.match_file('foo/other')) + + +class SuffixPattern(Pattern): + def match_file(self, file: str) -> Optional[str]: + return file if file.endswith('.secret') else None + + +class CustomPatternBackendTest(TestCase): + """ + Automatic backend selection must preserve custom matching implementations. + """ + + def test_custom_pattern(self): + for preferred in ('simple', 're2', 'hyperscan'): + for backend in (None, 'best'): + with self.subTest(preferred=preferred, backend=backend): + with patch('pathspec._backends.agg._BEST_BACKEND', preferred): + spec = PathSpec([SuffixPattern(True)], backend=backend) + self.assertEqual(list(spec.match_files(['public.txt', 'key.secret'])), ['key.secret']) + self.assertEqual(list(spec.match_files(['public.txt', 'key.secret'], negate=True)), ['public.txt']) + self.assertEqual(spec.check_file('key.secret').index, 0) + + def test_custom_pattern_factory(self): + for preferred in ('simple', 're2', 'hyperscan'): + with self.subTest(preferred=preferred): + with patch('pathspec._backends.agg._BEST_BACKEND', preferred): + spec = PathSpec.from_lines(lambda line: SuffixPattern(True), ['secrets']) + self.assertTrue(spec.match_file('key.secret')) + self.assertFalse(spec.match_file('public.txt')) + + def test_mixed_patterns(self): + for preferred in ('simple', 're2', 'hyperscan'): + for include in (False, True): + patterns = [ + RegexPattern(re.compile('.*'), include=not include), + Pattern(None), + SuffixPattern(include), + ] + with self.subTest(preferred=preferred, include=include): + with patch('pathspec._backends.agg._BEST_BACKEND', preferred): + spec = PathSpec(patterns) + result = spec.check_file('key.secret') + self.assertEqual((result.include, result.index), (include, 2)) + self.assertEqual(spec.match_file('public.txt'), not include) + + def test_add_custom_pattern(self): + spec = PathSpec.from_lines('gitignore', ['*']) + other = PathSpec([SuffixPattern(False)], backend='simple') + combined = spec + other + self.assertTrue(spec.match_file('key.secret')) + self.assertFalse(combined.match_file('key.secret')) + original = spec + spec += other + self.assertIs(spec, original) + self.assertFalse(spec.match_file('key.secret')) + self.assertTrue(spec.match_file('public.txt'))