Skip to content

Commit 49cb1be

Browse files
authored
Add hook for checking that a rule has a certain tag (#98)
1 parent 8f98510 commit 49cb1be

5 files changed

Lines changed: 224 additions & 0 deletions

File tree

.pre-commit-hooks.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@
142142
language: python
143143
types:
144144
- bazel
145+
- id: check-rule-has-tag
146+
name: Check that a Bazel rule has a specific tag
147+
description: |-
148+
Check that a Bazel rule contains a specific tag in its `tags` attribute.
149+
150+
Use `--rule-name` to select the rule and `--tag` to define the required tag.
151+
Example args in the pre-commit config: `args: [--rule-name=py_venv, --tag=manual]`
152+
153+
This is useful for checks such as: `py_venv(..., tags = ["manual"])` or `pkg_tar(..., tags = ["no-remote"])`.
154+
An alternative would be <https://github.com/bazel-contrib/tar.bzl?tab=readme-ov-file#remote-cache-and-rbe> making use of Bazel's `--modify_execution_info` flag.
155+
entry: check-rule-has-tag
156+
language: python
157+
types:
158+
- bazel
145159
- id: check-non-existing-and-duplicate-excludes
146160
name: Check non-existing and duplicate excludes in pre-commit-config
147161
description: |-

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ These tools are used to help developers in their day-to-day tasks.
3030
- [`check-shellscript-set-options`](#check-shellscript-set-options)
3131
- [`check-jira-reference-in-todo`](#check-jira-reference-in-todo)
3232
- [`check-load-statement`](#check-load-statement)
33+
- [`check-rule-has-tag`](#check-rule-has-tag)
3334
- [`check-non-existing-and-duplicate-excludes`](#check-non-existing-and-duplicate-excludes)
3435
- [`print-pre-commit-metrics`](#print-pre-commit-metrics)
3536
- [`sync-vscode-config`](#sync-vscode-config)
@@ -119,6 +120,16 @@ Both arguments are required.
119120
Make sure you't put any ticks around the rule path and rule name.
120121
This hook can be used multiple times to check different rules.
121122

123+
### `check-rule-has-tag`
124+
125+
Check that a Bazel rule contains a specific tag in its `tags` attribute.
126+
127+
Use `--rule-name` to select the rule and `--tag` to define the required tag.
128+
Example args in the pre-commit config: `args: [--rule-name=py_venv, --tag=manual]`
129+
130+
This is useful for checks such as: `py_venv(..., tags = ["manual"])` or `pkg_tar(..., tags = ["no-remote"])`.
131+
An alternative would be <https://github.com/bazel-contrib/tar.bzl?tab=readme-ov-file#remote-cache-and-rbe> making use of Bazel's `--modify_execution_info` flag.
132+
122133
### `check-non-existing-and-duplicate-excludes`
123134

124135
Check for non existing and duplicate paths in `.pre-commit-config.yaml`.

dev_tools/check_rule_has_tag.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import sys
5+
from typing import TYPE_CHECKING
6+
7+
from dev_tools.git_hook_utils import create_default_parser
8+
9+
if TYPE_CHECKING:
10+
import argparse
11+
from collections.abc import Sequence
12+
from pathlib import Path
13+
14+
15+
def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
16+
parser = create_default_parser()
17+
parser.add_argument("--rule-name", type=str, required=True)
18+
parser.add_argument("--tag", type=str, required=True)
19+
return parser.parse_args(argv)
20+
21+
22+
def _remove_comments(content: str) -> str:
23+
return re.sub(r"(?m)#.*$", "", content)
24+
25+
26+
def find_rule_calls(content: str, rule_name: str) -> list[str]:
27+
content_without_comments = _remove_comments(content)
28+
rule_pattern = re.compile(rf"(?m)(?<![A-Za-z0-9_]){re.escape(rule_name)}\s*\(")
29+
rule_calls: list[str] = []
30+
31+
for match in rule_pattern.finditer(content_without_comments):
32+
open_parens_count = 1
33+
current_index = match.end()
34+
35+
while current_index < len(content_without_comments) and open_parens_count > 0:
36+
character = content_without_comments[current_index]
37+
if character == "(":
38+
open_parens_count += 1
39+
elif character == ")":
40+
open_parens_count -= 1
41+
current_index += 1
42+
43+
if open_parens_count == 0:
44+
rule_calls.append(content_without_comments[match.end() : current_index - 1])
45+
46+
return rule_calls
47+
48+
49+
def rule_has_tag(rule_body: str, tag: str) -> bool:
50+
tags_pattern = re.compile(r'(?ms)(?<![A-Za-z0-9_])tags\s*=\s*\[[^\]]*["\']' + re.escape(tag) + r'["\']')
51+
return bool(tags_pattern.search(_remove_comments(rule_body)))
52+
53+
54+
def is_rule_missing_tag(rule_body: str, tag: str) -> bool:
55+
return not rule_has_tag(rule_body, tag)
56+
57+
58+
def find_invalid_files(filenames: Sequence[Path], rule_name: str, tag: str) -> list[Path]:
59+
return [
60+
filename
61+
for filename in filenames
62+
if (rule_calls := find_rule_calls(filename.read_text(), rule_name))
63+
and any(is_rule_missing_tag(rule_body, tag) for rule_body in rule_calls)
64+
]
65+
66+
67+
def main(argv: Sequence[str] | None = None) -> int:
68+
args = parse_arguments(argv)
69+
invalid_files = find_invalid_files(args.filenames, args.rule_name, args.tag)
70+
71+
for filename in invalid_files:
72+
print(
73+
f"Error: {filename} contains a `{args.rule_name}` rule without `tags` containing `{args.tag}`. "
74+
f"Make sure you tag this rule with `{args.tag}`."
75+
)
76+
77+
return 1 if invalid_files else 0
78+
79+
80+
if __name__ == "__main__":
81+
sys.exit(main())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dev = [
3131
[project.scripts]
3232
check-jira-reference-in-todo = "dev_tools.check_jira_reference_in_todo:main"
3333
check-load-statement = "dev_tools.check_load_statement:main"
34+
check-rule-has-tag = "dev_tools.check_rule_has_tag:main"
3435
check-max-one-sentence-per-line = "dev_tools.check_max_one_sentence_per_line:main"
3536
check-number-of-lines-count = "dev_tools.check_number_of_lines_count:main"
3637
check-ownership = "dev_tools.check_ownership:main"

tests/test_check_rule_has_tag.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING
5+
6+
import pytest
7+
8+
from dev_tools.check_rule_has_tag import find_invalid_files, find_rule_calls, rule_has_tag
9+
10+
if TYPE_CHECKING:
11+
from pyfakefs.fake_filesystem import FakeFilesystem
12+
13+
14+
@pytest.mark.parametrize(
15+
("content", "expected_rule_count"),
16+
[
17+
('py_venv(name = "venv")', 1),
18+
('other_rule(name = "x")', 0),
19+
(
20+
"""
21+
py_venv(name = "venv")
22+
pkg_tar(name = "archive")
23+
py_venv(
24+
name = "second",
25+
)
26+
""",
27+
2,
28+
),
29+
(
30+
"""
31+
py_venv(
32+
name = name + "_venv",
33+
deps = [":{}_foo".format(name)],
34+
package_collisions = "ignore",
35+
tags = ["manual"],
36+
)
37+
""",
38+
1,
39+
),
40+
],
41+
)
42+
def test_find_rule_calls_for_content_should_return_expected_rule_count(content: str, expected_rule_count: int) -> None:
43+
assert len(find_rule_calls(content, "py_venv")) == expected_rule_count
44+
45+
46+
@pytest.mark.parametrize(
47+
("rule_body", "tag"),
48+
[
49+
('name = "venv", tags = ["manual"]', "manual"),
50+
('name = "venv", tags = ["manual", "no-remote"]', "manual"),
51+
('name = "venv", tags = [\n "manual",\n]', "manual"),
52+
('name = "archive", tags = ["no-remote"]', "no-remote"),
53+
],
54+
)
55+
def test_rule_has_tag_for_matching_tag_should_return_true(rule_body: str, tag: str) -> None:
56+
assert rule_has_tag(rule_body, tag) is True
57+
58+
59+
@pytest.mark.parametrize(
60+
("rule_body", "tag"),
61+
[
62+
('name = "venv"', "manual"),
63+
('name = "venv", tags = ["not-manual"]', "manual"),
64+
('name = "venv", tags = [] # manual', "manual"),
65+
('name = "archive", tags = ["remote"]', "no-remote"),
66+
('name = "thing", package_collisions = "ignore"', "manual"),
67+
],
68+
)
69+
def test_rule_has_tag_for_non_matching_tag_should_return_false(rule_body: str, tag: str) -> None:
70+
assert rule_has_tag(rule_body, tag) is False
71+
72+
73+
def test_find_invalid_files_for_rule_without_tag_should_return_file(fs: FakeFilesystem) -> None:
74+
fs.create_file(
75+
Path("repo/BUILD.bazel"),
76+
contents="""
77+
py_venv(
78+
name = "bad",
79+
)
80+
py_venv(
81+
name = "good",
82+
tags = ["manual"],
83+
)
84+
""",
85+
)
86+
87+
assert find_invalid_files([Path("repo/BUILD.bazel")], "py_venv", "manual") == [Path("repo/BUILD.bazel")]
88+
89+
90+
def test_find_invalid_files_for_file_without_target_rule_should_return_empty_list(fs: FakeFilesystem) -> None:
91+
fs.create_file(
92+
Path("repo/BUILD.bazel"),
93+
contents="""
94+
pkg_tar(
95+
name = "archive",
96+
tags = ["no-remote"],
97+
)
98+
""",
99+
)
100+
101+
assert find_invalid_files([Path("repo/BUILD.bazel")], "py_venv", "manual") == []
102+
103+
104+
def test_find_invalid_files_for_rule_with_nested_parentheses_should_return_empty_list(fs: FakeFilesystem) -> None:
105+
fs.create_file(
106+
Path("repo/BUILD.bazel"),
107+
contents="""
108+
py_venv(
109+
name = name + "_venv",
110+
deps = [":{}_foo".format(name)],
111+
package_collisions = "ignore",
112+
tags = ["manual"],
113+
)
114+
""",
115+
)
116+
117+
assert find_invalid_files([Path("repo/BUILD.bazel")], "py_venv", "manual") == []

0 commit comments

Comments
 (0)