diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 8490da7..a59a798 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -64,11 +64,13 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: "3.14" - - name: Use local whoowns package - run: echo "whoowns @ file://${PWD}/packages/whoowns" > constraints-e2e.txt + - name: Prepare local package constraints + run: | + echo "whoowns @ file://${PWD}/packages/whoowns" > constraints-e2e.txt + echo "pre-commit-excludes @ file://${PWD}/packages/pre_commit_excludes" >> constraints-e2e.txt - name: Run ${{ matrix.tool }} try repo env: - # Do not set UV_CONSTRAINT here: prek/uv already resolves whoowns from the copied try-repo workspace. + # Do not set UV_CONSTRAINT here: prek/uv already resolves local packages from the copied try-repo workspace. # Constraining uv to the original checkout creates conflicting file URLs for the same package. PIP_CONSTRAINT: ${{ github.workspace }}/constraints-e2e.txt run: SKIP=check-jira-reference-in-todo,go-revive,go-fmt,go-imports,check-number-of-lines-count uvx "${{ matrix.tool }}" try-repo "$PWD" --all-files --color=always --verbose diff --git a/.github/workflows/publish-pre-commit-excludes.yml b/.github/workflows/publish-pre-commit-excludes.yml new file mode 100644 index 0000000..0fb6e5e --- /dev/null +++ b/.github/workflows/publish-pre-commit-excludes.yml @@ -0,0 +1,33 @@ +name: Publish pre-commit-excludes to PyPI + +on: + push: + tags: + - 'pre-commit-excludes-*' + +permissions: + contents: read + packages: read + +jobs: + build-and-publish: + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pre-commit-excludes + permissions: + id-token: write # Required for trusted (OIDC) publishing + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Validate tag version against pyproject.toml + run: ./scripts/check_release.sh "${{ github.ref_name }}" + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.12" + - name: Build pre-commit-excludes package + run: uv build --package pre-commit-excludes + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + packages-dir: dist/ diff --git a/dev_tools/check_useless_exclude_paths_hooks.py b/dev_tools/check_useless_exclude_paths_hooks.py index a6688f9..dc2d7f5 100644 --- a/dev_tools/check_useless_exclude_paths_hooks.py +++ b/dev_tools/check_useless_exclude_paths_hooks.py @@ -1,107 +1,15 @@ # Copyright (c) Luminar Technologies, Inc. All rights reserved. # Licensed under the MIT License. -from __future__ import annotations - -import itertools import sys -from collections import Counter from pathlib import Path from typing import Any -from ruamel.yaml import YAML +from pre_commit_excludes.hook_utils import load_hooks CONFIG_FILE = ".pre-commit-config.yaml" -class Hook: - """Represent a pre-commit hook with its excluded paths.""" - - def __init__(self, id: str, exclude_paths: list[Path]) -> None: - self.__id = id - self.__exclude_paths = exclude_paths - - @classmethod - def from_hook_config(cls: type[Hook], root_directory: Path, hook_config: dict[str, str]) -> Hook: - excluded_paths_list = [ - root_directory / Path(exclude) for exclude in extract_literal_exclude_paths(hook_config["exclude"]) - ] - - return cls(hook_config["id"], excluded_paths_list) - - @property - def id(self) -> str: - return self.__id - - @property - def exclude_paths(self) -> list[Path]: - return self.__exclude_paths - - def find_duplicates(self) -> list[Path]: - counter = Counter(self.exclude_paths) - - return [path for path in counter if counter[path] > 1] - - def find_non_existing_paths(self) -> list[Path]: - return [path for path in self.exclude_paths if not path.resolve().exists()] - - def has_duplicates(self) -> bool: - return bool(self.find_duplicates()) - - def has_non_existing_paths(self) -> bool: - return bool(self.find_non_existing_paths()) - - def count_excluded_files(self) -> int: - existing_paths = [path for path in self.exclude_paths if path.exists()] - total_file_count = sum(1 for path in existing_paths if path.is_file()) - excluded_dirs = [path for path in existing_paths if path.is_dir()] - total_dir_count = sum( - sum(1 for file in excluded_dir.rglob("*") if file.is_file()) for excluded_dir in excluded_dirs - ) - return total_file_count + total_dir_count - - -def is_regex_pattern(exclude: str) -> bool: - return any(regex_key in exclude for regex_key in ["*", "$", "^"]) - - -def extract_literal_exclude_paths(exclude_regex: str) -> list[str]: - exclude_list = ( - _remove_verbose_regex_comments(exclude_regex) - .replace("\n", "") - .replace(" ", "") - .replace("(?x)^(", "") - .replace("^", "") - .replace(r"\.", ".") - .replace(")", "") - .split("|") - ) - - return [exclude for exclude in exclude_list if not is_regex_pattern(exclude)] - - -def _remove_verbose_regex_comments(exclude: str) -> str: - return "\n".join(line.split("#", 1)[0] for line in exclude.splitlines()) - - -def has_excludes(hook_config: dict[str, str]) -> bool: - return bool(hook_config.get("exclude")) and hook_config.get("exclude") != "^$" - - -def load_config(config_file: Path) -> dict[str, Any]: - yaml = YAML(typ="safe") - config = yaml.load(config_file.read_text(encoding="utf-8")) - - return config if isinstance(config, dict) else {} - - -def load_hooks(root_directory: Path, config_file: Path) -> list[Hook]: - config = load_config(config_file) - hook_configs = itertools.chain(*[repo["hooks"] for repo in config["repos"]]) - - return [Hook.from_hook_config(root_directory, hook) for hook in hook_configs if has_excludes(hook)] - - def have_non_existent_paths_or_duplicates(hooks_list: list[Any]) -> bool: non_existing_paths: list[tuple[str, str]] = [ (hook_instance.id, path) diff --git a/dev_tools/print_pre_commit_metrics.py b/dev_tools/print_pre_commit_metrics.py index b0ef7ae..73789c7 100644 --- a/dev_tools/print_pre_commit_metrics.py +++ b/dev_tools/print_pre_commit_metrics.py @@ -6,8 +6,7 @@ from pathlib import Path from pre_commit.constants import CONFIG_FILE - -from dev_tools.check_useless_exclude_paths_hooks import Hook, load_hooks +from pre_commit_excludes.hook_utils import Hook, load_hooks def parse_arguments() -> Namespace: diff --git a/packages/pre_commit_excludes/LICENSE b/packages/pre_commit_excludes/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/packages/pre_commit_excludes/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/packages/pre_commit_excludes/README.md b/packages/pre_commit_excludes/README.md new file mode 100644 index 0000000..e88b8e1 --- /dev/null +++ b/packages/pre_commit_excludes/README.md @@ -0,0 +1,7 @@ +# Pre-Commit-Excludes + + +[![PyPI](https://img.shields.io/pypi/v/pre-commit-excludes)](https://pypi.org/project/pre-commit-excludes/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pre-commit-excludes)](https://pypi.org/project/pre-commit-excludes/) +[![PyPI - License](https://img.shields.io/pypi/l/pre-commit-excludes)](https://pypi.org/project/pre-commit-excludes/) + diff --git a/packages/pre_commit_excludes/pre_commit_excludes/__init__.py b/packages/pre_commit_excludes/pre_commit_excludes/__init__.py new file mode 100644 index 0000000..250af7f --- /dev/null +++ b/packages/pre_commit_excludes/pre_commit_excludes/__init__.py @@ -0,0 +1 @@ +"""Tools to maintain pre-commit exclude lists.""" diff --git a/packages/pre_commit_excludes/pre_commit_excludes/hook_utils.py b/packages/pre_commit_excludes/pre_commit_excludes/hook_utils.py new file mode 100644 index 0000000..54b1499 --- /dev/null +++ b/packages/pre_commit_excludes/pre_commit_excludes/hook_utils.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import itertools +from collections import Counter +from pathlib import Path +from typing import Any + +from ruamel.yaml import YAML + + +class Hook: + """Represent a pre-commit hook with its excluded paths.""" + + def __init__(self, id: str, exclude_paths: list[Path]) -> None: + self.__id = id + self.__exclude_paths = exclude_paths + + @classmethod + def from_hook_config(cls: type[Hook], root_directory: Path, hook_config: dict[str, str]) -> Hook: + excluded_paths_list = [ + root_directory / Path(exclude) for exclude in extract_literal_exclude_paths(hook_config["exclude"]) + ] + + return cls(hook_config["id"], excluded_paths_list) + + @property + def id(self) -> str: + return self.__id + + @property + def exclude_paths(self) -> list[Path]: + return self.__exclude_paths + + def find_duplicates(self) -> list[Path]: + counter = Counter(self.exclude_paths) + + return [path for path in counter if counter[path] > 1] + + def find_non_existing_paths(self) -> list[Path]: + return [path for path in self.exclude_paths if not path.resolve().exists()] + + def has_duplicates(self) -> bool: + return bool(self.find_duplicates()) + + def has_non_existing_paths(self) -> bool: + return bool(self.find_non_existing_paths()) + + def count_excluded_files(self) -> int: + existing_paths = [path for path in self.exclude_paths if path.exists()] + total_file_count = sum(1 for path in existing_paths if path.is_file()) + excluded_dirs = [path for path in existing_paths if path.is_dir()] + total_dir_count = sum( + sum(1 for file in excluded_dir.rglob("*") if file.is_file()) for excluded_dir in excluded_dirs + ) + return total_file_count + total_dir_count + + +def is_regex_pattern(exclude: str) -> bool: + return any(regex_key in exclude for regex_key in ["*", "$", "^"]) + + +def extract_literal_exclude_paths(exclude_regex: str) -> list[str]: + exclude_list = ( + _remove_verbose_regex_comments(exclude_regex) + .replace("\n", "") + .replace(" ", "") + .replace("(?x)^(", "") + .replace("^", "") + .replace(r"\.", ".") + .replace(")", "") + .split("|") + ) + + return [exclude for exclude in exclude_list if not is_regex_pattern(exclude)] + + +def _remove_verbose_regex_comments(exclude: str) -> str: + return "\n".join(line.split("#", 1)[0] for line in exclude.splitlines()) + + +def has_excludes(hook_config: dict[str, str]) -> bool: + return bool(hook_config.get("exclude")) and hook_config.get("exclude") != "^$" + + +def load_config(config_file: Path) -> dict[str, Any]: + yaml = YAML(typ="safe") + config = yaml.load(config_file.read_text(encoding="utf-8")) + + return config if isinstance(config, dict) else {} + + +def load_hooks(root_directory: Path, config_file: Path) -> list[Hook]: + config = load_config(config_file) + hook_configs = itertools.chain(*[repo["hooks"] for repo in config["repos"]]) + + return [Hook.from_hook_config(root_directory, hook) for hook in hook_configs if has_excludes(hook)] diff --git a/packages/pre_commit_excludes/pyproject.toml b/packages/pre_commit_excludes/pyproject.toml new file mode 100644 index 0000000..14fae0a --- /dev/null +++ b/packages/pre_commit_excludes/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["uv_build>=0.10.0,<0.12"] +build-backend = "uv_build" + +[project] +name = "pre_commit_excludes" +version = "0.1.0" +description = "Tools to maintain pre-commit exclude lists" +readme = "README.md" +authors = [ + {name = "Markus Hofbauer"}, + {name = "Chris Bachhuber"} +] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +dependencies = [] +keywords = ["pre-commit", "prek"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Version Control :: Git" +] + +[project.urls] +Homepage = "https://github.com/hofbi/dev-tools" +Repository = "https://github.com/hofbi/dev-tools" +Issues = "https://github.com/hofbi/dev-tools/issues" + +[tool.uv.build-backend] +module-root = "" # required to avoid the "src" layout, which is the default for uv_build diff --git a/pyproject.toml b/pyproject.toml index 0a4880c..f100f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pyjson5>=1.6.8", "regex>=2026.2.28", "ruamel-yaml>=0.19.1", + "pre-commit-excludes", "configure-vscode-for-bazel", "whoowns" ] @@ -46,7 +47,7 @@ sync-vscode-config = "dev_tools.sync_vscode_config:main" sync-tool-versions = "dev_tools.sync_tool_versions:main" [tool.pytest.ini_options] -addopts = "--cov-report term-missing --cov=dev_tools --cov=packages/whoowns/whoowns --cov=packages/configure_vscode_for_bazel/configure_vscode_for_bazel -vv" +addopts = "--cov-report term-missing --cov=dev_tools --cov=packages/whoowns/whoowns --cov=packages/pre_commit_excludes/pre_commit_excludes --cov=packages/configure_vscode_for_bazel/configure_vscode_for_bazel -vv" testpaths = [ "tests" ] @@ -92,10 +93,15 @@ module-root = "" # required to avoid the "src" layout, which is the default for [tool.uv.sources] configure-vscode-for-bazel = {workspace = true} +pre-commit-excludes = {workspace = true} whoowns = {workspace = true} [tool.uv.workspace] -members = ["packages/configure_vscode_for_bazel", "packages/whoowns"] +members = [ + "packages/configure_vscode_for_bazel", + "packages/pre_commit_excludes", + "packages/whoowns" +] [tool.vulture] min_confidence = 100 diff --git a/tests/pre_commit_excludes/__init__.py b/tests/pre_commit_excludes/__init__.py new file mode 100644 index 0000000..4e0cfe6 --- /dev/null +++ b/tests/pre_commit_excludes/__init__.py @@ -0,0 +1 @@ +"""Tests for packages/pre_commit_excludes.""" diff --git a/tests/pre_commit_excludes/test_hook_utils.py b/tests/pre_commit_excludes/test_hook_utils.py new file mode 100644 index 0000000..a2fb2ab --- /dev/null +++ b/tests/pre_commit_excludes/test_hook_utils.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from pre_commit_excludes.hook_utils import ( + Hook, + extract_literal_exclude_paths, + has_excludes, + is_regex_pattern, + load_hooks, +) +from ruamel.yaml import YAML + +if TYPE_CHECKING: + from pyfakefs.fake_filesystem import FakeFilesystem + + +@pytest.mark.parametrize("pattern", [".*\\/conanfile.py", "\\.lock$", "^samples/ros1/src/"]) +def test_is_regex_pattern_for_regex_should_be_true(pattern: str) -> None: + assert is_regex_pattern(pattern) + + +def test_is_regex_pattern_for_no_regex_should_be_false() -> None: + assert is_regex_pattern("packages/thirdparty/") is False + + +def test_extract_literal_exclude_paths_should_ignore_regex_patterns_and_comments() -> None: + assert extract_literal_exclude_paths( + r"""(?x)^( + # keep-sorted start ignore_prefixes=* + BUILD.bazel| + bar/.*\.png| + # keep-sorted end * + foo.txt +)""", + ) == ["BUILD.bazel", "foo.txt"] + + +def test_extract_literal_exclude_paths_for_regex_only_exclude_should_return_empty_list() -> None: + assert extract_literal_exclude_paths(r"(?x)^(bar/.*\.png|.*\.lock$)") == [] + + +def test_extract_literal_exclude_paths_for_escaped_dot_should_unescape() -> None: + assert extract_literal_exclude_paths(r"(?x)^(foo/bar\.py)") == ["foo/bar.py"] + + +def test_extract_literal_exclude_paths_for_single_literal_should_return_path() -> None: + assert extract_literal_exclude_paths("packages/thirdparty/") == ["packages/thirdparty/"] + + +def test_extract_literal_exclude_paths_for_multiple_literals_should_return_paths() -> None: + assert extract_literal_exclude_paths("(?x)^(python/aws_auth|packages/thirdparty/)") == [ + "python/aws_auth", + "packages/thirdparty/", + ] + + +def test_from_hook_config_for_single_path(fs: FakeFilesystem) -> None: + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + hook_instance = Hook.from_hook_config(root_directory, {"id": "buildifier", "exclude": "packages/thirdparty/"}) + + assert has_excludes({"id": "check-snake-case", "exclude": "packages/thirdparty"}) + assert hook_instance.id == "buildifier" + assert hook_instance.exclude_paths == [root_directory / Path("packages/thirdparty/")] + + +def test_from_hook_config_for_multiple_paths(fs: FakeFilesystem) -> None: + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + hook_instance = Hook.from_hook_config( + root_directory, + {"id": "buildifier", "exclude": "(?x)^(\n packages/thirdparty/|\n python/aws_auth/\n)\n"}, + ) + + assert hook_instance.id == "buildifier" + assert hook_instance.exclude_paths == [ + root_directory / Path("packages/thirdparty/"), + root_directory / Path("python/aws_auth/"), + ] + + +def test_from_hook_config_for_keep_sorted_comments_should_ignore_comments(fs: FakeFilesystem) -> None: + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + hook_instance = Hook.from_hook_config( + root_directory, + { + "id": "buildifier", + "exclude": r"""(?x)^( + # keep-sorted start ignore_prefixes=* + BUILD.bazel| + bar/.*\.png| + # keep-sorted end * + foo.txt +)""", + }, + ) + + assert hook_instance.id == "buildifier" + assert hook_instance.exclude_paths == [ + root_directory / Path("BUILD.bazel"), + root_directory / Path("foo.txt"), + ] + + +def test_find_duplicates_for_duplicate_paths_should_return_duplicate_paths(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/existing_path1")) + fs.create_file(Path("Repo/existing_path2")) + hook_instance = Hook( + "test_id", + [Path("existing_path1"), Path("existing_path2"), Path("existing_path1"), Path("existing_path2")], + ) + + assert hook_instance.has_duplicates() + assert hook_instance.find_duplicates() == [Path("existing_path1"), Path("existing_path2")] + + +def test_find_duplicates_for_no_duplicate_paths_should_return_empty_list(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/existing_path1")) + fs.create_file(Path("Repo/existing_path2")) + hook_instance = Hook("test_id", [Path("existing_path1"), Path("existing_path2")]) + + assert not hook_instance.has_duplicates() + assert hook_instance.find_duplicates() == [] + + +def test_find_non_existing_paths_for_non_existing_files_should_return_non_existing_files(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/existing_path")) + hook_instance = Hook( + "test_id", + [Path("Repo/existing_path"), Path("Repo/non_existing_path1"), Path("Repo/non_existing_path2")], + ) + + assert hook_instance.has_non_existing_paths() + assert hook_instance.find_non_existing_paths() == [ + Path("Repo/non_existing_path1"), + Path("Repo/non_existing_path2"), + ] + + +def test_find_non_existing_paths_for_existing_files_should_return_empty_list(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/existing_path1")) + fs.create_file(Path("Repo/existing_path2")) + hook_instance = Hook("test_id", [Path("Repo/existing_path1"), Path("Repo/existing_path2")]) + + assert not hook_instance.has_non_existing_paths() + assert hook_instance.find_non_existing_paths() == [] + + +def test_has_excludes_for_existing_excludes_should_return_true() -> None: + assert has_excludes({"id": "check-snake-case", "exclude": "packages/thirdparty"}) + + +def test_has_excludes_for_non_existing_excludes_should_return_false() -> None: + assert not has_excludes({"id": "check-snake-case"}) + + +def _create_yaml_content(yaml_file: Path, exclude: str, extra_attributes: dict | None = None) -> YAML: + if extra_attributes is None: + extra_attributes = {} + + yaml = YAML() + return yaml.dump( + { + "repos": [ + { + "repo": "meta", + "hooks": [ + {"id": "check-hooks-apply"}, + ], + }, + { + "repo": "local", + "hooks": [ + { + "id": "check-snake-case", + "name": "check snake case", + "entry": "python3 foo.py", + "language": "python", + **({"exclude": exclude} if exclude else {}), + **extra_attributes, + }, + ], + }, + ], + }, + yaml_file, + ) + + +def test_load_hooks_for_no_exclude_file(fs: FakeFilesystem) -> None: + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + config_file = root_directory / Path(".pre-commit-config.yaml") + fs.create_file(config_file) + _create_yaml_content(config_file, "") + + assert len(load_hooks(root_directory, config_file)) == 0 + + +def test_load_hooks_for_exclude_files(fs: FakeFilesystem) -> None: + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + config_file = root_directory / Path(".pre-commit-config.yaml") + fs.create_file(config_file) + _create_yaml_content(config_file, "(?x)^(python/aws_auth|packages/thirdparty/)") + + result = load_hooks(root_directory, config_file) + + assert len(result) == 1 + assert result[0].id == "check-snake-case" + + +def test_load_hooks_for_pass_filenames_42_should_parse_config(fs: FakeFilesystem) -> None: + """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + config_file = root_directory / Path(".pre-commit-config.yaml") + fs.create_file(config_file) + _create_yaml_content(config_file, "foo", extra_attributes={"pass_filenames": 42}) + + result = load_hooks(root_directory, config_file) + + assert len(result) == 1 + assert result[0].id == "check-snake-case" + + +def test_load_hooks_for_repo_builtin_should_parse_config(fs: FakeFilesystem) -> None: + """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + config_file = root_directory / Path(".pre-commit-config.yaml") + fs.create_file( + config_file, + contents=""" +repos: + - repo: meta + hooks: + - id: check-hooks-apply + - repo: builtin + hooks: + - id: check-added-large-files + exclude: foo +""", + ) + + result = load_hooks(root_directory, config_file) + + assert len(result) == 1 + assert result[0].id == "check-added-large-files" + + +def test_load_hooks_for_minimum_version_attributes_should_parse_config(fs: FakeFilesystem) -> None: + """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" + root_directory = Path("Test_directory/") + fs.create_dir(root_directory) + config_file = root_directory / Path(".pre-commit-config.yaml") + fs.create_file( + config_file, + contents=""" +minimum_pre_commit_version: 'Update to prek!' +minimum_prek_version: '0.3.10' +repos: + - repo: meta + hooks: + - id: check-hooks-apply +""", + ) + + result = load_hooks(root_directory, config_file) + + assert len(result) == 0 + + +def test_count_excluded_files_for_single_file(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/test_file.txt")) + hook_instance = Hook("test_id", [Path("Repo/test_file.txt")]) + + assert hook_instance.count_excluded_files() == 1 + + +def test_count_excluded_files_for_multiple_files(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/file1.txt")) + fs.create_file(Path("Repo/file2.txt")) + fs.create_file(Path("Repo/file3.txt")) + hook_instance = Hook("test_id", [Path("Repo/file1.txt"), Path("Repo/file2.txt"), Path("Repo/file3.txt")]) + + assert hook_instance.count_excluded_files() == 3 + + +def test_count_excluded_files_for_directory(fs: FakeFilesystem) -> None: + fs.create_dir(Path("Repo/test_dir")) + fs.create_file(Path("Repo/test_dir/file1.txt")) + fs.create_file(Path("Repo/test_dir/file2.txt")) + fs.create_file(Path("Repo/test_dir/subdir/file3.txt")) + hook_instance = Hook("test_id", [Path("Repo/test_dir")]) + + assert hook_instance.count_excluded_files() == 3 + + +def test_count_excluded_files_for_mixed_paths(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/single_file.txt")) + fs.create_dir(Path("Repo/test_dir")) + fs.create_file(Path("Repo/test_dir/file1.txt")) + fs.create_file(Path("Repo/test_dir/file2.txt")) + hook_instance = Hook("test_id", [Path("Repo/single_file.txt"), Path("Repo/test_dir")]) + + assert hook_instance.count_excluded_files() == 3 + + +def test_count_excluded_files_for_non_existing_paths() -> None: + hook_instance = Hook("test_id", [Path("Repo/non_existing_file.txt"), Path("Repo/non_existing_dir")]) + + assert hook_instance.count_excluded_files() == 0 + + +def test_count_excluded_files_for_mixed_existing_and_non_existing_paths(fs: FakeFilesystem) -> None: + fs.create_file(Path("Repo/existing_file.txt")) + fs.create_dir(Path("Repo/existing_dir")) + fs.create_file(Path("Repo/existing_dir/file.txt")) + hook_instance = Hook( + "test_id", + [Path("Repo/existing_file.txt"), Path("Repo/existing_dir"), Path("Repo/non_existing_file.txt")], + ) + + assert hook_instance.count_excluded_files() == 2 + + +def test_count_excluded_files_for_empty_exclude_paths() -> None: + hook_instance = Hook("test_id", []) + + assert hook_instance.count_excluded_files() == 0 diff --git a/tests/test_check_useless_exclude_paths_hooks.py b/tests/test_check_useless_exclude_paths_hooks.py index 090ee5c..62dd077 100644 --- a/tests/test_check_useless_exclude_paths_hooks.py +++ b/tests/test_check_useless_exclude_paths_hooks.py @@ -6,280 +6,17 @@ from pathlib import Path from typing import TYPE_CHECKING -import pytest -from ruamel.yaml import YAML - -from dev_tools.check_useless_exclude_paths_hooks import ( +from pre_commit_excludes.hook_utils import ( Hook, - extract_literal_exclude_paths, - has_excludes, - have_non_existent_paths_or_duplicates, - is_regex_pattern, - load_hooks, ) +from dev_tools.check_useless_exclude_paths_hooks import have_non_existent_paths_or_duplicates + if TYPE_CHECKING: + import pytest from pyfakefs.fake_filesystem import FakeFilesystem -@pytest.mark.parametrize("pattern", [".*\\/conanfile.py", "\\.lock$", "^samples/ros1/src/"]) -def test_is_regex_pattern_for_regex_should_be_true(pattern: str) -> None: - assert is_regex_pattern(pattern) - - -def test_is_regex_pattern_for_no_regex_should_be_false() -> None: - assert is_regex_pattern("packages/thirdparty/") is False - - -def test_extract_literal_exclude_paths_should_ignore_regex_patterns_and_comments() -> None: - assert extract_literal_exclude_paths( - r"""(?x)^( - # keep-sorted start ignore_prefixes=* - BUILD.bazel| - bar/.*\.png| - # keep-sorted end * - foo.txt -)""", - ) == ["BUILD.bazel", "foo.txt"] - - -def test_extract_literal_exclude_paths_for_regex_only_exclude_should_return_empty_list() -> None: - assert extract_literal_exclude_paths(r"(?x)^(bar/.*\.png|.*\.lock$)") == [] - - -def test_extract_literal_exclude_paths_for_escaped_dot_should_unescape() -> None: - assert extract_literal_exclude_paths(r"(?x)^(foo/bar\.py)") == ["foo/bar.py"] - - -def test_extract_literal_exclude_paths_for_single_literal_should_return_path() -> None: - assert extract_literal_exclude_paths("packages/thirdparty/") == ["packages/thirdparty/"] - - -def test_extract_literal_exclude_paths_for_multiple_literals_should_return_paths() -> None: - assert extract_literal_exclude_paths("(?x)^(python/aws_auth|packages/thirdparty/)") == [ - "python/aws_auth", - "packages/thirdparty/", - ] - - -def test_from_hook_config_for_single_path(fs: FakeFilesystem) -> None: - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - hook_instance = Hook.from_hook_config(root_directory, {"id": "buildifier", "exclude": "packages/thirdparty/"}) - - assert has_excludes({"id": "check-snake-case", "exclude": "packages/thirdparty"}) - assert hook_instance.id == "buildifier" - assert hook_instance.exclude_paths == [root_directory / Path("packages/thirdparty/")] - - -def test_from_hook_config_for_multiple_paths(fs: FakeFilesystem) -> None: - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - hook_instance = Hook.from_hook_config( - root_directory, - {"id": "buildifier", "exclude": "(?x)^(\n packages/thirdparty/|\n python/aws_auth/\n)\n"}, - ) - - assert hook_instance.id == "buildifier" - assert hook_instance.exclude_paths == [ - root_directory / Path("packages/thirdparty/"), - root_directory / Path("python/aws_auth/"), - ] - - -def test_from_hook_config_for_keep_sorted_comments_should_ignore_comments(fs: FakeFilesystem) -> None: - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - hook_instance = Hook.from_hook_config( - root_directory, - { - "id": "buildifier", - "exclude": r"""(?x)^( - # keep-sorted start ignore_prefixes=* - BUILD.bazel| - bar/.*\.png| - # keep-sorted end * - foo.txt -)""", - }, - ) - - assert hook_instance.id == "buildifier" - assert hook_instance.exclude_paths == [ - root_directory / Path("BUILD.bazel"), - root_directory / Path("foo.txt"), - ] - - -def test_find_duplicates_for_duplicate_paths_should_return_duplicate_paths(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/existing_path1")) - fs.create_file(Path("Repo/existing_path2")) - hook_instance = Hook( - "test_id", - [Path("existing_path1"), Path("existing_path2"), Path("existing_path1"), Path("existing_path2")], - ) - - assert hook_instance.has_duplicates() - assert hook_instance.find_duplicates() == [Path("existing_path1"), Path("existing_path2")] - - -def test_find_duplicates_for_no_duplicate_paths_should_return_empty_list(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/existing_path1")) - fs.create_file(Path("Repo/existing_path2")) - hook_instance = Hook("test_id", [Path("existing_path1"), Path("existing_path2")]) - - assert not hook_instance.has_duplicates() - assert hook_instance.find_duplicates() == [] - - -def test_find_non_existing_paths_for_non_existing_files_should_return_non_existing_files(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/existing_path")) - hook_instance = Hook( - "test_id", - [Path("Repo/existing_path"), Path("Repo/non_existing_path1"), Path("Repo/non_existing_path2")], - ) - - assert hook_instance.has_non_existing_paths() - assert hook_instance.find_non_existing_paths() == [ - Path("Repo/non_existing_path1"), - Path("Repo/non_existing_path2"), - ] - - -def test_find_non_existing_paths_for_existing_files_should_return_empty_list(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/existing_path1")) - fs.create_file(Path("Repo/existing_path2")) - hook_instance = Hook("test_id", [Path("Repo/existing_path1"), Path("Repo/existing_path2")]) - - assert not hook_instance.has_non_existing_paths() - assert hook_instance.find_non_existing_paths() == [] - - -def test_has_excludes_for_existing_excludes_should_return_true() -> None: - assert has_excludes({"id": "check-snake-case", "exclude": "packages/thirdparty"}) - - -def test_has_excludes_for_non_existing_excludes_should_return_false() -> None: - assert not has_excludes({"id": "check-snake-case"}) - - -def _create_yaml_content(yaml_file: Path, exclude: str, extra_attributes: dict | None = None) -> YAML: - if extra_attributes is None: - extra_attributes = {} - - yaml = YAML() - return yaml.dump( - { - "repos": [ - { - "repo": "meta", - "hooks": [ - {"id": "check-hooks-apply"}, - ], - }, - { - "repo": "local", - "hooks": [ - { - "id": "check-snake-case", - "name": "check snake case", - "entry": "python3 foo.py", - "language": "python", - **({"exclude": exclude} if exclude else {}), - **extra_attributes, - }, - ], - }, - ], - }, - yaml_file, - ) - - -def test_load_hooks_for_no_exclude_file(fs: FakeFilesystem) -> None: - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - config_file = root_directory / Path(".pre-commit-config.yaml") - fs.create_file(config_file) - _create_yaml_content(config_file, "") - - assert len(load_hooks(root_directory, config_file)) == 0 - - -def test_load_hooks_for_exclude_files(fs: FakeFilesystem) -> None: - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - config_file = root_directory / Path(".pre-commit-config.yaml") - fs.create_file(config_file) - _create_yaml_content(config_file, "(?x)^(python/aws_auth|packages/thirdparty/)") - - result = load_hooks(root_directory, config_file) - - assert len(result) == 1 - assert result[0].id == "check-snake-case" - - -def test_load_hooks_for_pass_filenames_42_should_parse_config(fs: FakeFilesystem) -> None: - """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - config_file = root_directory / Path(".pre-commit-config.yaml") - fs.create_file(config_file) - _create_yaml_content(config_file, "foo", extra_attributes={"pass_filenames": 42}) - - result = load_hooks(root_directory, config_file) - - assert len(result) == 1 - assert result[0].id == "check-snake-case" - - -def test_load_hooks_for_repo_builtin_should_parse_config(fs: FakeFilesystem) -> None: - """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - config_file = root_directory / Path(".pre-commit-config.yaml") - fs.create_file( - config_file, - contents=""" -repos: - - repo: meta - hooks: - - id: check-hooks-apply - - repo: builtin - hooks: - - id: check-added-large-files - exclude: foo -""", - ) - - result = load_hooks(root_directory, config_file) - - assert len(result) == 1 - assert result[0].id == "check-added-large-files" - - -def test_load_hooks_for_minimum_version_attributes_should_parse_config(fs: FakeFilesystem) -> None: - """Reproduce issue from https://github.com/hofbi/dev-tools/issues/113.""" - root_directory = Path("Test_directory/") - fs.create_dir(root_directory) - config_file = root_directory / Path(".pre-commit-config.yaml") - fs.create_file( - config_file, - contents=""" -minimum_pre_commit_version: 'Update to prek!' -minimum_prek_version: '0.3.10' -repos: - - repo: meta - hooks: - - id: check-hooks-apply -""", - ) - - result = load_hooks(root_directory, config_file) - - assert len(result) == 0 - - def test_have_non_existent_paths_or_duplicates_for_non_existing_paths( capsys: pytest.CaptureFixture, fs: FakeFilesystem, @@ -324,63 +61,3 @@ def test_have_non_existent_paths_or_duplicates_for_duplicate_paths( assert "duplicates" in output assert "existing_path1" in output assert "existing_path2" in output - - -def test_count_excluded_files_for_single_file(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/test_file.txt")) - hook_instance = Hook("test_id", [Path("Repo/test_file.txt")]) - - assert hook_instance.count_excluded_files() == 1 - - -def test_count_excluded_files_for_multiple_files(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/file1.txt")) - fs.create_file(Path("Repo/file2.txt")) - fs.create_file(Path("Repo/file3.txt")) - hook_instance = Hook("test_id", [Path("Repo/file1.txt"), Path("Repo/file2.txt"), Path("Repo/file3.txt")]) - - assert hook_instance.count_excluded_files() == 3 - - -def test_count_excluded_files_for_directory(fs: FakeFilesystem) -> None: - fs.create_dir(Path("Repo/test_dir")) - fs.create_file(Path("Repo/test_dir/file1.txt")) - fs.create_file(Path("Repo/test_dir/file2.txt")) - fs.create_file(Path("Repo/test_dir/subdir/file3.txt")) - hook_instance = Hook("test_id", [Path("Repo/test_dir")]) - - assert hook_instance.count_excluded_files() == 3 - - -def test_count_excluded_files_for_mixed_paths(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/single_file.txt")) - fs.create_dir(Path("Repo/test_dir")) - fs.create_file(Path("Repo/test_dir/file1.txt")) - fs.create_file(Path("Repo/test_dir/file2.txt")) - hook_instance = Hook("test_id", [Path("Repo/single_file.txt"), Path("Repo/test_dir")]) - - assert hook_instance.count_excluded_files() == 3 - - -def test_count_excluded_files_for_non_existing_paths() -> None: - hook_instance = Hook("test_id", [Path("Repo/non_existing_file.txt"), Path("Repo/non_existing_dir")]) - - assert hook_instance.count_excluded_files() == 0 - - -def test_count_excluded_files_for_mixed_existing_and_non_existing_paths(fs: FakeFilesystem) -> None: - fs.create_file(Path("Repo/existing_file.txt")) - fs.create_dir(Path("Repo/existing_dir")) - fs.create_file(Path("Repo/existing_dir/file.txt")) - hook_instance = Hook( - "test_id", - [Path("Repo/existing_file.txt"), Path("Repo/existing_dir"), Path("Repo/non_existing_file.txt")], - ) - - assert hook_instance.count_excluded_files() == 2 - - -def test_count_excluded_files_for_empty_exclude_paths() -> None: - hook_instance = Hook("test_id", []) - - assert hook_instance.count_excluded_files() == 0 diff --git a/tests/test_print_pre_commit_metrics.py b/tests/test_print_pre_commit_metrics.py index 3db010f..1abada2 100644 --- a/tests/test_print_pre_commit_metrics.py +++ b/tests/test_print_pre_commit_metrics.py @@ -4,7 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING -from dev_tools.check_useless_exclude_paths_hooks import Hook +from pre_commit_excludes.hook_utils import Hook + from dev_tools.print_pre_commit_metrics import create_excluded_files_report, write_pre_commit_metrics if TYPE_CHECKING: diff --git a/uv.lock b/uv.lock index 8e78651..c9d356f 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.10" members = [ "configure-vscode-for-bazel", "dev-tools", + "pre-commit-excludes", "whoowns", ] @@ -157,6 +158,7 @@ source = { editable = "." } dependencies = [ { name = "configure-vscode-for-bazel" }, { name = "pre-commit" }, + { name = "pre-commit-excludes" }, { name = "pyjson5" }, { name = "regex" }, { name = "ruamel-yaml" }, @@ -175,6 +177,7 @@ dev = [ requires-dist = [ { name = "configure-vscode-for-bazel", editable = "packages/configure_vscode_for_bazel" }, { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pre-commit-excludes", editable = "packages/pre_commit_excludes" }, { name = "pyfakefs", marker = "extra == 'dev'" }, { name = "pyjson5", specifier = ">=1.6.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -286,6 +289,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pre-commit-excludes" +version = "0.1.0" +source = { editable = "packages/pre_commit_excludes" } + [[package]] name = "pyfakefs" version = "6.2.0"