Option C Tools
Loading...
Searching...
No Matches
test_command_flags_introspection.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_cli/test_command_flags_introspection.py
4
5"""
6Purpose
7-------
8OI-523 drift-prevention regression — :data:`oct.cli._COMMAND_FLAGS` is a
9hand-maintained table of ``(flag, description)`` rows rendered by
10:meth:`OctGroup.format_help`. It is easy for a contributor to add a new
11``@click.option`` on a command and forget to append the matching row to
12``_COMMAND_FLAGS``, leaving ``oct --help`` silently out of date.
13
14This test walks every non-hidden Click command / subcommand in the ``oct``
15CLI and asserts that each declared :class:`click.Option` appears
16somewhere in the command's ``_COMMAND_FLAGS`` entry. Running this test in
17CI turns a silent doc drift into a loud unit-test failure.
18
19Responsibilities
20----------------
21- Every top-level Click option on every ``oct`` subcommand is mentioned
22 in the ``_COMMAND_FLAGS`` rows.
23- Every Click group (``git``, ``diag``) exposes its subcommands in the
24 group's ``_COMMAND_FLAGS`` rows.
25- Argparse-routed commands (``lint``, ``format``, ``typecheck``) are
26 skipped because their flags live in ``argparse``, not Click.
27
28Diagnostics
29-----------
30Domain: CLI-TESTS
31Levels:
32 L2 — test lifecycle
33 L3 — assertion details
34 L4 — deep tracing
35
36Contracts
37---------
38- Tests introspect the Click command tree in-process; no subprocess or
39 filesystem writes.
40- Argparse-routed commands are excluded from the check.
41"""
42
43from __future__ import annotations
44
45import click
46import pytest
47
48from oct.cli import _COMMAND_FLAGS, cli
49
50
51# Commands whose flags live in argparse (``oct lint`` etc. accept
52# ``nargs=-1 UNPROCESSED`` and re-parse via argparse). Their flag rows
53# are curated manually; there is no Click-level introspection to check
54# against, so we exclude them from the drift scan.
55_ARGPARSE_ROUTED = {"lint", "format", "typecheck"}
56
57
58def _click_option_names(cmd: click.Command) -> list[str]:
59 """Return the canonical long-option name for each :class:`Option`."""
60 names: list[str] = []
61 for param in cmd.params:
62 if not isinstance(param, click.Option):
63 continue
64 # ``param.opts`` holds every declared option spelling (``--json`` /
65 # ``-j`` / ``--jobs``); pick the longest one as the canonical name.
66 long = max(param.opts, key=len) if param.opts else ""
67 if long:
68 names.append(long)
69 return names
70
71
73 group: click.Group, prefix: str = "",
74) -> list[tuple[str, click.Command]]:
75 """Yield ``(qualified_name, command)`` for every non-hidden command."""
76 out: list[tuple[str, click.Command]] = []
77 ctx = click.Context(group)
78 for name in group.list_commands(ctx):
79 sub = group.get_command(ctx, name)
80 if sub is None or getattr(sub, "hidden", False):
81 continue
82 qname = f"{prefix}{name}"
83 out.append((qname, sub))
84 if isinstance(sub, click.Group):
85 out.extend(_walk_commands(sub, prefix=f"{qname} "))
86 return out
87
88
89def _rendered_for(qname: str) -> str:
90 """Join the flag rows for ``qname``, falling back to the parent group.
91
92 ``_COMMAND_FLAGS`` keys subgroup subcommands under two conventions:
93 ``git`` and ``diag`` either get per-subcommand keys (``git status``)
94 or roll the subcommands into the parent key (``diag``). Either
95 convention satisfies the drift guard.
96 """
97 parts = qname.split()
98 rows = _COMMAND_FLAGS.get(qname, [])
99 if not rows and len(parts) > 1:
100 rows = _COMMAND_FLAGS.get(parts[0], [])
101 return " | ".join(f"{flag} {desc}" for flag, desc in rows)
102
103
104# Commands where the current manual convention rolls subcommand options
105# into the parent group entry; the drift guard accepts this as long as
106# the parent mentions the option. These are allow-listed here so each
107# exception is visible and deliberate rather than permissive by default.
108_ROLLED_INTO_PARENT = {
109 "diag validate-config",
110 "diag list-domains",
111 "diag set-level",
112 "diag migrate-config",
113 "diag restore",
114}
115
116
118 missing: list[tuple[str, str]] = []
119 for qname, cmd in _walk_commands(cli):
120 if qname in _ARGPARSE_ROUTED:
121 continue
122 rendered = _rendered_for(qname)
123 for option_name in _click_option_names(cmd):
124 if option_name in rendered:
125 continue
126 # diag subcommands may document their flags inline in the
127 # ``diag`` parent entry — accept that convention.
128 if qname in _ROLLED_INTO_PARENT and option_name in _rendered_for("diag"):
129 continue
130 missing.append((qname, option_name))
131
132 assert not missing, (
133 "Drift detected — the following @click.option declarations are "
134 "missing from _COMMAND_FLAGS: " + ", ".join(
135 f"{q}:{o}" for q, o in missing
136 )
137 )
138
139
141 # For each Click group (``git``, ``diag``), every non-hidden
142 # subcommand name should appear in the group's _COMMAND_FLAGS entry.
143 missing: list[tuple[str, str]] = []
144 ctx = click.Context(cli)
145 for name in cli.list_commands(ctx):
146 sub = cli.get_command(ctx, name)
147 if not isinstance(sub, click.Group) or getattr(sub, "hidden", False):
148 continue
149 rows = _COMMAND_FLAGS.get(name, [])
150 rendered = " | ".join(f"{flag} {desc}" for flag, desc in rows)
151 sub_ctx = click.Context(sub)
152 for sub_name in sub.list_commands(sub_ctx):
153 sub_cmd = sub.get_command(sub_ctx, sub_name)
154 if sub_cmd is None or getattr(sub_cmd, "hidden", False):
155 continue
156 if sub_name not in rendered:
157 missing.append((name, sub_name))
158
159 assert not missing, (
160 "Drift detected — the following Click subcommands are missing "
161 "from their group's _COMMAND_FLAGS entry: " + ", ".join(
162 f"{g}:{s}" for g, s in missing
163 )
164 )
165
166
168 # Catch typos in _COMMAND_FLAGS keys. Every key must be either a
169 # known top-level command or a "<group> <subcommand>" pair.
170 ctx = click.Context(cli)
171 top_level = set(cli.list_commands(ctx))
172 groups = {
173 name: cli.get_command(ctx, name)
174 for name in top_level
175 if isinstance(cli.get_command(ctx, name), click.Group)
176 }
177
178 stale: list[str] = []
179 for key in _COMMAND_FLAGS:
180 parts = key.split()
181 if len(parts) == 1:
182 if parts[0] not in top_level:
183 stale.append(key)
184 else:
185 group_name, sub_name = parts[0], " ".join(parts[1:])
186 group = groups.get(group_name)
187 if group is None:
188 stale.append(key)
189 continue
190 sub_ctx = click.Context(group)
191 if sub_name.split()[0] not in group.list_commands(sub_ctx):
192 # Allow nested groups (e.g. "git hooks install").
193 nested = group.get_command(sub_ctx, sub_name.split()[0])
194 if not isinstance(nested, click.Group):
195 stale.append(key)
196
197 assert not stale, (
198 "_COMMAND_FLAGS has keys that do not map to real commands: "
199 + ", ".join(stale)
200 )
Definition cli.py:1
list[tuple[str, click.Command]] _walk_commands(click.Group group, str prefix="")