Repository: pre-commit/pre-commit

Issue:
Short-circuit the `check-hooks-apply` hook
### search you tried in the issue tracker

check-hooks-apply

### describe your actual problem

I was looking at the implementation of `check_hooks_apply` and noticed that while the `filenames_for_hook` function returns multiple matching files, the caller is only interested in whether the tuple is empty or not. I was curious if short-circuiting would have any benefit:

I prototyped a function `Classifier.any_filenames_for_hook` which short-circuits:

```python
	def any_filenames_for_hook(self, hook: Hook) -> bool:
	    include_re, exclude_re = re.compile(hook.files), re.compile(hook.exclude)
	    types, types_or, exclude_types = (
	        frozenset(hook.types),
	        frozenset(hook.types_or),
	        frozenset(hook.exclude_types),
	    )
	
	    for i, name in enumerate(self.filenames):
	        if include_re.search(name) and not exclude_re.search(name):
	            return True
	
	        tags = self._types_for_file(name)
	        if (
	            tags >= types
	            and (not types or tags & types_or)
	            and not tags & exclude_types
	        ):
	            return True
	
	    return False

```

```diff
     for hook in all_hooks(config, Store()):
         if hook.always_run or hook.language == 'fail':
             continue
-        elif not classifier.filenames_for_hook(hook):
+        elif not classifier.any_filenames_for_hook(hook):
             print(f'{hook.id} does not apply to this repository')
             retv = 1
```

Just to try it out, I applied this change in my pre-commit install directory and profiled with `hyperfine` on a local repo. The repo has about 12000 files. The short-circuit version ran about 20% faster. I also added logging and saw the short-circuit version cut out after ~26 files, whereas the existing version looks at all 12000.

Old

```console
$ hyperfine 'pre-commit run check-hooks-apply --all-files'
Benchmark 1: pre-commit run check-hooks-apply --all-files
  Time (mean ± σ):      1.253 s ±  0.066 s    [User: 0.546 s, System: 0.695 s]
  Range (min … max):    1.193 s …  1.419 s    10 runs
```

New

```console
$ hyperfine 'pre-commit run check-hooks-apply --all-files'
Benchmark 1: pre-commit run check-hooks-apply --all-files
  Time (mean ± σ):     969.9 ms ±  65.6 ms    [User: 429.0 ms, System: 535.0 ms]
  Range (min … max):   873.1 ms … 1067.6 ms    10 runs
```




### pre-commit --version

3.2.2


Discussion:
I'd be concerned that the two functions would drift -- the current implementation directly matches what pre-commit would do.  I'm not sure the performance benefits are worth it?
Agreed, the POC was to measure the improvement. I can take a stab at unifying the two (for example refactor `filenames_for_hook` to return an iterator) but in general if even that causes more harm than good I'm happy to close this issue
sure give that a shot -- though sometimes a generator will be slower but maybe not in this case?

Resolving patch:
diff --git a/pre_commit/commands/run.py b/pre_commit/commands/run.py
--- a/pre_commit/commands/run.py
+++ b/pre_commit/commands/run.py
@@ -10,7 +10,8 @@
 import time
 import unicodedata
 from typing import Any
-from typing import Collection
+from typing import Generator
+from typing import Iterable
 from typing import MutableMapping
 from typing import Sequence
 
@@ -57,20 +58,20 @@ def _full_msg(
 
 
 def filter_by_include_exclude(
-        names: Collection[str],
+        names: Iterable[str],
         include: str,
         exclude: str,
-) -> list[str]:
+) -> Generator[str, None, None]:
     include_re, exclude_re = re.compile(include), re.compile(exclude)
-    return [
+    return (
         filename for filename in names
         if include_re.search(filename)
         if not exclude_re.search(filename)
-    ]
+    )
 
 
 class Classifier:
-    def __init__(self, filenames: Collection[str]) -> None:
+    def __init__(self, filenames: Iterable[str]) -> None:
         self.filenames = [f for f in filenames if os.path.lexists(f)]
 
     @functools.lru_cache(maxsize=None)
@@ -79,15 +80,14 @@ def _types_for_file(self, filename: str) -> set[str]:
 
     def by_types(
             self,
-            names: Sequence[str],
-            types: Collection[str],
-            types_or: Collection[str],
-            exclude_types: Collection[str],
-    ) -> list[str]:
+            names: Iterable[str],
+            types: Iterable[str],
+            types_or: Iterable[str],
+            exclude_types: Iterable[str],
+    ) -> Generator[str, None, None]:
         types = frozenset(types)
         types_or = frozenset(types_or)
         exclude_types = frozenset(exclude_types)
-        ret = []
         for filename in names:
             tags = self._types_for_file(filename)
             if (
@@ -95,24 +95,24 @@ def by_types(
                     (not types_or or tags & types_or) and
                     not tags & exclude_types
             ):
-                ret.append(filename)
-        return ret
-
-    def filenames_for_hook(self, hook: Hook) -> tuple[str, ...]:
-        names = self.filenames
-        names = filter_by_include_exclude(names, hook.files, hook.exclude)
-        names = self.by_types(
-            names,
+                yield filename
+
+    def filenames_for_hook(self, hook: Hook) -> Generator[str, None, None]:
+        return self.by_types(
+            filter_by_include_exclude(
+                self.filenames,
+                hook.files,
+                hook.exclude,
+            ),
             hook.types,
             hook.types_or,
             hook.exclude_types,
         )
-        return tuple(names)
 
     @classmethod
     def from_config(
             cls,
-            filenames: Collection[str],
+            filenames: Iterable[str],
             include: str,
             exclude: str,
     ) -> Classifier:
@@ -121,7 +121,7 @@ def from_config(
         # this also makes improperly quoted shell-based hooks work better
         # see #1173
         if os.altsep == '/' and os.sep == '\\':
-            filenames = [f.replace(os.sep, os.altsep) for f in filenames]
+            filenames = (f.replace(os.sep, os.altsep) for f in filenames)
         filenames = filter_by_include_exclude(filenames, include, exclude)
         return Classifier(filenames)
 
@@ -148,7 +148,7 @@ def _run_single_hook(
         verbose: bool,
         use_color: bool,
 ) -> tuple[bool, bytes]:
-    filenames = classifier.filenames_for_hook(hook)
+    filenames = tuple(classifier.filenames_for_hook(hook))
 
     if hook.id in skips or hook.alias in skips:
         output.write(
@@ -250,7 +250,7 @@ def _compute_cols(hooks: Sequence[Hook]) -> int:
     return max(cols, 80)
 
 
-def _all_filenames(args: argparse.Namespace) -> Collection[str]:
+def _all_filenames(args: argparse.Namespace) -> Iterable[str]:
     # these hooks do not operate on files
     if args.hook_stage in {
         'post-checkout', 'post-commit', 'post-merge', 'post-rewrite',
diff --git a/pre_commit/meta_hooks/check_hooks_apply.py b/pre_commit/meta_hooks/check_hooks_apply.py
--- a/pre_commit/meta_hooks/check_hooks_apply.py
+++ b/pre_commit/meta_hooks/check_hooks_apply.py
@@ -21,7 +21,7 @@ def check_all_hooks_match_files(config_file: str) -> int:
     for hook in all_hooks(config, Store()):
         if hook.always_run or hook.language == 'fail':
             continue
-        elif not classifier.filenames_for_hook(hook):
+        elif not any(classifier.filenames_for_hook(hook)):
             print(f'{hook.id} does not apply to this repository')
             retv = 1
 
diff --git a/pre_commit/meta_hooks/check_useless_excludes.py b/pre_commit/meta_hooks/check_useless_excludes.py
--- a/pre_commit/meta_hooks/check_useless_excludes.py
+++ b/pre_commit/meta_hooks/check_useless_excludes.py
@@ -2,6 +2,7 @@
 
 import argparse
 import re
+from typing import Iterable
 from typing import Sequence
 
 from cfgv import apply_defaults
@@ -14,7 +15,7 @@
 
 
 def exclude_matches_any(
-        filenames: Sequence[str],
+        filenames: Iterable[str],
         include: str,
         exclude: str,
 ) -> bool:
@@ -50,11 +51,12 @@ def check_useless_excludes(config_file: str) -> int:
             # Not actually a manifest dict, but this more accurately reflects
             # the defaults applied during runtime
             hook = apply_defaults(hook, MANIFEST_HOOK_DICT)
-            names = classifier.filenames
-            types = hook['types']
-            types_or = hook['types_or']
-            exclude_types = hook['exclude_types']
-            names = classifier.by_types(names, types, types_or, exclude_types)
+            names = classifier.by_types(
+                classifier.filenames,
+                hook['types'],
+                hook['types_or'],
+                hook['exclude_types'],
+            )
             include, exclude = hook['files'], hook['exclude']
             if not exclude_matches_any(names, include, exclude):
                 print(


Test patch:
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -1127,8 +1127,8 @@ def test_classifier_empty_types_or(tmpdir):
             types_or=[],
             exclude_types=[],
         )
-        assert for_symlink == ['foo']
-        assert for_file == ['bar']
+        assert tuple(for_symlink) == ('foo',)
+        assert tuple(for_file) == ('bar',)
 
 
 @pytest.fixture
@@ -1142,33 +1142,33 @@ def some_filenames():
 
 def test_include_exclude_base_case(some_filenames):
     ret = filter_by_include_exclude(some_filenames, '', '^$')
-    assert ret == [
+    assert tuple(ret) == (
         '.pre-commit-hooks.yaml',
         'pre_commit/git.py',
         'pre_commit/main.py',
-    ]
+    )
 
 
 def test_matches_broken_symlink(tmpdir):
     with tmpdir.as_cwd():
         os.symlink('does-not-exist', 'link')
         ret = filter_by_include_exclude({'link'}, '', '^$')
-        assert ret == ['link']
+        assert tuple(ret) == ('link',)
 
 
 def test_include_exclude_total_match(some_filenames):
     ret = filter_by_include_exclude(some_filenames, r'^.*\.py$', '^$')
-    assert ret == ['pre_commit/git.py', 'pre_commit/main.py']
+    assert tuple(ret) == ('pre_commit/git.py', 'pre_commit/main.py')
 
 
 def test_include_exclude_does_search_instead_of_match(some_filenames):
     ret = filter_by_include_exclude(some_filenames, r'\.yaml$', '^$')
-    assert ret == ['.pre-commit-hooks.yaml']
+    assert tuple(ret) == ('.pre-commit-hooks.yaml',)
 
 
 def test_include_exclude_exclude_removes_files(some_filenames):
     ret = filter_by_include_exclude(some_filenames, '', r'\.py$')
-    assert ret == ['.pre-commit-hooks.yaml']
+    assert tuple(ret) == ('.pre-commit-hooks.yaml',)
 
 
 def test_args_hook_only(cap_out, store, repo_with_passing_hook):