Coverage for src/keel/wizardrun.py: 100%

69 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""Thin I/O: drive a ``--wizard`` run for ``keel ship`` / ``keel work-block`` (#1018). 

2 

3The decisions are :mod:`keel.wizard`'s and stay pure. This module owns the three edges 

4that cannot be: the provider probe (:func:`keel.providerprobe.collect`), the terminal 

5(``input``, and the ``isatty`` check that decides whether there is one), and the parsed 

6``argparse`` namespace the resolved answers are written back onto. All three are 

7injectable, so the whole surface is unit-testable offline. 

8 

9The **interactivity guard** is the contract ``ship.md`` has documented since the flag 

10existed: in any non-interactive context — watch mode, an overnight or background run, 

11a pipe — the wizard is a *logged no-op* and the command proceeds with the literal flags 

12as parsed. Never a hang waiting on a stdin nobody is typing into, never a rejection of 

13a run that was perfectly well specified on the command line. 

14 

15``--wizard-answer KEY=VALUE`` is the third path: recorded answers, applied without 

16prompting, on a TTY or not. That is what makes a wizard run reproducible — and what 

17lets keel's own tests drive every question without a terminal. 

18""" 

19 

20from __future__ import annotations 

21 

22import argparse 

23import sys 

24from collections.abc import Callable 

25from typing import Any 

26 

27from . import providerprobe, wizard 

28 

29#: Printed instead of prompting when there is no terminal and no recorded answers. 

30NON_INTERACTIVE = ( 

31 "wizard: non-interactive context — logged no-op; proceeding with the flags as parsed" 

32) 

33 

34#: Printed when the probe found nothing usable. Also a no-op: a wizard whose every 

35#: question would offer an empty list is worse than no wizard. 

36NO_PROVIDERS = ( 

37 "wizard: no provider is available on this machine — logged no-op; run " 

38 "`keel doctor --providers` to see why" 

39) 

40 

41 

42def _default_ask(prompt: str, default: str) -> str: # pragma: no cover - interactive I/O 

43 """Read one answer. A blank line is returned **as** a blank line, not as the default. 

44 

45 :func:`keel.wizard.run` needs to tell "I accept the default" from "I chose the value 

46 that happens to be the default": the first writes no flag and leaves the option to 

47 `knobs.team` and the risk tier, the second is an explicit override. Substituting the 

48 default here — as `cli._ask` does for the scaffolder's free-text questions — would 

49 collapse the two. 

50 """ 

51 return input(f"{prompt}\n answer [{default}]: ").strip() 

52 

53 

54def _default_isatty() -> bool: # pragma: no cover - reads the real stdin/stdout 

55 return sys.stdin.isatty() and sys.stdout.isatty() 

56 

57 

58def run_option_wizard( 

59 args: argparse.Namespace, 

60 config: Any, 

61 *, 

62 command: str, 

63 _probe: Callable[[Any], dict[str, object]] | None = None, 

64 _ask: Callable[[str, str], str] | None = None, 

65 _isatty: Callable[[], bool] | None = None, 

66) -> int: 

67 """Run the option wizard and write its answers back onto ``args``. 

68 

69 Returns ``0`` when the command may proceed (including every no-op path) and ``1`` 

70 when the operator's own input was wrong — a malformed ``--wizard-answer``, or one 

71 naming a choice the wizard does not offer. Those fail closed on purpose: silently 

72 ignoring a misspelled answer would run a team the operator did not ask for. 

73 """ 

74 if not getattr(args, "wizard", False): 

75 return 0 

76 # Resolved here, not in the signature: a default argument binds the function object 

77 # at import time, which makes the seam unpatchable from a test that wants to stub 

78 # the probe for a whole module rather than for one call. 

79 probe = _probe if _probe is not None else providerprobe.collect 

80 ask = _ask if _ask is not None else _default_ask 

81 isatty = _isatty if _isatty is not None else _default_isatty 

82 # stdout carries the JSON contract when --json is on, so the wizard's own prose 

83 # moves to stderr rather than corrupting a document a host is about to parse. 

84 stream = sys.stderr if getattr(args, "json", False) else sys.stdout 

85 answers, malformed = wizard.parse_answer_args(getattr(args, "wizard_answer", ()) or ()) 

86 if malformed: 

87 for message in malformed: 

88 print(message, file=sys.stderr) 

89 return 1 

90 if not answers and not isatty(): 

91 print(NON_INTERACTIVE, file=stream) 

92 return 0 

93 catalog = wizard.Catalog.from_report(probe(config)) 

94 if not catalog.candidates: 

95 print(NO_PROVIDERS, file=stream) 

96 return 0 

97 policy = config.knobs.team 

98 for name in wizard.unavailable(policy, catalog): 

99 print(f"wizard: knobs.team names {name!r}, which is not usable here", file=stream) 

100 state = wizard.start( 

101 catalog, 

102 policy=policy, 

103 scope=wizard.SCOPE_RUN, 

104 review_comments=getattr(args, "review_comments", "inline") or "inline", 

105 jury=_jury_answer(args, policy), 

106 delegate=getattr(args, "delegate", None), 

107 ) 

108 if answers: 

109 state, rejected = wizard.apply_answers(state, answers) 

110 if rejected: 

111 for message in rejected: 

112 print(f"wizard: {message}", file=sys.stderr) 

113 return 1 

114 else: 

115 state = wizard.run(state, ask, lambda message: print(f"wizard: {message}", file=stream)) 

116 resolution = state.resolve() 

117 apply_resolution(args, resolution) 

118 print(f"keel {command} --wizard — resolved", file=stream) 

119 print(wizard.render(resolution), file=stream) 

120 return 0 

121 

122 

123def _jury_answer(args: argparse.Namespace, policy: Any) -> str: 

124 """Where the jury question starts: the flags, then ``knobs.team.jury.mode``. 

125 

126 The fallback is the policy's mode and then :data:`keel.wizard.JURY_OFF` — but that 

127 last one is only the value the *question* opens on, never a decision. An unanswered 

128 jury question writes no flag at all (:meth:`keel.wizard.Resolution.flags`), so a run 

129 whose tier would convene the jury still does. Treating the fallback as an answer is 

130 what made a quick-start run on a tier-3 change pass `--no-jury`. 

131 """ 

132 if getattr(args, "jury_advisory", False): 

133 return "advisory" 

134 if getattr(args, "jury", False): 

135 return "gating" 

136 if getattr(args, "no_jury", False): 

137 return wizard.JURY_OFF 

138 return policy.jury_mode or wizard.JURY_OFF 

139 

140 

141def apply_resolution(args: argparse.Namespace, resolution: wizard.Resolution) -> None: 

142 """Write the resolved answers back onto the parsed flags. 

143 

144 **Only what the operator actually answered.** Every option also has a resolved 

145 default, and writing those defaults back is not neutral: the run wizard's bench is 

146 derived at a nominal tier (the real one is classified at s1, after the wizard) and 

147 the jury default is "whatever the flags and `knobs.team` already say". Materialising 

148 them turned a quick-start run on a tier-3 change into `--reviewers 2 --no-jury` — 

149 one reviewer and the gating jury gone, which is the opposite of what a wizard that 

150 was told to take every default is for. An unanswered option is left exactly as 

151 parsed, so the command resolves it as it would have without `--wizard`. 

152 

153 Only attributes the command actually has are set. ``keel work-block`` takes 

154 ``--reviewers``/``--review-comments`` and hands the rest down to each child 

155 ``keel ship``, so its implementer and jury choices are *echoed* for the adapter to 

156 pass on rather than silently written onto a namespace with nowhere to put them. 

157 """ 

158 seats = resolution.review if isinstance(resolution.review, tuple) else () 

159 answered = resolution.answered 

160 updates: dict[str, Any] = {} 

161 if {"implement.provider", "implement.model"} & answered: 

162 updates["delegate"] = wizard.seat_token(resolution.implement) 

163 if "review" in answered and seats: 

164 updates["review_delegate"] = [wizard.seat_token(seat) for seat in seats] 

165 updates["reviewers"] = len(seats) 

166 if "review_comments" in answered: 

167 updates["review_comments"] = resolution.review_comments 

168 if "jury" in answered: 

169 updates["jury"] = resolution.jury == "gating" 

170 updates["no_jury"] = resolution.jury == wizard.JURY_OFF 

171 updates["jury_advisory"] = resolution.jury == "advisory" 

172 for name, value in updates.items(): 

173 if hasattr(args, name): 

174 setattr(args, name, value)