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
« 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).
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.
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.
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"""
20from __future__ import annotations
22import argparse
23import sys
24from collections.abc import Callable
25from typing import Any
27from . import providerprobe, wizard
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)
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)
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.
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()
54def _default_isatty() -> bool: # pragma: no cover - reads the real stdin/stdout
55 return sys.stdin.isatty() and sys.stdout.isatty()
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``.
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
123def _jury_answer(args: argparse.Namespace, policy: Any) -> str:
124 """Where the jury question starts: the flags, then ``knobs.team.jury.mode``.
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
141def apply_resolution(args: argparse.Namespace, resolution: wizard.Resolution) -> None:
142 """Write the resolved answers back onto the parsed flags.
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`.
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)