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

218 statements  

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

1"""`keel init` — scaffold a default `.keel/project.yaml`, or build one with a wizard. 

2 

3Pure + deterministic: :func:`detect_stack` is a function of which marker files exist, 

4:func:`render_config` renders YAML from explicit values, and :func:`wizard` builds those 

5values through an injectable `ask` callback (so the interactive flow is unit-tested 

6offline). The CLI supplies the real `input`-based `ask` and does the file I/O. 

7 

8Since #1018 the wizard also asks who is on the **team**. The questions come from 

9:mod:`keel.wizard`, closed over a catalogue of the providers the probe found on this 

10machine, and their answers are rendered here as the ``knobs.team`` block (#1014) — so 

11a repository is scaffolded naming seats that actually exist rather than seats copied 

12out of somebody else's example. 

13""" 

14 

15from __future__ import annotations 

16 

17import re 

18import shutil 

19import tomllib 

20from collections.abc import Callable 

21from pathlib import Path 

22from typing import Any 

23 

24from . import config as cfg 

25from . import consent 

26from . import wizard as wizard_core 

27from . import yaml_helper as yaml 

28 

29#: marker file (checked in order) -> stack name. 

30_MARKERS: tuple[tuple[str, str], ...] = ( 

31 ("pubspec.yaml", "flutter"), 

32 ("build.gradle", "android"), 

33 ("build.gradle.kts", "android"), 

34 ("pom.xml", "java"), 

35 ("Cargo.toml", "rust"), 

36 ("go.mod", "go"), 

37 ("pyproject.toml", "python"), 

38 ("setup.py", "python"), 

39 ("requirements.txt", "python"), 

40 ("Pipfile", "python"), 

41 ("package.json", "node"), 

42) 

43 

44#: per-stack defaults: platform, build cmd, lint cmd (or None), tier-3 globs. 

45_TEMPLATES: dict[str, dict] = { 

46 "flutter": { 

47 "platform": "flutter", 

48 "build": "flutter test", 

49 "lint": "flutter analyze", 

50 "globs": ("lib/**/*.dart",), 

51 }, 

52 "python": { 

53 "platform": "python", 

54 "build": "make test", 

55 "lint": "ruff check .", 

56 "globs": ("src/**/*.py",), 

57 }, 

58 "node": { 

59 "platform": "node", 

60 "build": "npm test", 

61 "lint": "npm run lint", 

62 "globs": ("src/**/*.ts", "src/**/*.js"), 

63 }, 

64 "android": { 

65 "platform": "android", 

66 "build": "./gradlew test", 

67 "lint": "./gradlew lint", 

68 "globs": ("app/src/**",), 

69 }, 

70 "rust": { 

71 "platform": "rust", 

72 "build": "cargo test", 

73 "lint": "cargo clippy", 

74 "globs": ("src/**/*.rs",), 

75 }, 

76 "go": { 

77 "platform": "go", 

78 "build": "go test ./...", 

79 "lint": "golangci-lint run", 

80 "globs": ("**/*.go",), 

81 }, 

82 "java": { 

83 "platform": "java", 

84 "build": "mvn test", 

85 "lint": "mvn checkstyle:check", 

86 "globs": ("src/main/**",), 

87 }, 

88 # No stack, so no command keel can name (#1328): `make test` only when the Makefile 

89 # has that rule (see template_for), else none — the build gate is then unconfigured. 

90 "generic": {"platform": "generic", "build": None, "lint": None, "globs": ()}, 

91} 

92 

93#: Written above ``knobs:`` when no build command was found (#1328), so the file itself 

94#: says what to set. 

95_UNSET_BUILD_COMMENT = ( 

96 "# build_gate_cmd is not set: keel found no test command for this project (no stack", 

97 "# detected, and no Makefile `test` rule). Until you set it under knobs, the `build`", 

98 "# gate blocks every run. For example:", 

99 '# build_gate_cmd: "./scripts/test.sh"', 

100) 

101 

102 

103# A rule line names its targets before a `:` or `::`. `test := x`, `test ::= x` and 

104# `test ?= x` assign a variable instead, and a line starting with a tab is a recipe 

105# (#1301 review). Leading spaces are allowed: a rule inside `ifdef` may be indented. 

106_MAKE_RULE = re.compile(r"^([^:#=\t][^:#=]*?)[ \t]*::?(?![:=])") 

107# `pytest` or a `pytest-…` / `pytest_…` plugin (which depends on it; PEP 503 makes the 

108# two spellings one name) as a requirement or a table key — never 

109# `flake8-pytest-style`, whose name only contains the word. 

110_PYTEST_NAME = re.compile(r"(?i)pytest(?:[-_.][\w.-]*)?") 

111_PYTEST_REQUIREMENT = re.compile(r"(?i)^\s*pytest(?:[-_.][\w.-]*)?\s*(?:[\[(<>=!~;@,]|$)") 

112_PYTEST_WORD = re.compile(r"(?i)(?<![\w.-])pytest(?![a-z0-9])") 

113 

114 

115def _read_text(path: Path) -> str | None: 

116 """A file's text, or None when it is absent or cannot be read — no evidence.""" 

117 try: 

118 return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None 

119 except OSError: 

120 return None 

121 

122 

123def _read_toml(path: Path) -> dict: 

124 text = _read_text(path) 

125 if text is None: 

126 return {} 

127 try: 

128 return tomllib.loads(text) 

129 except tomllib.TOMLDecodeError: 

130 return {} 

131 

132 

133def _makefile_has_target(root: Path, target: str) -> bool: 

134 """Whether the project's Makefile has a rule for ``target``.""" 

135 for name in ("GNUmakefile", "makefile", "Makefile"): 

136 path = root / name 

137 if path.is_file(): 

138 text = _read_text(path) or "" 

139 for line in text.splitlines(): 

140 rule = _MAKE_RULE.match(line) 

141 if rule and target in rule.group(1).split(): 

142 return True 

143 return False 

144 return False 

145 

146 

147def _toml_names_pytest(node: object) -> bool: 

148 """Whether a parsed TOML tree names pytest: a ``[tool.pytest…]`` table, a Poetry or 

149 Pipfile ``pytest = "…"`` key, or a PEP 508 requirement string anywhere in it. 

150 

151 Deliberately not bounded by table: Hatch, PDM, Rye, uv and Flit each keep 

152 dependencies somewhere else, and a string that parses as a pytest requirement 

153 almost always belongs to a project that runs pytest.""" 

154 if isinstance(node, dict): 

155 return any( 

156 _PYTEST_NAME.fullmatch(str(key)) or _toml_names_pytest(value) 

157 for key, value in node.items() 

158 ) 

159 if isinstance(node, list): 

160 return any(_toml_names_pytest(item) for item in node) 

161 return isinstance(node, str) and _PYTEST_REQUIREMENT.match(node) is not None 

162 

163 

164def _text_names_pytest(text: str) -> bool: 

165 """Whether an INI, ``setup.py`` or requirements text names pytest outside a comment.""" 

166 return any(_PYTEST_WORD.search(line.split("#", 1)[0]) for line in text.splitlines()) 

167 

168 

169def _uses_pytest(root: Path) -> bool: 

170 """Whether the project runs pytest — configured *or* merely depended on. 

171 

172 Most pytest projects keep no pytest config at all: plain ``def test_…`` 

173 functions and ``pytest`` in their dependencies. ``unittest discover`` finds 

174 none of those and, from Python 3.12, exits 5 ("NO TESTS RAN") — the same 

175 blocked gate by another route — so a dependency on pytest counts too. 

176 """ 

177 for name in ("pytest.ini", ".pytest.ini", "conftest.py"): 

178 if (root / name).is_file(): 

179 return True 

180 if any((root / d / "conftest.py").is_file() for d in ("tests", "test")): 

181 return True 

182 if any(_toml_names_pytest(_read_toml(root / n)) for n in ("pyproject.toml", "Pipfile")): 

183 return True 

184 texts = [root / n for n in ("setup.cfg", "tox.ini", "setup.py", "noxfile.py")] 

185 patterns = ("*requirements*.txt", "*requirements*.in") 

186 for pattern in (*patterns, "requirements/**/*.txt", "requirements/**/*.in"): 

187 texts += sorted(root.glob(pattern)) 

188 return any(_text_names_pytest(_read_text(path) or "") for path in texts) 

189 

190 

191def _uses_ruff(root: Path) -> bool: 

192 """Whether the project configures ruff: its own file, or a ``[tool.ruff]`` table.""" 

193 if (root / "ruff.toml").is_file() or (root / ".ruff.toml").is_file(): 

194 return True 

195 tool = _read_toml(root / "pyproject.toml").get("tool") 

196 return isinstance(tool, dict) and "ruff" in tool 

197 

198 

199def _unittest_command(root: Path, py: str) -> str: 

200 """``unittest discover`` pointed where the tests are. 

201 

202 Discovery recurses only into packages, so from the root it finds nothing in a 

203 ``tests/`` directory without an ``__init__.py`` — the most common layout — and 

204 exits 5 (#1301 review). Such a directory is named with ``-s``; a package, or no 

205 tests directory, keeps the plain command. 

206 

207 Two layouts stay out of reach of any ``unittest`` command: tests in a 

208 subdirectory that is not a package (``tests/unit/`` without ``__init__.py``), 

209 which discovery never enters, and a ``src/`` layout whose package is not 

210 installed — the gate runs after the project's own install, as pytest's would. 

211 """ 

212 for directory in ("tests", "test"): 

213 if (root / directory).is_dir() and not (root / directory / "__init__.py").is_file(): 

214 return f"{py} -m unittest discover -s {directory}" 

215 return f"{py} -m unittest discover" 

216 

217 

218def _python_interpreter() -> str: 

219 """``python`` where it exists, else ``python3``. 

220 

221 A venv — and Windows — has ``python``; a macOS or Debian machine outside one has 

222 only ``python3``. Writing either one blindly scaffolds a gate that fails with 

223 "command not found" on the other, the same broken gate #1297 is about. 

224 """ 

225 return "python" if shutil.which("python") else "python3" 

226 

227 

228def _python_gates(root: Path) -> tuple[str, str | None]: 

229 """The build and lint commands a Python project can actually run (#1297). 

230 

231 The template wrote ``make test`` whatever the project held, so a Python project 

232 with no Makefile scaffolded a gate that BLOCKs every ship with 

233 ``make: *** No rule to make target `test'``; and ``ruff check .`` whether or not 

234 ruff is part of the project. Build: the Makefile's ``test`` rule if there is one, 

235 else pytest if the project configures or depends on it, else ``unittest``, which 

236 ships with Python. Lint: ruff only when the project configures ruff; otherwise no 

237 lint gate, which the operator can add. 

238 """ 

239 py = _python_interpreter() 

240 if _makefile_has_target(root, "test"): 

241 build = "make test" 

242 elif _uses_pytest(root): 

243 build = f"{py} -m pytest" 

244 else: 

245 build = _unittest_command(root, py) 

246 return build, ("ruff check ." if _uses_ruff(root) else None) 

247 

248 

249def template_for(stack: str, root: str | Path | None = None) -> dict: 

250 """The stack's defaults, resolved against the project at ``root`` when given.""" 

251 t = dict(_TEMPLATES.get(stack, _TEMPLATES["generic"])) 

252 if stack == "python" and root is not None: 

253 t["build"], t["lint"] = _python_gates(Path(root)) 

254 elif t["platform"] == "generic" and root is not None: 

255 # The one command a stackless project can be shown to have (#1328). 

256 if _makefile_has_target(Path(root), "test"): 

257 t["build"] = "make test" 

258 return t 

259 

260 

261def missing_build_gate(text: str) -> bool: 

262 """Whether a rendered config leaves ``knobs.build_gate_cmd`` unset (#1328).""" 

263 knobs = yaml.load(text).get("knobs") or {} 

264 return not knobs.get("build_gate_cmd") 

265 

266 

267def detect_stack(root: str | Path) -> str: 

268 """Detect the project stack from marker files (``generic`` if none match).""" 

269 root = Path(root) 

270 for marker, stack in _MARKERS: 

271 if (root / marker).exists(): 

272 return stack 

273 return "generic" 

274 

275 

276def detect_base_branch(root: str | Path) -> str: 

277 """Detect the repository's default base branch (defaults to ``main``).""" 

278 root = Path(root) 

279 head_file = root / ".git" / "HEAD" 

280 if head_file.exists(): 

281 try: 

282 content = head_file.read_text(encoding="utf-8").strip() 

283 if content.startswith("ref: refs/heads/"): 

284 ref_name = content[len("ref: refs/heads/") :].strip() 

285 if ref_name in ("main", "master", "develop", "trunk"): 

286 return ref_name 

287 except OSError: 

288 return "main" 

289 return "main" 

290 

291 

292def auto_detect_config( 

293 root: str | Path, 

294 *, 

295 repo: str = "my-repo", 

296 owner: str | None = None, 

297) -> tuple[str, dict]: 

298 """Inspect the repository stack and base branch, returning (yaml_text, metadata).""" 

299 root = Path(root) 

300 stack = detect_stack(root) 

301 base_branch = detect_base_branch(root) 

302 t = template_for(stack, root) 

303 meta = { 

304 "stack": stack, 

305 "platform": t["platform"], 

306 "base_branch": base_branch, 

307 "owner": owner, 

308 "build_cmd": t["build"], 

309 "lint_cmd": t["lint"], 

310 "tier3_globs": t["globs"], 

311 } 

312 text = render_config( 

313 repo=repo, 

314 owner=owner, 

315 base_branch=base_branch, 

316 platform=t["platform"], 

317 build_cmd=t["build"], 

318 lint_cmd=t["lint"], 

319 tier3_globs=t["globs"], 

320 generator="keel init --auto", 

321 ) 

322 return text, meta 

323 

324 

325def render_config( 

326 *, 

327 repo: str = "my-repo", 

328 owner: str | None = None, 

329 base_branch: str = "main", 

330 platform: str = "generic", 

331 build_cmd: str | None = "make test", 

332 lint_cmd: str | None = None, 

333 tier3_globs: tuple[str, ...] = (), 

334 timezone: str | None = None, 

335 merge_window: str | None = None, 

336 consent_mode: str = "explicit", 

337 team: dict[str, Any] | None = None, 

338 generator: str = "keel init", 

339) -> str: 

340 """Render a valid ``project.yaml`` from explicit values (passes ``keel validate``). 

341 

342 ``team`` is a ``knobs.team`` block (:meth:`keel.wizard.Resolution.team_block`). 

343 ``None`` — the default — writes no block at all, which is *not* the same as writing 

344 an empty one: an absent ``team`` leaves ``config_hash`` exactly where it was for 

345 every project that never opted in (:func:`keel.team.canonical`). 

346 """ 

347 if consent_mode not in consent.CONSENT_MODES: 

348 raise ValueError( 

349 f"unknown consent mode {consent_mode!r}; valid: {', '.join(consent.CONSENT_MODES)}" 

350 ) 

351 if bool(timezone) != bool(merge_window): 

352 raise ValueError( 

353 "timezone and merge_window are all-or-nothing: pass both to configure a merge " 

354 "window or neither to configure none — a config with one of them is a config " 

355 "`keel validate` refuses (#1076)" 

356 ) 

357 generator_comment = " ".join(str(generator).splitlines()) 

358 lines = [ 

359 f"# keel consumer config (generated by `{generator_comment}`)", 

360 "extends: keel", 

361 'core_version: "^1.0"', 

362 f"repo: {_yaml_scalar(repo)}", 

363 # `owner` completes the `owner/repo` a live evidence/merge run needs; omitted (not 

364 # written blank) when no git remote named one, so `keel validate` still passes and the 

365 # operator fills it in (#1247). 

366 *([f"owner: {_yaml_scalar(owner)}"] if owner else []), 

367 f"base_branch: {_yaml_scalar(base_branch)}", 

368 f"platform: {_yaml_scalar(platform)}", 

369 f"consent_mode: {_yaml_scalar(consent_mode)}", 

370 ] 

371 if timezone: 

372 lines.append(f"timezone: {_yaml_scalar(timezone)}") 

373 if merge_window: 

374 lines.append(f"merge_window: {_yaml_scalar(merge_window)}") 

375 knobs = [f" build_gate_cmd: {_yaml_scalar(build_cmd)}"] if build_cmd else [] 

376 if team: 

377 knobs.append(" team:") 

378 knobs.extend(_render_mapping(team, 2)) 

379 if lint_cmd: 

380 knobs.append(f" lint_cmd: {_yaml_scalar(lint_cmd)}") 

381 if tier3_globs: 

382 knobs.append(" tier3_globs:") 

383 knobs.extend(_render_sequence(list(tier3_globs), 2)) 

384 lines.append("") 

385 if not build_cmd: 

386 # No command, but `build` stays in `gates:` below: an unconfigured gate is planned 

387 # and blocks with a finding naming the knob, where an absent one would pass (#1328). 

388 lines.extend(_UNSET_BUILD_COMMENT) 

389 lines += ["knobs:", *knobs] if knobs else ["knobs: {}"] 

390 gates = "[build, lint]" if lint_cmd else "[build]" 

391 lines += ["", f"gates: {gates}", "extensions: {}", "extensions_dir: .keel/extensions", ""] 

392 return "\n".join(lines) 

393 

394 

395def _yaml_scalar(value: str) -> str: 

396 """Render a scalar as inline YAML so scaffolded values cannot inject new keys.""" 

397 return yaml.dump( 

398 str(value), 

399 default_style='"', 

400 default_flow_style=True, 

401 width=10**6, 

402 sort_keys=False, 

403 ).strip() 

404 

405 

406def _yaml_key(key: str) -> str: 

407 """A mapping key, quoted unless it is a plain identifier. 

408 

409 The tier keys of ``review.by_tier`` are the reason: ``"1"`` has to stay quoted 

410 because YAML reads a bare ``1:`` as an *integer* key, which the schema cannot 

411 describe and :func:`keel.team._tier_key_issues` rejects by name. 

412 """ 

413 return key if key.isidentifier() else _yaml_scalar(key) 

414 

415 

416def _yaml_value(value: Any) -> str: 

417 return str(value) if isinstance(value, int) else _yaml_scalar(value) 

418 

419 

420def _render_mapping(data: dict[str, Any], indent: int) -> list[str]: 

421 """Block-style YAML for a nested mapping of scalars, mappings and lists.""" 

422 pad = " " * indent 

423 lines: list[str] = [] 

424 for key, value in data.items(): 

425 if isinstance(value, dict): 

426 lines.append(f"{pad}{_yaml_key(key)}:") 

427 lines.extend(_render_mapping(value, indent + 1)) 

428 elif isinstance(value, list): 

429 lines.append(f"{pad}{_yaml_key(key)}:") 

430 lines.extend(_render_sequence(value, indent + 1)) 

431 else: 

432 lines.append(f"{pad}{_yaml_key(key)}: {_yaml_value(value)}") 

433 return lines 

434 

435 

436def _render_sequence(items: list[Any], indent: int) -> list[str]: 

437 pad = " " * indent 

438 lines: list[str] = [] 

439 for item in items: 

440 if isinstance(item, dict): 

441 rendered = _render_mapping(item, indent + 1) 

442 lines.append(f"{pad}- {rendered[0].strip()}") 

443 lines.extend(rendered[1:]) 

444 else: 

445 lines.append(f"{pad}- {_yaml_value(item)}") 

446 return lines 

447 

448 

449def default_config( 

450 stack: str, 

451 *, 

452 repo: str = "my-repo", 

453 owner: str | None = None, 

454 base_branch: str = "main", 

455 root: str | Path | None = None, 

456) -> str: 

457 """Render the default ``project.yaml`` for ``stack`` (non-interactive).""" 

458 t = template_for(stack, root) 

459 return render_config( 

460 repo=repo, 

461 owner=owner, 

462 base_branch=base_branch, 

463 platform=t["platform"], 

464 build_cmd=t["build"], 

465 lint_cmd=t["lint"], 

466 tier3_globs=t["globs"], 

467 ) 

468 

469 

470def _ignore(_message: str) -> None: 

471 """Default ``notify``: a caller that did not ask for feedback gets none.""" 

472 

473 

474def wizard( 

475 stack: str, 

476 ask: Callable[[str, str], str], 

477 *, 

478 repo: str = "my-repo", 

479 owner: str | None = None, 

480 base_default: str = "main", 

481 catalog: wizard_core.Catalog | None = None, 

482 notify: Callable[[str], None] | None = None, 

483 root: str | Path | None = None, 

484) -> str: 

485 """Build a config by asking for each value, defaulting to the stack template. 

486 

487 ``ask(prompt, default)`` returns the chosen value (an empty answer ⇒ the default). 

488 Pure given ``ask`` — the CLI passes a real `input`-based implementation. 

489 

490 The ``timezone`` + ``merge_window`` pair is one question, not two — see 

491 :func:`merge_window_answers` — so no answer can produce the half-configured pair 

492 ``parse_config`` refuses (#1082). 

493 

494 ``catalog`` is the providers the probe found usable on this machine (#1018). Given 

495 one, the wizard adds the **team step**: who implements, who gives the mandatory 

496 gate review, who reviews at each risk tier, and how the jury gates — every option 

497 drawn from the catalogue, so the scaffolded ``knobs.team`` cannot name a provider 

498 this machine has never had. Without one (or with an empty one — a machine where 

499 nothing is installed yet) the step is skipped and no ``team`` block is written. 

500 """ 

501 t = template_for(stack, root) 

502 report = _ignore if notify is None else notify 

503 base = ask("Base branch", base_default) 

504 tz, win = merge_window_answers(ask, report) 

505 mode = ask("Consent mode (explicit, standing, agent)", "explicit") or "explicit" 

506 build_prompt = "Build/test command" 

507 if not t["build"]: 

508 build_prompt += " (blank leaves it unset: the build gate then blocks until you set it)" 

509 build = ask(build_prompt, t["build"] or "") 

510 lint = ask("Lint command (blank to skip)", t["lint"] or "") 

511 return render_config( 

512 repo=repo, 

513 owner=owner, 

514 base_branch=base, 

515 platform=t["platform"], 

516 build_cmd=build or None, 

517 lint_cmd=lint or None, 

518 tier3_globs=t["globs"], 

519 timezone=tz, 

520 merge_window=win, 

521 consent_mode=mode, 

522 team=team_block(ask, catalog, notify=report), 

523 generator="keel init --wizard", 

524 ) 

525 

526 

527def merge_window_answers( 

528 ask: Callable[[str, str], str], 

529 notify: Callable[[str], None], 

530) -> tuple[str | None, str | None]: 

531 """Ask for the ``timezone`` + ``merge_window`` pair as **one** decision (#1082). 

532 

533 The two used to be independent prompts, each advertising "blank to skip", which the 

534 all-or-nothing rule #1076 made unanswerable: answer one, skip the other, and the 

535 wizard wrote a ``project.yaml`` the very next ``keel validate`` rejected. So there 

536 is a single yes/no gate now, and on *yes* neither half is skippable. 

537 

538 Each answer is checked with the function ``parse_config`` will check it with 

539 (:func:`keel.config.timezone_issue` / :func:`keel.config.merge_window_issue`) and a 

540 value that will not evaluate is reported and asked for again, at most 

541 :data:`keel.wizard.MAX_ATTEMPTS` times — the same bounded re-ask as the team step, 

542 because a wizard that argues forever is a hang. Giving up drops the *pair*, never 

543 one half of it, so every path out of here is a pair the config layer accepts: 

544 ``(zone, window)`` or ``(None, None)``. 

545 """ 

546 if not _asked_yes(ask, "Configure a merge window (timezone + hours)? (y/n)"): 

547 return None, None 

548 tz = _ask_evaluable(ask, notify, "Timezone (IANA)", "Europe/Istanbul", cfg.timezone_issue) 

549 win = ( 

550 None 

551 if tz is None 

552 else _ask_evaluable( 

553 ask, notify, "Merge window HH:MM-HH:MM", "07:00-01:30", cfg.merge_window_issue 

554 ) 

555 ) 

556 if tz is None or win is None: 

557 notify( 

558 "no merge window configured: 'timezone' and 'merge_window' are all-or-nothing, " 

559 "so neither was written — re-run the wizard, or add both by hand" 

560 ) 

561 return None, None 

562 return tz, win 

563 

564 

565def _asked_yes(ask: Callable[[str, str], str], prompt: str, default: str = "y") -> bool: 

566 """True if the operator answered yes; a blank answer keeps ``default``. 

567 

568 Yes is ``y``/``yes`` in any case, anything else is no. The default is *yes*: the 

569 old prompts defaulted to a configured window, and pressing Enter through the wizard 

570 must keep scaffolding the night no-merge window it always did. 

571 """ 

572 return ((ask(prompt, default) or "").strip() or default).lower() in ("y", "yes") 

573 

574 

575def _ask_evaluable( 

576 ask: Callable[[str, str], str], 

577 notify: Callable[[str], None], 

578 prompt: str, 

579 default: str, 

580 issue: Callable[[str], str | None], 

581) -> str | None: 

582 """Ask until ``issue`` finds nothing wrong with the answer (``None`` == gave up).""" 

583 for _ in range(wizard_core.MAX_ATTEMPTS): 

584 value = (ask(prompt, default) or "").strip() or default 

585 problem = issue(value) 

586 if problem is None: 

587 return value 

588 notify(problem) 

589 return None 

590 

591 

592def team_block( 

593 ask: Callable[[str, str], str], 

594 catalog: wizard_core.Catalog | None, 

595 *, 

596 notify: Callable[[str], None] | None = None, 

597) -> dict[str, Any] | None: 

598 """Ask the team questions and return the ``knobs.team`` block (``None`` to skip).""" 

599 # Narrowed before the emptiness check, not after: a machine whose only providers are 

600 # registry entries has nothing a committed policy could name, and a wizard that asked 

601 # anyway would write a `team` block `keel validate` then refuses. 

602 catalog = None if catalog is None else wizard_core.committable(catalog) 

603 if catalog is None or not catalog.candidates: 

604 return None 

605 state = wizard_core.start(catalog, scope=wizard_core.SCOPE_CONFIG) 

606 answered = wizard_core.run(state, ask, notify if notify is not None else _ignore) 

607 return answered.resolve().team_block()