Coverage for src/footman/_split.py: 93%

554 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Separator-free chain splitting, driven purely by the manifest. 

2 

3`fm build lint --fix test` is split into three independent segments with no 

4separator at all — duty's muscle memory, but with real flags and positionals. 

5A task's *address* is always a single dotted token (`fm docs.serve`), so the 

6first word of every segment names its task completely — no group descent. 

7The manifest gives the splitter exact knowledge of every task's shape, which 

8makes the split deterministic under six rules (see `NOTES`): 

9 

101. params with defaults are options, never positionals (the load-bearing rule); 

112. required positionals are consumed by exact arity, eagerly validated; 

123. options bind to their own segment, and a value is always `=`-attached 

13 (`--target=prod`, `-j=4`) — a bare word is a task or a positional, a bare 

14 `--x`/`-x` is a flag, so every token reads without arity knowledge; 

154. list options repeat the flag (`--tag=a --tag=b`) or comma-join (`--tag=a,b`); 

165. variadic / `--` passthrough segments are terminal; `+` is the always 

17 available explicit boundary; 

186. globals precede the first task name. 

19 

20Every error names the task, states the expectation, and proposes the fix — 

21error messages are product surface here, not diagnostics. The space form of 

22a nested address (`fm docs serve`) is permanently *taught against*, never 

23parsed: the error path detects it and answers with the dotted spelling. 

24""" 

25 

26from __future__ import annotations 

27 

28import contextlib 

29import difflib 

30import os 

31from collections.abc import Callable, Generator, Iterable, Sequence 

32from dataclasses import dataclass, field 

33from pathlib import Path 

34from typing import Annotated, Any, Literal 

35 

36from footman import _coerce, registry 

37from footman.params import between as _between 

38from footman.params import default as _computed 

39 

40 

41def _close1(a: str, b: str) -> bool: 

42 """Damerau-Levenshtein distance exactly 1: one substitution, insertion, 

43 deletion, or adjacent transposition — the "pyhton" shapes a hurried hand 

44 actually types. Bounded and cheap; called only on a group default's 

45 positional values against its child names.""" 

46 if a == b: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true

47 return False 

48 la, lb = len(a), len(b) 

49 if abs(la - lb) > 1: 

50 return False 

51 if la == lb: 

52 diffs = [i for i in range(la) if a[i] != b[i]] 

53 if len(diffs) == 1: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 return True # substitution 

55 return ( 

56 len(diffs) == 2 

57 and diffs[1] == diffs[0] + 1 

58 and a[diffs[0]] == b[diffs[1]] 

59 and a[diffs[1]] == b[diffs[0]] 

60 ) # adjacent transposition 

61 short, long_ = (a, b) if la < lb else (b, a) 

62 i = 0 

63 while i < len(short) and short[i] == long_[i]: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 i += 1 

65 return short[i:] == long_[i + 1 :] # one insertion/deletion 

66 

67 

68def _did_you_mean(word: str, known: Iterable[str]) -> str: 

69 """A ` — did you mean 'x'?` suffix when *word* closely matches a known name. 

70 

71 Empty when nothing is close, so a genuine typo never gets false-confident 

72 advice. The one idiom behind every not-found message (task, option, choice). 

73 """ 

74 close = difflib.get_close_matches(word, list(known), n=1) 

75 return f" — did you mean {close[0]!r}?" if close else "" 

76 

77 

78def _owners_of(tree: dict[str, Any], flag: str) -> list[str]: 

79 """Every task address whose own options include *flag*. 

80 

81 Walked only when a refusal is already certain, so the common path 

82 never pays for it.""" 

83 found: list[str] = [] 

84 

85 def walk(node: dict[str, Any], prefix: str) -> None: 

86 for name, task in node["tasks"].items(): 

87 if any( 

88 "--" + str(p["name"]) == flag 

89 for p in task["params"] 

90 if p["kind"] in ("flag", "option") 

91 ): 

92 found.append(f"{prefix}{name}") 

93 for name, sub in node["groups"].items(): 

94 walk(sub, f"{prefix}{name}.") 

95 

96 walk(tree, "") 

97 return found 

98 

99 

100def _unknown_global( 

101 name: str, 

102 known: dict[str, str], 

103 tree: dict[str, Any] | None = None, 

104 rest: Sequence[str] = (), 

105) -> str: 

106 """The teaching for a dash token that is not a global option. 

107 

108 Two shapes are muscle memory rather than typos, and both deserve their 

109 own sentence instead of "unknown option". A short option wearing its 

110 value (`-j1`, the `make -j4` habit) gets the same teaching the spaced 

111 form already gets — one canonical spelling, taught from whichever way a 

112 hand reaches for it. Combined shorts (`-sq`) get told that footman does 

113 not combine them, rather than being read as a name nobody wrote. 

114 """ 

115 if len(name) > 2 and name[0] == "-" and name[1] != "-": 

116 if f"-{name}" in known: 

117 # `-jobs` is a long name missing a dash, and reading it as the 

118 # short `-j` fused with `obs` produced the comic hint 

119 # "did you mean -j=obs?" — name the spelling the hand meant. 

120 return f"{name} is missing a dash — did you mean -{name}?" 

121 head, tail = name[:2], name[2:] 

122 kind = known.get(head) 

123 if kind == "option": 

124 return f"{head} takes its value attached — did you mean {head}={tail}?" 

125 if kind == "flag" and all(f"-{c}" in known for c in name[1:]): 

126 spelled = " ".join(f"-{c}" for c in name[1:]) 

127 return ( 

128 f"{name} combines short options, which footman does not read " 

129 f"— write them apart: {spelled}" 

130 ) 

131 if provider := _own_plugin_flags().get(name): 

132 # Not unknown — unmounted. The generic sentence sends someone hunting 

133 # for a spelling mistake in a flag they spelled correctly. 

134 return ( 

135 f"{name} comes from {provider}, which this project has not " 

136 f'mounted — add plugin("{provider}") to tasks.py' 

137 ) 

138 if tree is not None and (owners := _owners_of(tree, name)): 

139 # Not a global at all: a task option, written where globals live. 

140 # The generic hint points the wrong way here — the fix is to move it 

141 # RIGHT, past the task that owns it. Name the task they actually 

142 # typed when one of the owners is in the line; several owners and 

143 # none of them typed is the only case that has to stay a list. 

144 typed = [owner for owner in owners if owner in rest] 

145 whose = typed[0] if typed else owners[0] if len(owners) == 1 else "" 

146 if whose: 

147 return ( 

148 f"{name} is an option of {whose}, not a global — it goes " 

149 f"after the task name: {whose} {name}" 

150 ) 

151 listed = ", ".join(owners[:3]) + (", …" if len(owners) > 3 else "") 

152 return ( 

153 f"{name} is a task option, not a global — it goes after the task " 

154 f"that takes it ({listed})" 

155 ) 

156 return f"unknown global option {name} (global options go before the first task)" 

157 

158 

159def _unexposed(dotted: str, tree: dict[str, Any]) -> str: 

160 """Why a real task refuses from here — resolved only once it has. 

161 

162 Which side the refusal comes from is knowable without storing it: a 

163 manifest built for a project can only be withholding a `global_only` 

164 task, one built outside can only be withholding a `project_only` one. 

165 

166 The generic form on purpose: footman knows the task does not belong 

167 here, and it does not know what a particular brand would have you do 

168 about it. Outside a project it names the file it looked for, which is 

169 the fact that actually resolves the confusion, because the reader is 

170 usually a tool that started in the wrong directory rather than a person 

171 wondering how to make a project. 

172 """ 

173 from footman import _app 

174 

175 if tree.get("project"): 

176 return f"{dotted} runs only outside a project" 

177 return ( 

178 f"{dotted} needs a project — no {_app._brand.tasks_file} found here " 

179 f"or in any parent of {os.getcwd()}" 

180 ) 

181 

182 

183def _misplaced_global(token: str) -> str | None: 

184 """The teaching message when *token* is really one of the GLOBALS. 

185 

186 A global option found after a task name is a position mistake, not an 

187 unknown name — so name the real problem and its fix instead of guessing 

188 at close matches. Only fires for *unknown* task options: a task param 

189 that shares a global's name still wins by position, as it should. 

190 """ 

191 name = token.split("=", 1)[0] 

192 if name not in _GLOBAL_KIND: 

193 return None 

194 canon = _CANON.get(name, name) 

195 label = name if name == canon else f"{name} ({canon})" 

196 return f"{label} is a global option — it goes before the first task name" 

197 

198 

199class ChainError(Exception): 

200 """A malformed command line, carrying a teaching message for the user. 

201 

202 `unknown` carries the unresolved head token when the failure was a 

203 task that isn't there — structured, so a catcher can add a remedy (the 

204 brand's built-ins) without parsing its own error text.""" 

205 

206 unknown: str | None = None 

207 

208 

209def _default_jobs() -> int: 

210 """The parallel width nobody chose, imported at the call rather than at 

211 module scope — `_progress` takes `Segment` from here, so importing it up 

212 top would close the cycle.""" 

213 from footman._progress import default_jobs 

214 

215 return default_jobs() 

216 

217 

218# Global options bind to `fm` itself and must precede the first task name 

219# (`--help`/`-h` is the one exception: anywhere before `--`, it wins). 

220# 

221# Declared as instances of the SAME class plugin globals use — one option 

222# model — with core constructed off the registration carriage: core options 

223# are not contributions riding a mount, they are the runner itself. The tuple 

224# table below is a DERIVED VIEW of these declarations: `_parse_globals`, 

225# help, and the docs table keep reading (canonical, alias, kind, hint, 

226# default, help) rows, and the completion hot path's hardcoded mirror is 

227# pinned against the declarations in CI. 

228 

229_REQUIRED = object() 

230# No-default marker for a core declaration: the option has no reading without 

231# a value (`--where` names a task to locate, and there is no default task), 

232# so its bare mention refuses and teaches the attachment. Distinct from a 

233# plugin option's `default=None`, which IS a value — absence hands the owner 

234# `None`, and the bare form is legal by construction. 

235 

236 

237class _CoreOption(registry.GlobalOption): 

238 """A core global's declaration — the plugin class, constructed off the 

239 carriage (`_register` is a no-op): the collision law must see core as 

240 "footman's own" through the derived table, not refuse it against itself. 

241 

242 Carries what the grammar needs beyond the plugin surface: the short 

243 alias (core's namespace, closed to plugins on purpose), the value hint, 

244 `config=True` for an option whose config key of the same name sets its 

245 default, a paired off-spelling's help for a bool that answers to 

246 `--no-x`, and `_REQUIRED` where a bare mention has no reading. Choices 

247 derive from a `Literal` annotation, so the hot-path mirror pins against 

248 the one source. A `bool` annotation is a flag; anything else takes an 

249 `=`-attached value, exactly as for plugins. 

250 """ 

251 

252 __slots__ = ("alias", "choices", "hint", "paired_off_help") 

253 

254 # Core's config keys live directly in the brand table (`[tool.footman] 

255 # jobs`), not under the `plugins.` child a provider's section gets. 

256 _config_scope = "root" 

257 

258 def __init__( 

259 self, 

260 name: str, 

261 annotation: Any = bool, 

262 *, 

263 alias: str | None = None, 

264 hint: str | None = None, 

265 default: Any = None, 

266 config: bool = False, 

267 paired_off_help: str | None = None, 

268 help: str = "", 

269 ) -> None: 

270 self.alias = alias 

271 self.hint = hint 

272 self.paired_off_help = paired_off_help 

273 choices = None 

274 if annotation is not bool: 

275 found = _coerce.all_choices(_coerce.peel(annotation).element) 

276 choices = tuple(found) if found else None 

277 self.choices = choices 

278 super().__init__(name, annotation, default=default, config=config, help=help) 

279 

280 def _register(self) -> None: 

281 """Off the carriage — see the class docstring.""" 

282 

283 

284def _color_from_env() -> str: 

285 """`--color`'s declared default: the ambient protocol variables, else 

286 `auto`. `NO_COLOR` set (to anything) means never; `FORCE_COLOR` set and 

287 non-zero means always. Living in the *default* rung is what keeps the 

288 ladder honest — an explicit `--color=` or a project's `color` key 

289 outranks the environment, because the specific beats the general.""" 

290 if "NO_COLOR" in os.environ: 

291 return "never" 

292 forced = os.environ.get("FORCE_COLOR") 

293 if forced not in (None, "", "0"): 

294 return "always" 

295 return "auto" 

296 

297 

298# The ladder-bearing options, named: their values resolve at bind — CLI > 

299# `env()` > config > `default(fn)` > declared — and `_app` reads the 

300# instances (`JOBS.value`), where pure presence flags stay on the parsed 

301# token dict. A paired bool declares once and derives both spellings; its 

302# declared default is real (`INPUT` is on unless told otherwise). `UV` is 

303# config-backed but consumed lexically: the uv handoff runs before any bind 

304# (it may replace this process) — the one stated early consumer here. 

305SORT = _CoreOption( 

306 "sort", 

307 config=True, 

308 help="list tasks alphabetically (default: as defined)", 

309 paired_off_help="list tasks in definition order", 

310) 

311SEQUENTIAL = _CoreOption( 

312 "sequential", 

313 alias="-s", 

314 config=True, 

315 help="run one at a time, parallel() blocks included", 

316 paired_off_help="run in parallel (undo config)", 

317) 

318JOBS = _CoreOption( 

319 "jobs", 

320 Annotated[int, _between(1, None), _computed(_default_jobs)], 

321 alias="-j", 

322 hint="N", 

323 config=True, 

324 help="max parallel tasks", 

325) 

326INPUT = _CoreOption( 

327 "input", 

328 default=True, 

329 config=True, 

330 help="allow prompting (undo config)", 

331 paired_off_help="never prompt; error if input is required", 

332) 

333COLOR = _CoreOption( 

334 "color", 

335 Annotated[Literal["always", "never", "auto"], _computed(_color_from_env)], 

336 hint="WHEN", 

337 config=True, 

338 help="when to colour: always|never|auto", 

339) 

340PROGRESS = _CoreOption( 

341 "progress", 

342 default=True, 

343 config=True, 

344 help="progress bar, eta and timing (undo config)", 

345 paired_off_help="no progress bar, eta, or timing capture", 

346) 

347UV = _CoreOption( 

348 "uv", 

349 default=True, 

350 config=True, 

351 help="take the uv handoffs (undo config)", 

352 paired_off_help="skip the uv handoffs for this run", 

353) 

354 

355# What `bind_global_options` resolves for a run (`UV` stays lexical, above). 

356CORE_LADDER: tuple[_CoreOption, ...] = (JOBS, COLOR, SORT, SEQUENTIAL, INPUT, PROGRESS) 

357 

358# `default` is what a bare mention means — `_REQUIRED` for an option with no 

359# reading without a value (the question a task option answers with 

360# `required`); a literal where it is a constant; the annotation's 

361# `default(fn)` where it is computed (`--jobs`, `--color`); `""` where the 

362# reading exists but has no spelling of its own: `--describe` means "the 

363# whole tree", `--install-completion` means "whichever shell is asking". 

364CORE_OPTIONS: tuple[_CoreOption, ...] = ( 

365 _CoreOption("help", alias="-h", help="help for {prog}, or the named group/task"), 

366 _CoreOption("version", alias="-V", help="print the version and exit"), 

367 _CoreOption("list", alias="-l", help="list tasks (flat)"), 

368 _CoreOption("tree", help="list tasks grouped by command group"), 

369 SORT, 

370 _CoreOption("all", alias="-a", help="include hidden tasks in the listings"), 

371 _CoreOption( 

372 "where", 

373 str, 

374 hint="TASK", 

375 default=_REQUIRED, 

376 help="print the task's source file:line", 

377 ), 

378 _CoreOption( 

379 "describe", 

380 str, 

381 hint="[ADDR]", 

382 default="", 

383 help="print task contracts as JSON", 

384 ), 

385 _CoreOption("plugins", help="list installed footman.tasks plugins, mounted or not"), 

386 _CoreOption( 

387 "dry-run", alias="-n", help="rehearse: bodies run, footman's work is faked" 

388 ), 

389 _CoreOption("keep-going", alias="-k", help="run every branch even if one fails"), 

390 _CoreOption("fail-fast", help="stop at the first failure"), 

391 SEQUENTIAL, 

392 JOBS, 

393 _CoreOption("yes", alias="-y", help="assume yes to every confirm() gate"), 

394 INPUT, 

395 _CoreOption("quiet", alias="-q", help="suppress the per-task summary"), 

396 _CoreOption("verbose", alias="-v", help="replay captured output even on success"), 

397 COLOR, 

398 _CoreOption("no-color", help="disable ANSI colour (same as --color=never)"), 

399 PROGRESS, 

400 UV, 

401 _CoreOption("json", help="stdout is one JSON document (captures output)"), 

402 _CoreOption("timings", help="show per-task durations"), 

403 _CoreOption( 

404 "directory", 

405 str, 

406 alias="-C", 

407 hint="PATH", 

408 default=_REQUIRED, 

409 help="run as if launched from PATH", 

410 ), 

411 _CoreOption( 

412 "tasks-file", 

413 str, 

414 alias="-f", 

415 hint="PATH", 

416 default=_REQUIRED, 

417 help="only this tasks file, no tasks cascade", 

418 ), 

419 _CoreOption( 

420 "config", 

421 str, 

422 hint="PATH", 

423 default=_REQUIRED, 

424 help="only this config file, no config cascade", 

425 ), 

426 # The bracketed hint marks the value optional: bare `--install-completion` 

427 # / `--setup-completion` detect the invoking shell. Their choices are 

428 # `_shellcomp.SHELLS` — pinned there by the mirror test, not duplicated 

429 # into a third copy here. 

430 _CoreOption( 

431 "install-completion", 

432 str, 

433 hint="[SHELL]", 

434 default="", 

435 help="install shell completion", 

436 ), 

437 _CoreOption( 

438 "setup-completion", 

439 str, 

440 hint="[SHELL]", 

441 default="", 

442 help="print completion for eval", 

443 ), 

444 _CoreOption( 

445 "uninstall-completion", 

446 str, 

447 hint="[SHELL]", 

448 default="", 

449 help="remove the completion hook", 

450 ), 

451) 

452 

453GlobalDefault = str | Callable[[], object] | None 

454 

455 

456def _rows( 

457 o: _CoreOption, 

458) -> list[tuple[str, str | None, str, str | None, GlobalDefault, str]]: 

459 """A declaration as grammar-table rows — two for a paired bool, the 

460 spelling that acts leading (`--no-x` first when the default is on). 

461 Flags derive a `None` default: the column answers "what does a bare 

462 mention of a *value* option mean", and a flag's bare form is simply the 

463 flag. A value option's column is its declared literal, else the 

464 `default(fn)` its annotation carries — one source for `--help` and the 

465 run alike.""" 

466 if o.annotation is bool: 

467 on = (f"--{o.name}", o.alias, "flag", None, None, o.help) 

468 if o.paired_off_help is None: 

469 return [on] 

470 off = (f"--no-{o.name}", None, "flag", None, None, o.paired_off_help) 

471 return [off, on] if o.default else [on, off] 

472 default: GlobalDefault 

473 if o.default is _REQUIRED: 

474 default = None 

475 elif o.default is None: 

476 marker = _coerce.peel(o.annotation).default_fn 

477 # The marker's own fn, unwrapped: a global has no siblings for a 

478 # computed default to read, so the zero-arg call is the contract. 

479 default = None if marker is None else marker.fn 

480 else: 

481 default = o.default 

482 return [(f"--{o.name}", o.alias, "option", o.hint, default, o.help)] 

483 

484 

485GLOBALS: list[tuple[str, str | None, str, str | None, GlobalDefault, str]] = [ 

486 row for o in CORE_OPTIONS for row in _rows(o) 

487] 

488_GLOBAL_KIND = {name: kind for name, _, kind, _, _, _ in GLOBALS} 

489_GLOBAL_KIND.update({alias: kind for _, alias, kind, _, _, _ in GLOBALS if alias}) 

490_CANON = {alias: name for name, alias, _, _, _, _ in GLOBALS if alias} 

491_GLOBAL_HINT = {name: hint for name, _, _, hint, _, _ in GLOBALS if hint} 

492# A global may be named bare exactly when it has a default — something for the 

493# mention to mean. That is the same question a task option answers with 

494# `required`, asked of the table instead of a signature: `--describe` and the 

495# completion trio have readings, `--where` names a task to locate and there is 

496# no default task, so a word behind it is taught rather than quietly becoming 

497# the task to run. The bracketed metavar is help notation now, not the rule. 

498_GLOBAL_DEFAULT = {name: d for name, _, _, _, d, _ in GLOBALS if d is not None} 

499 

500# What a *computed* default is, in words — for surfaces that outlive the 

501# machine they were rendered on. `--help` resolves the value live, which is 

502# exactly right at a terminal: the reader is on the machine the number 

503# answers for. A docs build is not: it baked `3` and `never` (the build 

504# runner's core count, and CI's NO_COLOR) into the published reference, 

505# machine-specific answers dressed as the product's. Keyed beside the one 

506# grammar table so a new computed default cannot ship without its phrase — 

507# `global_default` refuses a computed name missing here when asked to speak 

508# symbolically. 

509_COMPUTED_PHRASE = { 

510 "--jobs": "the machine's cores minus one, never below 2", 

511 "--color": "auto (or what NO_COLOR/FORCE_COLOR says)", 

512} 

513_VALUE_OPTIONAL = frozenset(_GLOBAL_DEFAULT) 

514 

515# Flag -> entry point, for globals that *would* exist had their provider been 

516# mounted. Memoised per brand distribution, and never touched on a path that 

517# is not already refusing (see `_own_plugin_flags`). 

518_OWN_FLAGS: dict[str, dict[str, str]] = {} 

519 

520 

521def _vouched_distributions() -> set[str]: 

522 """The packages footman will import to answer for a flag nobody mounted. 

523 

524 Two, and never a third. **footman's own** — `--profile` and 

525 `--env-file` are the framework's, they are useful to every runner built 

526 on it, and it is already imported by definition. And **the brand's**, 

527 when a branded CLI named one with `dist=`: a distribution vouches for 

528 what it packages. 

529 

530 Everything else stays shut. Teaching a third party's flag would mean 

531 importing, on a typo, code the project deliberately did not mount. 

532 """ 

533 from footman import _app 

534 from footman.app import DEFAULT_BRAND 

535 

536 # DEFAULT_BRAND is where footman states its own distribution; reading it 

537 # here keeps the two from drifting apart. 

538 return {dist for dist in (DEFAULT_BRAND.dist, _app._brand.dist) if dist} 

539 

540 

541def _own_plugin_flags() -> dict[str, str]: 

542 """Every global option the vouched distributions ship, as flag -> 

543 entry-point name, whether or not this project mounted any of them. 

544 

545 An entry point cannot advertise its options: the packaging spec is 

546 strictly `name = "module:attr"`, and a `GlobalOption` registers itself 

547 by being *constructed*, which happens when its module is imported. So 

548 the only way to learn an unmounted plugin's flags is to import it, and 

549 `_vouched_distributions` is the whole list footman will do that for. 

550 

551 That covers the case this exists for: a distribution ships several 

552 plugins, a tasks file mounts some of them, and a flag from one of the 

553 others reads as a spelling mistake — for a branded CLI's own plugins 

554 and for footman's alike. 

555 

556 Called only once a refusal is certain, so the imports never touch a 

557 successful run; memoised, so a process pays once. Every failure — an 

558 entry point that will not import, a brand whose package ships none — is 

559 simply a flag this cannot teach. 

560 """ 

561 from footman import _app 

562 

563 key = _app._brand.dist or "" 

564 if (cached := _OWN_FLAGS.get(key)) is not None: 

565 return cached 

566 found: dict[str, str] = {} 

567 try: 

568 from importlib.metadata import entry_points 

569 

570 from footman import compose 

571 

572 vouched = _vouched_distributions() 

573 for ep in entry_points(group=compose.ENTRY_POINT_GROUP): 

574 meta = getattr(ep.dist, "metadata", None) 

575 if not meta or meta.get("Name", "") not in vouched: 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true

576 continue 

577 try: 

578 # The SAME door mounting uses, not a raw `ep.load()`. A module 

579 # imports once per process, so whoever calls `load()` first 

580 # gets the only capture in which its declarations fire — 

581 # `_load_entry_point` memoises that tree for everyone after. 

582 # Scanning around it would spend a plugin's one import on an 

583 # error message and leave the real mount with nothing. 

584 tree = compose._load_entry_point(ep.name) 

585 except Exception: 

586 continue # a plugin that will not load teaches nothing 

587 for opt in tree.contributions["globals"]: 

588 found.setdefault("--" + opt.name, ep.name) 

589 except Exception: 

590 found = {} 

591 _OWN_FLAGS[key] = found 

592 return found 

593 

594 

595def global_default(name: str, *, resolved: bool = True) -> tuple[Any, bool]: 

596 """A global's default **value**, and whether it was computed — resolved 

597 *now*, so a computed one answers for this machine rather than whichever one 

598 last wrote a manifest. 

599 

600 The one source for both readings of a default: what `--help` prints, and 

601 what the run actually uses when nothing supplied the option. Those used to 

602 be derived separately — the table said `--jobs` meant cores-minus-one while 

603 `_app` independently called `default_jobs()` — which agreed only for as long 

604 as nobody edited one of them. 

605 

606 `None` when there is no default at all (the option must be given a value); 

607 `""` where the bare form has a reading with no spelling of its own 

608 (`--describe` is "the whole tree", `--install-completion` is "whichever 

609 shell is asking"), which those two say in their help text instead. 

610 """ 

611 value = _GLOBAL_DEFAULT.get(name) 

612 if value is None: 

613 return None, False 

614 # `isinstance(str)` rather than `callable()`: narrowing on the string side 

615 # leaves a concrete callable type, where `callable()` widens to "any 

616 # callable at all" and cannot be called safely. 

617 if isinstance(value, str): 

618 return value, False 

619 if not resolved: 

620 # The symbolic reading, for pages that outlive this machine: the 

621 # docs exporter must not bake the build runner's core count into 

622 # the published reference. Refusing an unphrased computed default 

623 # keeps the phrase table complete by construction. 

624 if name not in _COMPUTED_PHRASE: 

625 raise KeyError(f"computed default {name!r} has no symbolic phrase") 

626 return _COMPUTED_PHRASE[name], True 

627 return value(), True 

628 

629 

630@dataclass 

631class Segment: 

632 """One resolved task invocation within a chain.""" 

633 

634 task: str # dotted path, e.g. "docs.build" 

635 path: list[str] # ["docs", "build"] 

636 values: dict[str, Any] = field(default_factory=dict) # cli-name -> value 

637 bare: set[str] = field(default_factory=set) 

638 """Options named without a value (`--profile`). They carry no value — the 

639 binder runs the same ladder an absent option would — so what a bare mention 

640 contributes is *presence*: the caller asked for this parameter and meant 

641 whatever it would have got anyway, which `given()` reads and a value alone 

642 cannot say.""" 

643 variadic: list[str] = field(default_factory=list) 

644 passthrough: list[str] | None = None 

645 # Advisory stderr lines the app prints before running — `{prog}` is 

646 # substituted there. Notes never change what runs; the grammar stays 

647 # deterministic (a positional wins), they just say so out loud. 

648 notes: list[str] = field(default_factory=list) 

649 

650 

651def _required_label(p: dict[str, Any]) -> str: 

652 """Label a required option for the missing-option error — a flag teaches 

653 both its `--x` and `--no-x` forms.""" 

654 name = f"--{p['name']}" 

655 return f"{name} (or --no-{p['name']})" if p["kind"] == "flag" else name 

656 

657 

658def _suggest_only(choices: list[str] | None, dynamic: dict[str, Any] | None) -> bool: 

659 """Whether a completer only *suggests* (never rejects): a soft completer 

660 (`strict=False`), or a strict one whose candidate list is empty — the 

661 completer genuinely returned nothing, and a *failing* strict completer 

662 aborts the manifest build instead, so rejecting every value would brick 

663 the task.""" 

664 return bool(dynamic and (not dynamic.get("strict") or not choices)) 

665 

666 

667def _check( 

668 where: str, 

669 label: str, 

670 value: str, 

671 *, 

672 choices: list[str] | None = None, 

673 accepts: list[str] | None = None, 

674 types: list[str] | None = None, 

675 dynamic: dict[str, Any] | None = None, 

676 path: str | None = None, 

677 bounds: tuple[float | None, float | None] | None = None, 

678) -> None: 

679 """Validate one string against choices or type tags; raise a taught error.""" 

680 if choices is not None: 

681 if _suggest_only(choices, dynamic): 

682 return 

683 if value in choices: 

684 return # an exact choice needs no further type/bounds checks 

685 if accepts is not None and value in accepts: 

686 # The manifest's declared synonym set — an enum's aliases and 

687 # value faces. Accepted here, taught nowhere: the refusal below 

688 # and completion both speak `choices` alone. 

689 return 

690 # A union like `Literal['fast','slow'] | int` carries both choices and 

691 # types: accept a value that matches either, and only teach both when 

692 # neither fits. 

693 if not (types and _coerce.coerce_scalar(value, types)[0]): 

694 listing = "|".join(choices) if choices else "(none available)" 

695 extra = f", or {_coerce.type_phrase(types)}" if types else "" 

696 hint = _did_you_mean(value, choices) 

697 raise ChainError( 

698 f"{where}: {label} must be one of {listing}{extra} " 

699 f"(got {value!r}){hint}" 

700 ) 

701 # matched via the type path -> fall through to bounds/path below 

702 elif types and not _coerce.coerce_scalar(value, types)[0]: 

703 expected = _coerce.type_phrase(types) 

704 raise ChainError(f"{where}: {label} expects {expected} (got {value!r})") 

705 if path is not None: 

706 _check_path(where, label, value, path) 

707 if bounds is not None: 

708 _check_bounds(where, label, value, types, bounds) 

709 

710 

711_PATH_PHRASE = { 

712 "exists": ("an existing path", Path.exists), 

713 "file": ("an existing file", Path.is_file), 

714 "dir": ("an existing directory", Path.is_dir), 

715} 

716 

717 

718def _check_path(where: str, label: str, value: str, req: str) -> None: 

719 phrase, test = _PATH_PHRASE[req] 

720 if not test(Path(value)): 

721 raise ChainError(f"{where}: {label} must be {phrase} (got {value!r})") 

722 

723 

724def _check_bounds( 

725 where: str, 

726 label: str, 

727 value: str, 

728 types: list[str] | None, 

729 bounds: tuple[float | None, float | None], 

730) -> None: 

731 ok, number = _coerce.coerce_scalar(value, types or ["int", "float"]) 

732 if not ok or isinstance(number, bool) or not isinstance(number, (int, float)): 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true

733 return # the types check above already taught the type error 

734 lo, hi = bounds 

735 # Negated comparisons so NaN (which compares False to everything, so `< lo` 

736 # and `> hi` are both False) is rejected, not silently accepted; identical 

737 # to the plain comparisons for every real number. 

738 if (lo is not None and not (number >= lo)) or ( 

739 hi is not None and not (number <= hi) 

740 ): 

741 expect = ( 

742 f"at least {lo}" 

743 if hi is None 

744 else f"at most {hi}" 

745 if lo is None 

746 else f"between {lo} and {hi}" 

747 ) 

748 raise ChainError(f"{where}: {label} must be {expect} (got {value!r})") 

749 

750 

751ChoicesFor = Callable[[str, str], "list[str] | None"] 

752"""How the app resolves a dynamic parameter's choices, live: given the task's 

753dotted address and the parameter's CLI name, the values its `suggest()` 

754completer offers right now, or `None` when it cannot be asked.""" 

755 

756 

757def live_choices( 

758 where: str, p: dict[str, Any], choices_for: ChoicesFor | None 

759) -> list[str] | None: 

760 """A parameter's choices, resolving a dynamic one the first time a value 

761 actually needs them. 

762 

763 Nothing bakes these any more, so `choices` absent means "nobody asked 

764 yet" and a present `[]` still means "the completer ran and offered 

765 nothing" — the reading `_suggest_only` has always had. The resolved list 

766 is written back into the spec, which is this invocation's own dict, so a 

767 repeated option (`--tag=a --tag=b`) asks the completer once. 

768 """ 

769 if "choices" in p or p.get("dynamic") is None or choices_for is None: 

770 return p.get("choices") 

771 fresh = choices_for(where, str(p["name"])) 

772 if fresh is None: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true

773 return None 

774 p["choices"] = fresh 

775 return fresh 

776 

777 

778def _validate( 

779 where: str, 

780 p: dict[str, Any], 

781 value: str, 

782 at: int = 0, 

783 choices_for: ChoicesFor | None = None, 

784) -> None: 

785 """Eagerly validate a choice/typed value; raise a taught error if wrong. 

786 

787 *at* is the value's index in the stream, which only matters to a grouped 

788 shape: its positions have a type each, so the check has to know which slot 

789 this token is filling. `--size=800,tall` is refused before anything runs, 

790 naming `height` — the same eager treatment every other typed parameter 

791 gets, rather than a crash from inside binding. 

792 """ 

793 label = ( 

794 f"<{p['name']}>" 

795 if p["kind"] in ("positional", "variadic") 

796 else f"--{p['name']}" 

797 ) 

798 bounds = (p.get("min"), p.get("max")) if "min" in p or "max" in p else None 

799 if group := p.get("group"): 

800 slot = at % group["max"] 

801 names = group["names"] 

802 _check( 

803 where, 

804 f"{label}: {names[slot] if names else f'value {slot + 1}'}", 

805 value, 

806 types=group["types"][slot], 

807 ) 

808 return 

809 _check( 

810 where, 

811 label, 

812 value, 

813 choices=live_choices(where, p, choices_for), 

814 accepts=p.get("accepts"), 

815 types=p.get("types"), 

816 dynamic=p.get("dynamic"), 

817 path=p.get("path"), 

818 bounds=bounds, 

819 ) 

820 

821 

822def _check_arity( 

823 where: str, p: dict[str, Any], group: dict[str, Any], count: int 

824) -> None: 

825 """A grouped shape's arity, checked once the whole stream is in. 

826 

827 Values accumulate from commas and repetition, so the count is only final 

828 at the end of the segment — but it is knowable from the manifest alone, 

829 which is what makes it a parse-time refusal rather than a binding crash. 

830 """ 

831 label = f"--{p['name']}" 

832 size = group["max"] 

833 if group["many"]: 

834 if count % size: 

835 raise ChainError( 

836 f"{where}: {label} takes values in groups of {size} " 

837 f"({group['label']}) — got {count}, which leaves " 

838 f"{count % size} over" 

839 ) 

840 elif not group["min"] <= count <= size: 

841 want = ( 

842 group["label"] 

843 if group["min"] == size 

844 else f"{group['min']} to {size} values ({group['label']})" 

845 ) 

846 raise ChainError(f"{where}: {label} takes {want} — got {count}") 

847 

848 

849def _follower(argv: list[str], i: int) -> str | None: 

850 """The bare word right after position *i*, when one rode behind a 

851 value-bearing option — the token the attachment teaching names. `--`, 

852 `+` and dash tokens are not values-in-waiting, so they read as None. 

853 One lookahead for both refusal sites (globals and task options).""" 

854 nxt = argv[i + 1] if i + 1 < len(argv) else None 

855 if nxt is None or nxt in ("--", "+") or nxt.startswith("-"): 855 ↛ 856line 855 didn't jump to line 856 because the condition on line 855 was never true

856 return None 

857 return nxt 

858 

859 

860def _expects_value( 

861 where: str | None, given: str, hint: str, follower: str | None 

862) -> str: 

863 """The `=`-attachment teaching for a value-bearing option given bare. 

864 

865 When a bare word follows, name the exact fix with the user's own value 

866 (`--target prod` → "did you mean --target=prod?") — that word must never 

867 surface as "unknown task 'prod'". Otherwise state the shape. 

868 """ 

869 prefix = f"{where}: " if where else "" 

870 if follower is not None: 870 ↛ 875line 870 didn't jump to line 875 because the condition on line 870 was always true

871 return ( 

872 f"{prefix}{given} takes its value attached — " 

873 f"did you mean {given}={follower}?" 

874 ) 

875 return f"{prefix}{given} expects a value, attached: {given}={hint}" 

876 

877 

878@contextlib.contextmanager 

879def _hint_attachment(bare: str | None, token: str) -> Generator[None]: 

880 """Add the `=`-attachment hint to a failure caused by the token that rode 

881 behind a bare mention. 

882 

883 The space form is not a value spelling in this grammar — `--mode` binds its 

884 default and `strict` is simply the next token, which is the only reading 

885 available — but it is still what a hand types out of habit. So when that 

886 next token goes on to fail, the failure also says what was probably meant. 

887 Never on a line that parses: a working invocation has nothing to 

888 second-guess, and guessing at one would be footman overruling the tokens. 

889 

890 Never twice, either. When a task option's name shadows a value-taking 

891 global (`deploy --jobs 4` against the global `--jobs`), the failure the 

892 token raises is already the attachment teaching, word for word — appending 

893 it again would say the same sentence to the same person twice. 

894 """ 

895 if bare is None: 

896 yield 

897 return 

898 try: 

899 yield 

900 except ChainError as exc: 

901 suggestion = f"did you mean {bare}={token}?" 

902 if suggestion in str(exc): 

903 raise 

904 raise ChainError(f"{exc} — {suggestion}") from None 

905 

906 

907def _parse_globals( 

908 argv: list[str], 

909 i: int, 

910 *, 

911 tree: dict[str, Any] | None = None, 

912 plugin: dict[str, str] | None = None, 

913 lenient: bool = False, 

914) -> tuple[list[str], int]: 

915 """Consume the leading globals — purely lexical: every dash token is 

916 self-contained (a value is `=`-attached), and the first bare word starts 

917 the task chain. 

918 

919 A global whose bare form has a reading may be named bare — `_VALUE_OPTIONAL` 

920 says which, the same question a task option answers with `required`. One 

921 without a reading still refuses: `--where` names a task to locate, and there 

922 is no default task, so a word behind it is taught rather than quietly 

923 becoming the task to run. 

924 

925 *plugin* maps a mounted plugin's long options (`--env-file`) to their 

926 kinds: a bool contributes both its spellings as `flag`, and every 

927 value-taking one is `option?` — bare-legal, because presence is a reading 

928 its owner can always ask for (`.given`). *lenient* carries an unknown dash 

929 token through untouched instead of refusing — the pre-discovery walk 

930 cannot know the plugins yet, so the authoritative post-discovery parse is 

931 the one that teaches. 

932 """ 

933 known: dict[str, str] = dict(_GLOBAL_KIND) 

934 value_optional = set(_VALUE_OPTIONAL) 

935 if plugin: 

936 for flag, kind in plugin.items(): 

937 if kind == "option?": 

938 known[flag] = "option" 

939 value_optional.add(flag) 

940 else: 

941 known[flag] = kind 

942 globals_: list[str] = [] 

943 while i < len(argv) and argv[i].startswith("-") and argv[i] != "--": 

944 name = argv[i].split("=", 1)[0] 

945 if name not in known: 

946 if lenient: 

947 globals_.append(argv[i]) 

948 i += 1 

949 continue 

950 raise ChainError(_unknown_global(name, known, tree, argv[i + 1 :])) 

951 canon = _CANON.get(name, name) 

952 if known[name] == "flag" and "=" in argv[i]: 

953 raise ChainError(f"{canon} is a flag and takes no value") 

954 if ( 

955 known[name] == "option" 

956 and "=" not in argv[i] 

957 and canon not in value_optional 

958 ): 

959 raise ChainError( 

960 _expects_value( 

961 None, name, _GLOBAL_HINT.get(canon, "VALUE"), _follower(argv, i) 

962 ) 

963 ) 

964 globals_.append(canon + argv[i][len(name) :]) 

965 i += 1 

966 return globals_, i 

967 

968 

969def flat_addresses(tree: dict[str, Any]) -> list[str]: 

970 """Every runnable dotted address: tasks at any depth, plus runnable groups. 

971 

972 The one index behind did-you-mean suggestions for a mistyped address — 

973 everything in it is copy-paste-runnable, so a suggestion can never 

974 propose a bare namespace group, and never one that needs a project when 

975 there is none. A suggestion is a thing to try next; offering a name that 

976 can only refuse is worse than offering nothing. 

977 """ 

978 out: list[str] = [] 

979 

980 def walk(node: dict[str, Any], prefix: str) -> None: 

981 for name, spec in node["tasks"].items(): 

982 if spec.get("unexposed"): 

983 continue 

984 out.append(prefix + name) 

985 for name, sub in node["groups"].items(): 

986 if "default" in sub: 986 ↛ 987line 986 didn't jump to line 987 because the condition on line 986 was never true

987 out.append(prefix + name) 

988 walk(sub, f"{prefix}{name}.") 

989 

990 walk(tree, "") 

991 return out 

992 

993 

994def _children(node: dict[str, Any], prefix: str) -> str: 

995 """A node's children as addresses for a "know:" listing — groups keep a 

996 trailing dot (`docs.`), the `ls -F` idiom, so descend-vs-run is visible; 

997 tasks are bare and copy-paste-runnable. 

998 

999 What needs a project is left out where there is none, for the same reason 

1000 the listings leave it out: this is the set of things you could type here, 

1001 and those are not among them. 

1002 

1003 Rendered here rather than at each caller, so the empty answer is written 

1004 once: an empty group, a tasks file with no tasks, and a tree whose tasks 

1005 all need a project seen from a directory without one all reach a listing 

1006 with nothing in it, and `(know: )` reads as a bug in footman rather than 

1007 an answer. `nothing` is the word the markdown exporter already uses. 

1008 """ 

1009 names = [f"{prefix}{name}." for name in node["groups"]] + [ 

1010 f"{prefix}{name}" 

1011 for name, spec in node["tasks"].items() 

1012 if not spec.get("unexposed") 

1013 ] 

1014 return ", ".join(names) or "nothing" 

1015 

1016 

1017def _resolve_head( 

1018 tree: dict[str, Any], 

1019 argv: list[str], 

1020 i: int, 

1021 prev_group: tuple[str, dict[str, Any]] | None, 

1022 spent: tuple[str, int] | None = None, 

1023) -> tuple[dict[str, Any], list[str], dict[str, Any] | None, int]: 

1024 """Resolve segment head `argv[i]` — one dotted address — to its task. 

1025 

1026 Returns `(task, path, group_node, next_i)`; `group_node` is set when the 

1027 address named a runnable group (its default runs), so the caller can teach 

1028 `group.child` if the *next* head turns out to be a child of it. Every 

1029 failure is a taught `ChainError`; the space form of a nested address is 

1030 detected by lookahead and answered with the dotted spelling. 

1031 """ 

1032 token = argv[i] 

1033 parts = token.split(".") 

1034 if "" in parts: 

1035 if token.endswith(".") and "" not in token[:-1].split("."): 

1036 # `docs.` — an address left hanging; resolve the prefix so the 

1037 # answer can list what completes it. 

1038 node, path = tree, [] 

1039 for part in token[:-1].split("."): 

1040 if part not in node["groups"]: 1040 ↛ 1041line 1040 didn't jump to line 1041 because the condition on line 1040 was never true

1041 break 

1042 node = node["groups"][part] 

1043 path.append(part) 

1044 else: 

1045 known = _children(node, f"{'.'.join(path)}.") 

1046 raise ChainError(f"{token!r} is an incomplete address (know: {known})") 

1047 raise ChainError( 

1048 f"{token!r} is not a task address — addresses are dot-separated " 

1049 f"names with no empty segments, like 'docs.serve'" 

1050 ) 

1051 

1052 node, path = tree, [] 

1053 for pos, part in enumerate(parts): 

1054 last = pos == len(parts) - 1 

1055 if part in node["groups"]: 

1056 node = node["groups"][part] 

1057 path.append(part) 

1058 continue 

1059 if part in node["tasks"]: 

1060 path.append(part) 

1061 if not last: 

1062 dotted = ".".join(path) 

1063 raise ChainError( 

1064 f"{token!r}: {dotted!r} is a task, not a group — " 

1065 f"nothing lives beneath it" 

1066 ) 

1067 if node["tasks"][part].get("unexposed"): 

1068 # Found, and refused for the true reason. "No task named" would 

1069 # be a lie — the task exists, it just has nowhere to stand — and 

1070 # whoever typed this is most often a tool in the wrong 

1071 # directory, which is exactly what the answer should say. 

1072 raise ChainError(_unexposed(".".join(path), tree)) 

1073 return node["tasks"][part], path, None, i + 1 

1074 # Unknown segment. At the very start this may be a misplaced global, 

1075 # or a child of the runnable group that led the previous segment 

1076 # (`fm lint python` — the space form, taught, not parsed). 

1077 if pos == 0: 

1078 if (misplaced := _misplaced_global(token)) is not None: 

1079 raise ChainError(misplaced) 

1080 if ( 

1081 i > 0 

1082 and (prev := argv[i - 1]).startswith("-") 

1083 and "=" not in prev 

1084 and ( 

1085 _GLOBAL_KIND.get(prev) == "option" 

1086 or any( 

1087 "--" + g["name"] == prev and g["kind"] != "flag" 

1088 for g in tree.get("globals", ()) 

1089 ) 

1090 ) 

1091 ): 

1092 # The word rode behind a bare value-optional global 

1093 # (`--install-completion zsh`, a plugin's `--profile out.json` 

1094 # — required-value globals already refused in _parse_globals): 

1095 # teach the attachment, never "unknown task 'zsh'". 

1096 raise ChainError( 

1097 _expects_value(None, prev, _GLOBAL_HINT.get(prev, "VALUE"), token) 

1098 ) 

1099 if prev_group is not None: 1099 ↛ 1100line 1099 didn't jump to line 1100 because the condition on line 1099 was never true

1100 prev_path, prev_node = prev_group 

1101 if part in prev_node["groups"] or part in prev_node["tasks"]: 

1102 raise ChainError( 

1103 f"nested tasks use dots: '{prev_path}.{token}', " 

1104 f"not '{prev_path} {token}'" 

1105 ) 

1106 bad = ".".join([*path, part]) 

1107 hint = _did_you_mean(token, flat_addresses(tree)) 

1108 scope = f"{'.'.join(path)} has" if path else "know" 

1109 known = _children(node, f"{'.'.join(path)}." if path else "") 

1110 # One lead for both branches. They used to differ — "no task at" for a 

1111 # dotted address, "expected a task name, got" at the root — but the 

1112 # scope clause after already carries that distinction (`docs has:` vs 

1113 # `know:`), and someone who typed `docs.sevre` thinks of it as a name. 

1114 # A word that is no task, arriving right after a task whose 

1115 # positionals are already full, is far more often one argument too 

1116 # many than a misspelled address — say so, without dropping the 

1117 # address reading, since only the author knows which they meant. 

1118 arity = "" 

1119 if spent is not None and not hint: 

1120 who, takes = spent 

1121 counted = f"{takes} argument{'s' if takes != 1 else ''}" 

1122 arity = f" — or one argument too many for {who}, which takes " 

1123 arity += counted if takes else "none" 

1124 err = ChainError( 

1125 f"no task named {(bad if path else token)!r}{hint} " 

1126 f"({scope}: {known}){arity}" 

1127 ) 

1128 err.unknown = token 

1129 raise err 

1130 

1131 # The whole token named a group. Runnable — one with `@group.default` — 

1132 # resolves to its default action: `fm lint` / `fm lint --fix` run it, 

1133 # `path` stays the group's. The default's own signature decides what a 

1134 # trailing bare token means: a declared positional consumes it (a value 

1135 # wins — every child keeps its dotted spelling), otherwise it opens a 

1136 # fresh head on the next pass. 

1137 if "default" in node: 

1138 if node["default"].get("unexposed"): 

1139 # The bare spelling of a task that needs one — refused for the 

1140 # same reason and in the same words as `fm lint.default`. 

1141 raise ChainError(_unexposed(".".join(path), tree)) 

1142 return node["default"], path, node, i + 1 

1143 

1144 # A namespace group is never a segment target. Before refusing, look 

1145 # ahead: if the following words walk to something runnable, the user 

1146 # spelled a nested address with spaces — teach the dotted form, longest 

1147 # resolvable path first (`fm tools sync` → 'tools.sync'). 

1148 walk_node, walk_path, j = node, list(path), i + 1 

1149 while j < len(argv): 

1150 nxt = argv[j] 

1151 if nxt in walk_node["groups"]: 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true

1152 walk_node = walk_node["groups"][nxt] 

1153 walk_path.append(nxt) 

1154 j += 1 

1155 continue 

1156 if nxt in walk_node["tasks"]: 

1157 walk_path.append(nxt) 

1158 spaced = " ".join(walk_path) 

1159 raise ChainError( 

1160 f"nested tasks use dots: '{'.'.join(walk_path)}', not '{spaced}'" 

1161 ) 

1162 break 

1163 if len(walk_path) > len(path) and "default" in walk_node: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true

1164 spaced = " ".join(walk_path) 

1165 raise ChainError( 

1166 f"nested tasks use dots: '{'.'.join(walk_path)}', not '{spaced}'" 

1167 ) 

1168 dotted = ".".join(walk_path) 

1169 known = _children(walk_node, f"{dotted}.") 

1170 raise ChainError( 

1171 f"{dotted!r} is a group, not a task — name one of its tasks (know: {known})" 

1172 ) 

1173 

1174 

1175def _default_notes( 

1176 seg: Segment, 

1177 group_node: dict[str, Any], 

1178 fixed: list[dict[str, Any]], 

1179 rest: dict[str, Any] | None, 

1180) -> None: 

1181 """Advisory notes for a runnable group's positional values. 

1182 

1183 The grammar is deterministic — a positional wins — but consequence 3 

1184 carves a hole in the teaching error: `fm lint python` is a *valid* parse 

1185 when lint's default takes a positional, so the git-habit space form runs 

1186 instead of teaching. These stderr notes close the hole: an exact child 

1187 name gets the dotted pointer, and an edit-distance-1 near miss (which 

1188 used to be an "unknown task" error and would now silently filter on a 

1189 pattern matching nothing) names the nearest subtask. A path-shaped value 

1190 (`fm lint ./python`) is the documented quiet spelling — a legitimate 

1191 value that happens to equal a child name is not nagged forever. 

1192 """ 

1193 children = set(group_node["groups"]) | set(group_node["tasks"]) 

1194 if not children: 1194 ↛ 1195line 1194 didn't jump to line 1195 because the condition on line 1194 was never true

1195 return 

1196 values: list[str] = list(seg.variadic) 

1197 params = list(fixed) 

1198 if rest is not None and rest["kind"] == "positional": 1198 ↛ 1199line 1198 didn't jump to line 1199 because the condition on line 1198 was never true

1199 params.append(rest) 

1200 for p in params: 

1201 got = seg.values.get(p["name"]) 

1202 if isinstance(got, str): 1202 ↛ 1204line 1202 didn't jump to line 1204 because the condition on line 1202 was always true

1203 values.append(got) 

1204 elif isinstance(got, list): 

1205 values.extend(v for v in got if isinstance(v, str)) 

1206 dotted = seg.task 

1207 for value in values: 

1208 if "/" in value or value.startswith((".", "~")): 

1209 continue # path-shaped: the quiet spelling 

1210 if value in children: 

1211 seg.notes.append( 

1212 f"note: ran {dotted}'s default with {value!r}; " 

1213 f"for the subtask: {{prog}} {dotted}.{value}" 

1214 ) 

1215 elif ( 

1216 near := next((c for c in sorted(children) if _close1(value, c)), None) 

1217 ) is not None: 

1218 seg.notes.append( 

1219 f"note: {value!r} ran as {dotted}'s positional; " 

1220 f"nearest subtask: {{prog}} {dotted}.{near}" 

1221 ) 

1222 

1223 

1224def split_chain( 

1225 tree: dict[str, Any], 

1226 argv: list[str], 

1227 choices_for: ChoicesFor | None = None, 

1228) -> tuple[list[str], list[Segment]]: 

1229 """Split *argv* into leading globals and a list of resolved segments.""" 

1230 plugin: dict[str, str] = {} 

1231 for g in tree.get("globals", ()): 

1232 if g["kind"] == "flag": 

1233 # A bool answers to both spellings, like every task flag. 

1234 plugin["--" + g["name"]] = "flag" 

1235 plugin["--no-" + g["name"]] = "flag" 

1236 else: 

1237 # Every value-taking plugin global may be named bare: presence 

1238 # is a reading on its own, since the owner can ask `.given`. 

1239 plugin["--" + g["name"]] = "option?" 

1240 globals_, i = _parse_globals(argv, 0, tree=tree, plugin=plugin) 

1241 segments: list[Segment] = [] 

1242 prev_group: tuple[str, dict[str, Any]] | None = None 

1243 # The task whose positionals just filled up, alive for one token. 

1244 spent: tuple[str, int] | None = None 

1245 # The option a bare mention just named, alive for exactly the token after 

1246 # it — including across a segment boundary, since a word with nowhere left 

1247 # to go in this segment is tried as the next task's name. 

1248 bare_before: str | None = None 

1249 

1250 while i < len(argv): 

1251 with _hint_attachment(bare_before, argv[i]): 

1252 task, path, group_node, i = _resolve_head(tree, argv, i, prev_group, spent) 

1253 spent = None 

1254 bare_before = None 

1255 prev_group = (".".join(path), group_node) if group_node is not None else None 

1256 

1257 opts = { 

1258 "--" + p["name"]: p 

1259 for p in task["params"] 

1260 if p["kind"] in ("flag", "option") 

1261 } 

1262 # Exact-arity positionals, then a single trailing consumer for the rest: 

1263 # a typed multiple/one-or-many positional, or a `*args` variadic. 

1264 fixed = [ 

1265 p 

1266 for p in task["params"] 

1267 if p["kind"] == "positional" and not p.get("multiple") 

1268 ] 

1269 rest = next( 

1270 ( 

1271 p 

1272 for p in task["params"] 

1273 if (p["kind"] == "positional" and p.get("multiple")) 

1274 or p["kind"] == "variadic" 

1275 ), 

1276 None, 

1277 ) 

1278 seg = Segment(task=".".join(path), path=list(path)) 

1279 filled = 0 

1280 rest_count = 0 

1281 

1282 while i < len(argv): 

1283 tok = argv[i] 

1284 if tok == "+": # explicit segment boundary 

1285 i += 1 

1286 break 

1287 if tok == "--": # passthrough is terminal for the whole line 

1288 seg.passthrough = argv[i + 1 :] 

1289 i = len(argv) 

1290 break 

1291 if tok.startswith("--"): 

1292 before = len(seg.bare) 

1293 i = _consume_option(seg, opts, argv, i, frozenset(plugin), choices_for) 

1294 # Remember a bare mention for exactly one token. If the word 

1295 # after it goes on to fail, the failure gets the attachment 

1296 # hint — the space form is not a value spelling in this grammar, 

1297 # but it is still what a hand types out of habit, and a line 

1298 # that was going to error anyway can afford to say so. 

1299 bare_before = tok if len(seg.bare) > before else None 

1300 continue 

1301 with _hint_attachment(bare_before, tok): 

1302 if filled < len(fixed): 

1303 _consume_positional(seg, tree, fixed[filled], tok, choices_for) 

1304 filled += 1 

1305 i += 1 

1306 elif rest is not None: 

1307 if rest["kind"] == "variadic": 

1308 # eager, like a positional 

1309 _validate(seg.task, rest, tok, choices_for=choices_for) 

1310 seg.variadic.append(tok) 

1311 else: 

1312 _consume_positional(seg, tree, rest, tok, choices_for) 

1313 rest_count += 1 

1314 i += 1 

1315 else: 

1316 # Arity satisfied: the next word starts a new segment — 

1317 # or is an argument this task had no room for, which the 

1318 # address refusal gets to say if the word turns out to 

1319 # name nothing. 

1320 spent = (seg.task, len(fixed)) 

1321 break 

1322 bare_before = None 

1323 

1324 missing = [f"<{p['name']}>" for p in fixed[filled:] if not p.get("optional")] 

1325 if rest is not None and rest["kind"] == "positional" and rest_count == 0: 

1326 missing.append(f"<{rest['name']}>") 

1327 if missing: 

1328 raise ChainError( 

1329 f"{seg.task}: missing required positional(s): {', '.join(missing)}" 

1330 ) 

1331 

1332 # Required options — a mapping or bool with no default. (Dicts are only 

1333 # ever options; a bool is a flag, so teach both --x and --no-x forms.) 

1334 missing_opts = [ 

1335 _required_label(p) 

1336 for p in task["params"] 

1337 if p.get("required") and p["name"] not in seg.values 

1338 ] 

1339 if missing_opts: 

1340 raise ChainError( 

1341 f"{seg.task}: missing required option(s): {', '.join(missing_opts)}" 

1342 ) 

1343 for spec in task["params"]: 

1344 if (group := spec.get("group")) is None: 

1345 continue 

1346 given = seg.values.get(spec["name"]) 

1347 if given is None: 

1348 continue # absent: the default stands 

1349 _check_arity(seg.task, spec, group, len(given)) 

1350 if group_node is not None: 

1351 _default_notes(seg, group_node, fixed, rest) 

1352 segments.append(seg) 

1353 

1354 return globals_, segments 

1355 

1356 

1357def _consume_option( 

1358 seg: Segment, 

1359 opts: dict[str, dict[str, Any]], 

1360 argv: list[str], 

1361 i: int, 

1362 plugin_flags: frozenset[str] = frozenset(), 

1363 choices_for: ChoicesFor | None = None, 

1364) -> int: 

1365 tok = argv[i] 

1366 name = tok.split("=", 1)[0] 

1367 negated = False 

1368 p = opts.get(name) 

1369 if p is None and name.startswith("--no-"): 

1370 candidate = "--" + name[len("--no-") :] 

1371 if candidate in opts and opts[candidate]["kind"] == "flag": 1371 ↛ 1373line 1371 didn't jump to line 1373 because the condition on line 1371 was always true

1372 p, negated = opts[candidate], True 

1373 if p is None: 

1374 if (misplaced := _misplaced_global(name)) is not None: 

1375 raise ChainError(f"{seg.task}: {misplaced}") 

1376 if name in plugin_flags: 

1377 # A mounted plugin's global after a task name is the same position 

1378 # mistake a core global makes there — teach it by name, the way 

1379 # `_misplaced_global` does for core, instead of the generic 

1380 # unknown-option shrug the plugin side used to get. 

1381 raise ChainError( 

1382 f"{seg.task}: {name} is a global option — it goes before " 

1383 f"the first task name" 

1384 ) 

1385 forms = list(opts) + [ 

1386 f"--no-{opts[k]['name']}" for k in opts if opts[k]["kind"] == "flag" 

1387 ] 

1388 hint = _did_you_mean(name, forms) or ( 

1389 " (task options come right after their task; " 

1390 "globals go before the first task)" 

1391 ) 

1392 raise ChainError(f"{seg.task}: unknown option {name}{hint}") 

1393 

1394 cli = p["name"] 

1395 if p["kind"] == "flag": 

1396 if "=" in tok: 1396 ↛ 1397line 1396 didn't jump to line 1397 because the condition on line 1396 was never true

1397 raise ChainError(f"{seg.task}: --{cli} is a flag and takes no value") 

1398 seg.values[cli] = not negated 

1399 return i + 1 

1400 

1401 # A value is always `=`-attached, so a bare mention is unambiguous: it 

1402 # cannot be reading the next token, because that spelling is not in this 

1403 # grammar. It is legal wherever absence is legal, and records no value — 

1404 # only presence. A *required* option has no absence to mean, so it still 

1405 # refuses, with the same teaching it always gave. 

1406 if "=" not in tok: 

1407 if p.get("required"): 1407 ↛ 1408line 1407 didn't jump to line 1408 because the condition on line 1407 was never true

1408 raise ChainError( 

1409 _expects_value(seg.task, name, "VALUE", _follower(argv, i)) 

1410 ) 

1411 seg.bare.add(cli) 

1412 return i + 1 

1413 value = tok.split("=", 1)[1] 

1414 i += 1 

1415 if p.get("mapping"): 

1416 for pair in _values(p, value): 

1417 _consume_pair(seg, p, cli, pair) 

1418 elif p.get("multiple"): 

1419 for part in _values(p, value): 

1420 _validate( 

1421 seg.task, 

1422 p, 

1423 part, 

1424 at=len(seg.values.get(cli, ())), 

1425 choices_for=choices_for, 

1426 ) 

1427 seg.values.setdefault(cli, []).append(part) 

1428 else: 

1429 _validate(seg.task, p, value, choices_for=choices_for) 

1430 seg.values[cli] = value 

1431 return i 

1432 

1433 

1434def _values(p: dict[str, Any], value: str) -> list[str]: 

1435 """Comma-split parts of a list/dict value, unless the param opts out. 

1436 

1437 Called only for collection params, so splitting is the default; a `nosplit` 

1438 param (values may contain commas) takes the whole token verbatim. 

1439 """ 

1440 if p.get("nosplit"): 

1441 return [value] 

1442 return [part for part in value.split(",") if part] or [value] 

1443 

1444 

1445def _consume_pair(seg: Segment, p: dict[str, Any], cli: str, pair: str) -> None: 

1446 """Parse and validate one `KEY=VALUE` token for a dict parameter.""" 

1447 if "=" not in pair: 

1448 raise ChainError(f"{seg.task}: --{cli} expects KEY=VALUE (got {pair!r})") 

1449 key, value = pair.split("=", 1) 

1450 bounds = (p.get("min"), p.get("max")) if "min" in p or "max" in p else None 

1451 _check(seg.task, f"--{cli} key", key, types=p.get("key_types")) 

1452 _check( 

1453 seg.task, 

1454 f"--{cli} value", 

1455 value, 

1456 choices=p.get("value_choices"), 

1457 types=p.get("value_types"), 

1458 path=p.get("path"), 

1459 bounds=bounds, 

1460 ) 

1461 seg.values.setdefault(cli, []).append((key, value)) 

1462 

1463 

1464def _is_address(tree: dict[str, Any], tok: str) -> bool: 

1465 """Whether *tok* walks the tree to a task or group — the "looks like the 

1466 next task" peek for choice-positional errors. Loose on purpose: it only 

1467 shapes an error message, so a namespace group counts too.""" 

1468 node = tree 

1469 parts = tok.split(".") 

1470 if "" in parts: 1470 ↛ 1471line 1470 didn't jump to line 1471 because the condition on line 1470 was never true

1471 return False 

1472 for pos, part in enumerate(parts): 1472 ↛ 1479line 1472 didn't jump to line 1479 because the loop on line 1472 didn't complete

1473 if part in node["groups"]: 1473 ↛ 1474line 1473 didn't jump to line 1474 because the condition on line 1473 was never true

1474 node = node["groups"][part] 

1475 elif part in node["tasks"]: 

1476 return pos == len(parts) - 1 

1477 else: 

1478 return False 

1479 return True 

1480 

1481 

1482def _consume_positional( 

1483 seg: Segment, 

1484 tree: dict[str, Any], 

1485 p: dict[str, Any], 

1486 tok: str, 

1487 choices_for: ChoicesFor | None = None, 

1488) -> None: 

1489 # Resolve first, so a dynamic positional gets the same "that looks like 

1490 # the next task" reading a static one does. 

1491 known = live_choices(seg.task, p, choices_for) 

1492 if ( 

1493 known is not None 

1494 and tok not in known 

1495 and not _suggest_only(known, p.get("dynamic")) 

1496 and not (p.get("types") and _coerce.coerce_scalar(tok, p["types"])[0]) 

1497 and _is_address(tree, tok) 

1498 ): 

1499 raise ChainError( 

1500 f"{seg.task}: <{p['name']}> must be one of " 

1501 f"{'|'.join(known)} — {tok!r} looks like the next task; " 

1502 f"did you forget <{p['name']}>?" 

1503 ) 

1504 if p.get("multiple"): 

1505 for part in _values(p, tok): 

1506 # The stream index rides along, exactly as the option path passes 

1507 # it: a grouped shape's positions have a type each, and without 

1508 # `at` every token was checked against slot 0 — `plot 1 two` 

1509 # against `(x: int, y: str)` refused naming x, and a bad value 

1510 # that happened to fit slot 0's type escaped to binding. 

1511 _validate( 

1512 seg.task, 

1513 p, 

1514 part, 

1515 at=len(seg.values.get(p["name"], ())), 

1516 choices_for=choices_for, 

1517 ) 

1518 seg.values.setdefault(p["name"], []).append(part) 

1519 else: 

1520 _validate(seg.task, p, tok, choices_for=choices_for) 

1521 seg.values[p["name"]] = tok