mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The ruff toolchain was unpinned, so host venvs, the lint container and CI installed different releases over time. That drift is why lint-python only failed on CI: locally ruff 0.15.x preview-gated ISC004/FURB192 and (falsely) fired S310 on urllib.request.Request, while CI's 0.16.0 has ISC004/FURB192 stable and drops the S310-on-Request false positive. Pin ruff==0.16.0 in pyproject.toml's dev extras (Dependabot keeps it current) and have utils.install.lint.ruff read that pin and reinstall whenever the found version drifts, so every environment resolves the same ruff. Then fix what 0.16.0 flags: parenthesise the implicit string concatenations (ISC004), use min() over sorted()[0] (FURB192), drop the now-unused S310 noqa on the Request() builders (RUF100), and delete the narration comments that the comment lint surfaces in the touched files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""Lint: forbid Jinja delimiters inside Ansible conditional keys.
|
|
|
|
Ansible evaluates ``when`` / ``failed_when`` / ``changed_when`` / ``until``
|
|
as bare expressions. Wrapping the value in ``{{ }}`` / ``{% %}`` triggers
|
|
the ansible-core deprecation "Conditionals should not be surrounded by
|
|
templating delimiters" (removed in 2.23) and, when the templated string
|
|
flows into a ``(x | default(true)) | bool`` data conditional, the
|
|
"``bool`` filter coerced invalid value (str)" deprecation.
|
|
|
|
Scope: ``.yml`` / ``.yaml`` under ``roles/*/{tasks,handlers,vars}/``.
|
|
|
|
Excluded:
|
|
|
|
* ``roles/*/meta/{volumes,networks,services}.yml`` -- their ``when``
|
|
keys are custom filter fields evaluated by ``render_jinja`` and
|
|
REQUIRE ``{{ }}``; they are never Ansible task conditionals.
|
|
* ``.j2`` templates -- not parsed as Ansible conditionals.
|
|
|
|
Per-item render/apply lists (``role_templates`` for the shared
|
|
``sys-svc-compose`` render helper, ``LISTMONK_SETTINGS``) carry their
|
|
guard on a ``condition:`` data field, not ``when:``, precisely so a
|
|
templated boolean is legal there without colliding with this check.
|
|
|
|
Suppress a genuine exception with ``# nocheck: templated-conditional``
|
|
on the offending line or the line above.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from utils.annotations.suppress import is_suppressed_at
|
|
from utils.cache.files import iter_project_files, read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
_RULE = "templated-conditional"
|
|
_SCAN_SUFFIXES = (".yml", ".yaml")
|
|
_SCAN_SUBDIRS = ("tasks", "handlers", "vars")
|
|
_EXCLUDED_META_FILES = frozenset(
|
|
(
|
|
"volumes.yml",
|
|
"networks.yml",
|
|
"services.yml",
|
|
)
|
|
)
|
|
|
|
_CONDITIONAL_KEYS = ("when", "failed_when", "changed_when", "until")
|
|
_JINJA = re.compile(r"\{\{|\{%")
|
|
|
|
_SCALAR_KEY = re.compile(rf"^\s*(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*(?P<value>\S.*)$")
|
|
_LIST_KEY = re.compile(rf"^(?P<indent>\s*)(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*$")
|
|
_LIST_ITEM = re.compile(r"^(?P<indent>\s*)-\s*(?P<value>.*)$")
|
|
_BLOCK_KEY = re.compile(
|
|
rf"^(?P<indent>\s*)(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*[>|][+-]?\d*\s*$"
|
|
)
|
|
|
|
|
|
def _candidate_paths() -> list[Path]:
|
|
out: list[Path] = []
|
|
for s in iter_project_files(extensions=_SCAN_SUFFIXES):
|
|
p = Path(s)
|
|
try:
|
|
rel = p.relative_to(PROJECT_ROOT)
|
|
except ValueError:
|
|
continue
|
|
parts = rel.parts
|
|
if len(parts) < 3 or parts[0] != "roles":
|
|
continue
|
|
if parts[1] == "meta" and p.name in _EXCLUDED_META_FILES:
|
|
continue
|
|
if not any(sub in parts for sub in _SCAN_SUBDIRS):
|
|
continue
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def _hits_for(lines: list[str]) -> list[tuple[int, str]]:
|
|
"""Return (1-based line number, snippet) for each templated conditional."""
|
|
hits: list[tuple[int, str]] = []
|
|
for idx, line in enumerate(lines, start=1):
|
|
block_key = _BLOCK_KEY.match(line)
|
|
if block_key:
|
|
key_indent = len(block_key.group("indent"))
|
|
for follow_no in range(idx + 1, len(lines) + 1):
|
|
follow = lines[follow_no - 1]
|
|
if not follow.strip():
|
|
continue
|
|
if len(follow) - len(follow.lstrip()) <= key_indent:
|
|
break
|
|
if _JINJA.search(follow):
|
|
hits.append((idx, line.strip()))
|
|
break
|
|
continue
|
|
scalar = _SCALAR_KEY.match(line)
|
|
if scalar:
|
|
if _JINJA.search(scalar.group("value")):
|
|
hits.append((idx, line.strip()))
|
|
continue
|
|
list_key = _LIST_KEY.match(line)
|
|
if not list_key:
|
|
continue
|
|
key_indent = len(list_key.group("indent"))
|
|
for follow_no in range(idx + 1, len(lines) + 1):
|
|
follow = lines[follow_no - 1]
|
|
if not follow.strip():
|
|
continue
|
|
item = _LIST_ITEM.match(follow)
|
|
if item is None or len(item.group("indent")) <= key_indent:
|
|
break
|
|
if _JINJA.search(item.group("value")):
|
|
hits.append((follow_no, follow.strip()))
|
|
return hits
|
|
|
|
|
|
class TestNoTemplatedConditionals(unittest.TestCase):
|
|
def test_no_templated_conditionals(self) -> None:
|
|
offenders: dict[Path, list[str]] = {}
|
|
for path in _candidate_paths():
|
|
try:
|
|
text = read_text(str(path))
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
if not _JINJA.search(text):
|
|
continue
|
|
lines = text.splitlines()
|
|
file_hits: list[str] = []
|
|
for line_no, snippet in _hits_for(lines):
|
|
if is_suppressed_at(lines, line_no, _RULE, mode="same-or-above"):
|
|
continue
|
|
file_hits.append(f"line {line_no}: {snippet}")
|
|
if file_hits:
|
|
offenders[path] = file_hits
|
|
|
|
if offenders:
|
|
report = [
|
|
(
|
|
f"{sum(len(v) for v in offenders.values())} templated Ansible "
|
|
"conditional(s) found. Conditionals (when / failed_when / "
|
|
"changed_when / until) must be BARE expressions, never wrapped "
|
|
"in {{ }} or {% %}."
|
|
),
|
|
(
|
|
" Fix: drop the delimiters (`when: FLAG | bool`). For per-item "
|
|
"render/apply data guards, use a `condition:` data field (the "
|
|
"sys-svc-compose render helper and LISTMONK_SETTINGS honor it)."
|
|
),
|
|
f" Suppress a genuine exception with `# nocheck: {_RULE}`.",
|
|
]
|
|
for path, issues in sorted(offenders.items()):
|
|
report.append(f" - {path.relative_to(PROJECT_ROOT)}:")
|
|
report.extend(f" * {i}" for i in issues)
|
|
self.fail("\n".join(report))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|