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
« 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.
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`):
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.
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"""
26from __future__ import annotations
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
36from footman import _coerce, registry
37from footman.params import between as _between
38from footman.params import default as _computed
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
68def _did_you_mean(word: str, known: Iterable[str]) -> str:
69 """A ` — did you mean 'x'?` suffix when *word* closely matches a known name.
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 ""
78def _owners_of(tree: dict[str, Any], flag: str) -> list[str]:
79 """Every task address whose own options include *flag*.
81 Walked only when a refusal is already certain, so the common path
82 never pays for it."""
83 found: list[str] = []
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}.")
96 walk(tree, "")
97 return found
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.
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)"
159def _unexposed(dotted: str, tree: dict[str, Any]) -> str:
160 """Why a real task refuses from here — resolved only once it has.
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.
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
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 )
183def _misplaced_global(token: str) -> str | None:
184 """The teaching message when *token* is really one of the GLOBALS.
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"
199class ChainError(Exception):
200 """A malformed command line, carrying a teaching message for the user.
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."""
206 unknown: str | None = None
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
215 return default_jobs()
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.
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.
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.
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 """
252 __slots__ = ("alias", "choices", "hint", "paired_off_help")
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"
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)
280 def _register(self) -> None:
281 """Off the carriage — see the class docstring."""
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"
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)
355# What `bind_global_options` resolves for a run (`UV` stays lexical, above).
356CORE_LADDER: tuple[_CoreOption, ...] = (JOBS, COLOR, SORT, SEQUENTIAL, INPUT, PROGRESS)
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)
453GlobalDefault = str | Callable[[], object] | None
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)]
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}
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)
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]] = {}
521def _vouched_distributions() -> set[str]:
522 """The packages footman will import to answer for a flag nobody mounted.
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.
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
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}
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.
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.
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.
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
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
570 from footman import compose
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
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.
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.
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
630@dataclass
631class Segment:
632 """One resolved task invocation within a chain."""
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)
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
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))
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)
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}
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})")
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})")
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."""
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.
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
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.
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 )
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.
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}")
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
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.
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}"
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.
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.
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
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.
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.
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
969def flat_addresses(tree: dict[str, Any]) -> list[str]:
970 """Every runnable dotted address: tasks at any depth, plus runnable groups.
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] = []
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}.")
990 walk(tree, "")
991 return out
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.
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.
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"
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.
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 )
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
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
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 )
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.
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 )
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
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
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
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
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 )
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)
1354 return globals_, segments
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}")
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
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
1434def _values(p: dict[str, Any], value: str) -> list[str]:
1435 """Comma-split parts of a list/dict value, unless the param opts out.
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]
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))
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
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