Files
computer-playbook/tests/integration/ansible/handlers/test_names_static.py
kevinveenbirkenbachandClaude Opus 5 0a6e7aab71 test(handlers): one file per contract, and one of them was empty
Four tests were guarding two rules. Both duplicates are gone and what they
were meant to check now lives with the rule it belongs to.

The handler name/length pair: a 120-character limit sat over the same files
as an 80-character one, so it could never fire first, and outside that overlap
it matched `name:` keys that were no task at all -- a volume name, a var. It
found nothing in either half. Deleted; the surviving lint instead grows the
scope it was missing, `roles/*/handlers/**` and `playbooks/**`, because a
handler is a task, and the dispatch input descriptions, because a form line is
read like a job name.

The templated-handler-name pair is worse: the second one skipped every
document with `if not isinstance(doc, dict)`, and a handlers file is a list.
It has been vacuously green for its whole life. Its intent -- every
`handlers/*.yml`, not just `main.yml` -- is now what the surviving test does.

The notify/listen contract itself gains the rule that made the rename in
a8dd7f1 safe: a notify must land on a `listen:` topic, never on a display
name, and both spell out as [a-z_-]+. Following `import_tasks` chains by hand
is gone with it; scanning every project YAML sees the same handlers, plus the
ones a role hands to a generic renderer through a data structure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:43 +02:00

62 lines
2.2 KiB
Python

"""Handler entry points carry a static name.
An entry that declares `listen:` is the notification target of a role, and its
name is what an operator reads in the play output and greps for in a failed
run. Templating that name buys nothing -- the `listen:` topic already carries
every dynamic decision -- and costs a stable log line, plus an
undefined-variable error whenever the play reaches the handler with the
variable unset.
Scope: every `roles/*/handlers/*.yml`, not only `main.yml`, so a role that
splits its handlers by deploy mode is covered too. Sub-tasks *inside* an
included handler file are exempt: nothing notifies them, so their names are
free to name the thing they act on (`Connect '{{ CONTAINER }}' to ...`).
Which topic a notify may bind to, and how those topics are spelled, is the
subject of `test_invoked.py`.
"""
import re
import unittest
from utils.cache.yaml import load_yaml_all
from . import PROJECT_ROOT
JINJA_VAR_PATTERN = re.compile(r"{{.*?}}")
def handler_entries():
"""Every ``listen:``-bearing entry of every handlers file, with its path."""
for path in sorted((PROJECT_ROOT / "roles").glob("*/handlers/*.yml")):
try:
docs = list(load_yaml_all(str(path)))
except Exception as error:
yield path, {"name": f"YAML parse error: {error}", "listen": True}
continue
for doc in docs or []:
for entry in doc or []:
if isinstance(entry, dict) and "listen" in entry:
yield path, entry
class StaticHandlerNamesTest(unittest.TestCase):
def test_no_templated_names_in_handlers(self) -> None:
violations = [
f"{path.relative_to(PROJECT_ROOT).as_posix()}: {entry['name']!r}"
for path, entry in handler_entries()
if isinstance(entry.get("name"), str)
and JINJA_VAR_PATTERN.search(entry["name"])
]
self.assertEqual(
violations,
[],
"Handler entry point(s) with a templated name. Keep the name a "
"constant and let the static 'listen:' topic carry the routing:\n"
+ "\n".join(f" {v}" for v in violations),
)
if __name__ == "__main__":
unittest.main()