diff --git a/docs/configuring.md b/docs/configuring.md index 354dc3d3e4..0d11d299a9 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -42,7 +42,7 @@ counterparts: ## Ignoring rules for entire files -Ansible-lint will load skip rules from an `.ansible-lint-ignore` or +Ansible-lint will load ignore rules from an `.ansible-lint-ignore` or `.config/ansible-lint-ignore.txt` file that should reside adjacent to the config file. The file format is very simple, containing the filename and the rule to be ignored. It also supports comments starting with `#`. @@ -56,6 +56,15 @@ playbook.yml deprecated-module The file can also be created by adding `--generate-ignore` to the command line. Keep in mind that this will override any existing file content. +By default, rules ignored here will raise a non-fatal warning in the +output. If you add `skip` to the line, the test will be skipped +(see `skip_list`) and not raise any warning. + +```yaml title=".ansible-lint-ignore" +playbook.yml role-name # raises warning +playbook2.yml role-name skip # no warning +``` + ## Pre-commit setup To use Ansible-lint with the [pre-commit] tool, add the following to the diff --git a/src/ansiblelint/__main__.py b/src/ansiblelint/__main__.py index 54b2f5e327..51a7b757c2 100755 --- a/src/ansiblelint/__main__.py +++ b/src/ansiblelint/__main__.py @@ -57,7 +57,7 @@ log_entries, options, ) -from ansiblelint.loaders import load_ignore_txt +from ansiblelint.loaders import IgnoreRule, IgnoreRuleQualifier, load_ignore_txt from ansiblelint.output import ( console, console_stderr, @@ -272,6 +272,19 @@ def fix(runtime_options: Options, result: LintResult, rules: RulesCollection) -> result.matches.pop(idx) +# By default, matches ignored in .ansible-lint-ignore are treated +# as warnings [1]. If the user explicitly adds a skip qualifier +# to the rule, it is treated as skipped here and does not show up +# even as a warning. +# [1] https://github.com/ansible/ansible-lint/issues/3068 +def _rule_is_skipped(tag: str, rules: set[IgnoreRule]) -> bool: + for rule in rules: + if tag != rule.rule: + return False + return IgnoreRuleQualifier.SKIP in rule.qualifiers + return False + + # pylint: disable=too-many-locals,too-many-statements def main(argv: list[str] | None = None) -> int: """Linter CLI entry point.""" @@ -376,10 +389,13 @@ def main(argv: list[str] | None = None) -> int: # Remove skip_list items from the result result.matches = [m for m in result.matches if m.tag not in app.options.skip_list] - # Mark matches as ignored inside ignore file + # load ignore file ignore_map = load_ignore_txt(options.ignore_file) + # prune qualified skips from ignore file + result.matches = [m for m in result.matches if not _rule_is_skipped(m.tag, ignore_map[m.filename])] + # others entries are ignored for match in result.matches: - if match.tag in ignore_map[match.filename]: # pragma: no cover + if match.tag in [i.rule for i in ignore_map[match.filename]]: # pragma: no cover match.ignored = True _logger.debug("Ignored: %s", match) diff --git a/src/ansiblelint/loaders.py b/src/ansiblelint/loaders.py index 9a80b7d467..5bb320ecb3 100644 --- a/src/ansiblelint/loaders.py +++ b/src/ansiblelint/loaders.py @@ -2,6 +2,7 @@ from __future__ import annotations +import enum import logging import os from collections import defaultdict @@ -23,11 +24,21 @@ class IgnoreFile(NamedTuple): """IgnoreFile n.""" - default: str alternative: str +class IgnoreRuleQualifier(enum.Enum): + """Extra flags for ignored rules.""" + SKIP = "Force skip, not warning" + + +class IgnoreRule(NamedTuple): + """Ignored rule.""" + rule: str + qualifiers: frozenset[IgnoreRuleQualifier] + + IGNORE_FILE = IgnoreFile(".ansible-lint-ignore", ".config/ansible-lint-ignore.txt") yaml_load = partial(yaml.load, Loader=FullLoader) @@ -41,7 +52,19 @@ def yaml_from_file(filepath: str | Path) -> Any: return yaml_load(content) -def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[str]]: +def get_ignore_rule(rule: str, qualifiers: str) -> IgnoreRule: + """Validate qualifiers and return an IgnoreRule.""" + s = set() + if qualifiers: + for q in qualifiers.split(","): + if q == "skip": + s.add(IgnoreRuleQualifier.SKIP) + else: + raise ValueError + return IgnoreRule(rule, frozenset(s)) + + +def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[IgnoreRule]]: """Return a list of rules to ignore.""" result = defaultdict(set) @@ -64,17 +87,21 @@ def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[str]]: entry = line.split("#")[0].rstrip() if entry: try: - path, rule = entry.split() + fields = entry.split() + path = fields[0] + rule = fields[1] + qualifiers = fields[2] if len(fields) == 3 else "" + result[path].add(get_ignore_rule(rule, qualifiers)) except ValueError as exc: # pragma: no cover msg = f"Unable to parse line '{line}' from {ignore_file} file." raise RuntimeError(msg) from exc - result[path].add(rule) - return result __all__ = [ "IGNORE_FILE", + "IgnoreRule", + "IgnoreRuleQualifier", "YAMLError", "load_ignore_txt", "yaml_from_file", diff --git a/test/test_loaders.py b/test/test_loaders.py index 6e8d66b66e..b5992b296c 100644 --- a/test/test_loaders.py +++ b/test/test_loaders.py @@ -6,7 +6,14 @@ from pathlib import Path from textwrap import dedent -from ansiblelint.loaders import IGNORE_FILE, load_ignore_txt +import pytest + +from ansiblelint.loaders import ( + IGNORE_FILE, + IgnoreRule, + IgnoreRuleQualifier, + load_ignore_txt, +) def test_load_ignore_txt_default_empty() -> None: @@ -35,6 +42,7 @@ def test_load_ignore_txt_default_success() -> None: # See https://ansible.readthedocs.io/projects/lint/configuring/#ignoring-rules-for-entire-files playbook2.yml package-latest # comment playbook2.yml foo-bar + playbook2.yml another-role skip # rule with qualifier """, ), ) @@ -47,7 +55,10 @@ def test_load_ignore_txt_default_success() -> None: finally: os.chdir(cwd) - assert result == {"playbook2.yml": {"package-latest", "foo-bar"}} + assert result == {"playbook2.yml": + {IgnoreRule("package-latest", frozenset()), + IgnoreRule("foo-bar", frozenset()), + IgnoreRule("another-role", frozenset([IgnoreRuleQualifier.SKIP]))}} def test_load_ignore_txt_default_success_alternative() -> None: @@ -76,8 +87,8 @@ def test_load_ignore_txt_default_success_alternative() -> None: os.chdir(cwd) assert result == { - "playbook.yml": {"more-foo", "foo-bar"}, - "tasks/main.yml": {"more-bar"}, + "playbook.yml": {IgnoreRule("more-foo", frozenset()), IgnoreRule("foo-bar", frozenset())}, + "tasks/main.yml": {IgnoreRule("more-bar", frozenset())}, } @@ -108,10 +119,10 @@ def test_load_ignore_txt_custom_success() -> None: os.chdir(cwd) assert result == { - "playbook.yml": {"hector"}, - "roles/eduardo/tasks/main.yml": {"lalo"}, - "roles/guzman/tasks/main.yml": {"lalo"}, - "vars/main.yml": {"tuco"}, + "playbook.yml": {IgnoreRule("hector", frozenset())}, + "roles/eduardo/tasks/main.yml": {IgnoreRule("lalo", frozenset())}, + "roles/guzman/tasks/main.yml": {IgnoreRule("lalo", frozenset())}, + "vars/main.yml": {IgnoreRule("tuco", frozenset())}, } @@ -120,3 +131,22 @@ def test_load_ignore_txt_custom_fail() -> None: result = load_ignore_txt(Path(str(uuid.uuid4()))) assert not result + + +def test_load_ignore_txt_invalid_tags(monkeypatch: pytest.MonkeyPatch) -> None: + """Test load_ignore_txt with an existing ignore-file in the default location.""" + with tempfile.TemporaryDirectory() as temporary_directory: + ignore_file = Path(temporary_directory) / IGNORE_FILE.default + + with ignore_file.open("w", encoding="utf-8") as _ignore_file: + _ignore_file.write( + dedent( + """ + playbook2.yml package-latest invalid-tag + """, + ), + ) + + monkeypatch.chdir(temporary_directory) + with pytest.raises(RuntimeError, match="Unable to parse line"): + load_ignore_txt() diff --git a/tox.ini b/tox.ini index 59723b6374..68b1d739aa 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,7 @@ set_env = PIP_CONSTRAINT = {tox_root}/.config/constraints.txt PIP_DISABLE_PIP_VERSION_CHECK = 1 PRE_COMMIT_COLOR = always - PYTEST_REQPASS = 911 + PYTEST_REQPASS = 912 UV_CONSTRAINT = {tox_root}/.config/constraints.txt deps, devel, hook, lint, pkg, pre, py310, schemas: PIP_CONSTRAINT = /dev/null deps, devel, hook, lint, pkg, pre, py310, schemas: UV_CONSTRAINT = /dev/null