Coverage for src/footman/_coerce.py: 95%

483 statements  

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

1"""Annotation normalization and value coercion. 

2 

3The manifest (introspection), the splitter (validation), and the executor 

4(binding) all reason about a parameter's type through this one module, so a 

5parameter's CLI shape is derived in exactly one place. 

6 

7A parameter is normalized by `peel` into `(multiple, element, completer)` 

8and its scalar *element* is described as ordered "type tags" 

9(`bool`/`int`/`float`/`path`/`str`) or as choices (`Literal`/`Enum`). 

10Coercion tries the tags in **specificity order** — the most restrictive parser 

11first, `str` last as the universal fallback — so `str | int` turns `"5"` 

12into `5` and `"x"` into `"x"`. 

13""" 

14 

15from __future__ import annotations 

16 

17import dataclasses as _dataclasses 

18import datetime as _datetime 

19import enum 

20import types 

21import typing 

22from dataclasses import dataclass 

23from pathlib import Path, PurePath 

24from typing import Annotated, Any, TypedDict 

25 

26from footman.params import ( 

27 PathRequirement, 

28 StdoutMarker, 

29 ask, 

30 between, 

31 check, 

32 doc, 

33 env, 

34 matching, 

35 suggest, 

36) 

37from footman.params import _arg as _ARG 

38from footman.params import ask as _ask_marker 

39from footman.params import default as _default_marker 

40from footman.params import forward as _FORWARD 

41from footman.params import hidden as _HIDDEN 

42from footman.params import nosplit as _NOSPLIT 

43from footman.params import stdin as _stdin_marker 

44from footman.params import stdout as _STDOUT 

45 

46_TAG_ORDER = {"bool": 0, "int": 1, "float": 2, "path": 3, "str": 4} 

47 

48# The tokens a non-flag `bool` accepts (a scalar `bool` is a --flag and never 

49# parses a token; these cover bool inside collections, dict values, and unions). 

50_BOOL_TOKENS = { 

51 "true": True, 

52 "1": True, 

53 "yes": True, 

54 "on": True, 

55 "false": False, 

56 "0": False, 

57 "no": False, 

58 "off": False, 

59} 

60 

61 

62def _tag_of(t: Any) -> str | None: 

63 if t is bool: 

64 return "bool" 

65 if t is int: 

66 return "int" 

67 if t is float: 

68 return "float" 

69 if isinstance(t, type) and issubclass(t, PurePath): 

70 return "path" 

71 if t is str: 

72 return "str" 

73 return None 

74 

75 

76def _is_union(ann: Any) -> bool: 

77 return typing.get_origin(ann) in (typing.Union, types.UnionType) 

78 

79 

80def _strip_none(members: list[Any]) -> list[Any]: 

81 return [m for m in members if m is not type(None)] 

82 

83 

84def union_members(element: Any) -> list[Any]: 

85 """Members of a union (None stripped), or `[element]` for a non-union.""" 

86 if _is_union(element): 

87 return _strip_none(list(typing.get_args(element))) 

88 return [element] 

89 

90 

91def _union_of(parts: list[Any]) -> Any: 

92 parts = list(dict.fromkeys(parts)) 

93 union = parts[0] 

94 for part in parts[1:]: 94 ↛ 95line 94 didn't jump to line 95 because the loop on line 94 never started

95 union = union | part 

96 return union 

97 

98 

99@dataclass 

100class Peeled: 

101 multiple: bool # a list-valued parameter? 

102 element: Any # scalar type / Union (or, for a mapping, the value type) 

103 completer: suggest | None 

104 nosplit: bool = False # opt OUT of comma-splitting (collections split by default) 

105 mapping: bool = False # a dict[K, V] parameter? 

106 key: Any = None # mapping key type 

107 value_multiple: bool = False # mapping value is a list (dict[K, list[E]]) 

108 path_req: str | None = None # exists / file / dir requirement on a Path 

109 glob: str | None = None # matching(): narrow Tab to names like this 

110 bounds: tuple[float | None, float | None] | None = None # inclusive lo/hi 

111 env: str | None = None # environment-variable fallback 

112 checks: tuple[Any, ...] = () # post-coercion validators (check(fn)) 

113 doc: str | None = None # per-parameter help text (doc("...")) 

114 # `_ask_marker`, not `ask`: the field name shadows the class inside 

115 # this scope, so the plain spelling would annotate with the field. 

116 ask: _ask_marker | None = None # prompt-if-missing marker (ask()) 

117 forward: bool = False # thread this value to dispatched tasks (forward) 

118 optional: bool = False # Arg[T]: an optional trailing positional 

119 stdin: _stdin_marker | None = None # bind from the boundary's stdin read 

120 hidden: bool = False # out of the listings; still binds, still completes 

121 default_fn: _default_marker | None = None # computed default (default(fn)) 

122 container: type = list # the collection named: list/tuple/set/frozenset 

123 

124 

125class _Markers(TypedDict): 

126 """The marker bundle `peel` collects and hands to `Peeled` — typed so the 

127 `**markers` unpack checks against the dataclass's own field types.""" 

128 

129 path_req: str | None 

130 glob: str | None 

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

132 env: str | None 

133 checks: tuple[Any, ...] 

134 doc: str | None 

135 ask: _ask_marker | None 

136 forward: bool 

137 optional: bool 

138 stdin: _stdin_marker | None 

139 hidden: bool 

140 default_fn: _default_marker | None 

141 

142 

143def collection_of(ann: Any) -> type | None: 

144 """The collection *ann* names, or `None` if it names none. 

145 

146 `list[T]`, `set[T]`, `frozenset[T]` and `tuple[T, ...]` share one grammar 

147 — one or many, comma or repetition, `nosplit` and all — and differ only in 

148 what the body is handed. A *fixed-arity* `tuple[X, Y]` is a shape rather 

149 than a collection: its values are grouped, not accumulated, so `group_of` 

150 answers for it and this returns `None`. 

151 

152 A bare `list` / `set` / `frozenset` / `tuple` means a collection of `str`, 

153 the way a bare annotation always does here. 

154 """ 

155 origin = typing.get_origin(ann) or ann 

156 if origin is list or origin is set or origin is frozenset: 

157 return origin 

158 if origin is tuple: 

159 args = typing.get_args(ann) 

160 if not args or (len(args) == 2 and args[1] is Ellipsis): 

161 return tuple 

162 return None 

163 

164 

165def peel(ann: Any) -> Peeled: 

166 """Normalize a parameter annotation into (multiple, element, completer).""" 

167 completer: suggest | None = None 

168 is_nosplit = False 

169 path_req: str | None = None 

170 glob: str | None = None 

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

172 env_var: str | None = None 

173 checks: tuple[Any, ...] = () 

174 doc_text: str | None = None 

175 ask_marker: ask | None = None 

176 is_forward = False 

177 is_hidden = False 

178 is_optional = False 

179 stdin_marker: _stdin_marker | None = None 

180 default_marker: _default_marker | None = None 

181 

182 # Strip Annotated and Optional wrappers in any order/nesting, e.g. both 

183 # `Annotated[list[X], nosplit] | None` and `Annotated[list[X] | None, nosplit]`. 

184 changed = True 

185 while changed: 

186 changed = False 

187 if typing.get_origin(ann) is Annotated: 

188 base, *meta = typing.get_args(ann) 

189 for mark in meta: 

190 if isinstance(mark, suggest): 

191 completer = mark 

192 elif mark is _NOSPLIT: 

193 is_nosplit = True 

194 elif isinstance(mark, PathRequirement): 

195 path_req = mark.kind 

196 elif isinstance(mark, matching): 

197 glob = mark.pattern 

198 elif isinstance(mark, between): 

199 bounds = (mark.lo, mark.hi) 

200 elif isinstance(mark, range): 

201 # A bare range: Python's half-open semantics, ints only. 

202 bounds = (mark.start, mark.stop - 1) 

203 elif isinstance(mark, env): 

204 env_var = mark.var 

205 elif isinstance(mark, check): 

206 checks = (*checks, mark.fn) 

207 elif isinstance(mark, doc): 

208 doc_text = mark.text 

209 elif isinstance(mark, _default_marker): 

210 default_marker = mark 

211 elif isinstance(mark, ask): 

212 ask_marker = mark 

213 elif mark is _HIDDEN: 

214 is_hidden = True 

215 elif mark is _FORWARD: 

216 is_forward = True 

217 elif mark is _ARG: 

218 is_optional = True 

219 elif mark is _stdin_marker or isinstance(mark, _stdin_marker): 

220 # The bare class and an instance both mark the parameter: 

221 # `Annotated[str, stdin]` reads the whole stream, 

222 # `stdin("field")` / `stdin(lines=True)` refine it. 

223 stdin_marker = ( 

224 mark if isinstance(mark, _stdin_marker) else _stdin_marker() 

225 ) 

226 elif callable(mark) and not isinstance(mark, type): 

227 # A bare callable used to mean `suggest(fn)`. One spelling 

228 # per concept won: `suggest()` says what it does, and the 

229 # guess quietly swallowed anything callable — a plugin's 

230 # own marker became a mystery completer with no error 

231 # either way. Refused rather than ignored, because this 

232 # shape *did* work: silence would break it invisibly. 

233 # Unknown metadata that is not callable stays ignored, so 

234 # a plugin marker (the house pattern is a non-callable 

235 # instance) still rides through untouched. 

236 from footman._manifest import SpecError # circular at import 

237 

238 name = getattr(mark, "__name__", type(mark).__name__) 

239 raise SpecError( 

240 f"Annotated[…, {name}]: a bare callable is not a " 

241 f"marker — wrap it to say what it means: " 

242 f"suggest({name})" 

243 ) 

244 ann, changed = base, True 

245 elif _is_union(ann): 

246 members = _strip_none(list(typing.get_args(ann))) 

247 if len(members) == 1: 

248 ann, changed = members[0], True 

249 

250 markers: _Markers = { 

251 "path_req": path_req, 

252 "glob": glob, 

253 "bounds": bounds, 

254 "env": env_var, 

255 "checks": checks, 

256 "doc": doc_text, 

257 "ask": ask_marker, 

258 "forward": is_forward, 

259 "optional": is_optional, 

260 "stdin": stdin_marker, 

261 "hidden": is_hidden, 

262 "default_fn": default_marker, 

263 } 

264 

265 if ann is dict or typing.get_origin(ann) is dict: # dict[K, V] or bare dict 

266 kv = typing.get_args(ann) 

267 key_type = kv[0] if kv else str 

268 value_type = kv[1] if len(kv) > 1 else str 

269 value = peel(value_type) # recurse: value may be scalar / union / list 

270 # A marker on the value type — dict[str, Annotated[int, between(1, 5)]] 

271 # — applies to each value; an outer marker on the whole dict wins if 

272 # both are present. (env stays outer-only; env() on a dict is a 

273 # SpecError.) 

274 return Peeled( 

275 False, 

276 value.element, 

277 completer, 

278 is_nosplit, 

279 mapping=True, 

280 key=key_type, 

281 value_multiple=value.multiple, 

282 path_req=path_req if path_req is not None else value.path_req, 

283 bounds=bounds if bounds is not None else value.bounds, 

284 env=env_var, 

285 checks=(*checks, *value.checks), 

286 doc=doc_text, 

287 stdin=stdin_marker, 

288 ) 

289 

290 if (kind := collection_of(ann)) is not None: # list/set/frozenset/tuple[T, ...] 

291 element = (typing.get_args(ann) or (str,))[0] 

292 if typing.get_origin(element) is Annotated: 

293 # A marker on the element type — list[Annotated[int, between(1, 5)]] 

294 # — applies to each element, exactly as the dict branch reads the 

295 # markers on its value type; an outer marker on the whole 

296 # collection wins when both are present. The annotated element 

297 # used to reach the manifest unpeeled, where it read as "not a 

298 # usable type" and the values passed through as text. 

299 inner = peel(element) 

300 if not inner.multiple and not inner.mapping: 300 ↛ 311line 300 didn't jump to line 311 because the condition on line 300 was always true

301 element = inner.element 

302 completer = completer or inner.completer 

303 is_nosplit = is_nosplit or inner.nosplit 

304 if markers["path_req"] is None: 304 ↛ 306line 304 didn't jump to line 306 because the condition on line 304 was always true

305 markers["path_req"] = inner.path_req 

306 if markers["glob"] is None: 306 ↛ 308line 306 didn't jump to line 308 because the condition on line 306 was always true

307 markers["glob"] = inner.glob 

308 if markers["bounds"] is None: 

309 markers["bounds"] = inner.bounds 

310 markers["checks"] = (*markers["checks"], *inner.checks) 

311 return Peeled(True, element, completer, is_nosplit, container=kind, **markers) 

312 

313 if _is_union(ann): 

314 members = _strip_none(list(typing.get_args(ann))) 

315 collections = [(m, k) for m in members if (k := collection_of(m)) is not None] 

316 if collections: # set[X] | scalar... -> that collection, elements merged 

317 parts: list[Any] = [] 

318 for member, _ in collections: 

319 parts += [a for a in typing.get_args(member) if a is not Ellipsis] or [ 

320 str 

321 ] 

322 parts += [m for m in members if collection_of(m) is None] 

323 return Peeled( 

324 True, 

325 _union_of(parts), 

326 completer, 

327 is_nosplit, 

328 container=collections[0][1], 

329 **markers, 

330 ) 

331 return Peeled(False, ann, completer, is_nosplit, **markers) # scalar union 

332 

333 return Peeled(False, ann, completer, is_nosplit, **markers) # plain scalar 

334 

335 

336def emitted(ann: Any) -> tuple[bool, Any]: 

337 """Whether a *return* annotation carries the `stdout` marker, and the 

338 type inside it. 

339 

340 `Stdout[dict | None]` and `Stdout[dict] | None` read identically — 

341 `Annotated` and `Optional` strip in any order and nesting, the same 

342 normalisation `peel` applies to parameters. The inner type decides the 

343 emission: `str` verbatim, `bytes` raw, anything else JSON. 

344 """ 

345 found = False 

346 changed = True 

347 while changed: 

348 changed = False 

349 if typing.get_origin(ann) is Annotated: 

350 base, *meta = typing.get_args(ann) 

351 if any(m is _STDOUT or isinstance(m, StdoutMarker) for m in meta): 351 ↛ 353line 351 didn't jump to line 353 because the condition on line 351 was always true

352 found = True 

353 ann, changed = base, True 

354 elif _is_union(ann): 

355 members = _strip_none(list(typing.get_args(ann))) 

356 if len(members) == 1: 

357 ann, changed = members[0], True 

358 return found, ann 

359 

360 

361def emission_mode(inner: Any) -> str: 

362 """How an emitted value reaches stdout: `text`, `bytes`, or `json`.""" 

363 members = union_members(inner) 

364 if len(members) == 1: 364 ↛ 369line 364 didn't jump to line 369 because the condition on line 364 was always true

365 if members[0] is str: 

366 return "text" 

367 if members[0] is bytes: 

368 return "bytes" 

369 return "json" 

370 

371 

372@dataclass(frozen=True) 

373class Group: 

374 """A fixed-arity shape: what to build, from which positions. 

375 

376 One reading covers four spellings, because `inspect.signature` answers 

377 identically for all of them — `tuple[X, Y]` from its subscript, a 

378 `NamedTuple` and a dataclass from their fields, a plain class from its 

379 `__init__`. They are one case, not four. 

380 """ 

381 

382 target: Any # the callable that builds it (`tuple` builds from an iterable) 

383 names: tuple[str, ...] | None # field names; None for a bare tuple 

384 types: tuple[Any, ...] # the annotation at each position 

385 required: int # positions that must be filled 

386 from_iterable: bool # `tuple(values)` rather than `T(*values)` 

387 

388 @property 

389 def total(self) -> int: 

390 return len(self.types) 

391 

392 def build(self, values: list[Any]) -> Any: 

393 return self.target(values) if self.from_iterable else self.target(*values) 

394 

395 def label(self) -> str: 

396 """How the arity reads in an error: `width,height`, or `2 values`. 

397 

398 The named form is the whole argument for preferring `NamedTuple`: 

399 a plain tuple can only count, and counting errors read poorly. 

400 """ 

401 if self.names: 

402 return ",".join(self.names) 

403 return f"{self.total} values" 

404 

405 

406@dataclass(frozen=True) 

407class Field: 

408 """One field a record declares: its name, its type, whether it must be 

409 given.""" 

410 

411 name: str 

412 type: Any 

413 required: bool 

414 

415 

416def fields_of(target: Any) -> tuple[Field, ...] | None: 

417 """The fields *target* declares, or `None` if it is not a record. 

418 

419 One answer for the four ways a record is spelled — a dataclass, a 

420 `NamedTuple`, a `TypedDict`, a class with an annotated `__init__` — read 

421 three ways: positionally by `group_of` for the command line, by name by 

422 the stdin binder for a JSON object, and rendered into the manifest so a 

423 machine can read the shape a pipe expects. They cannot disagree about 

424 what a record is, which is exactly how a `NamedTuple` once got lost. 

425 

426 An *untyped* constructor is not a record. `uuid.UUID` takes seven 

427 optional arguments and describes none of them, so reading it as a shape 

428 invents a spelling its author never wrote. 

429 """ 

430 import inspect 

431 

432 if not isinstance(target, type) or target in (str, bytes): 

433 return None 

434 if typing.is_typeddict(target): 

435 hints = typing.get_type_hints(target) 

436 # getattr: the checkers narrow `target` to `type` here and lose sight 

437 # of the TypedDict attributes. 

438 required: frozenset[str] = getattr(target, "__required_keys__", frozenset()) 

439 return tuple(Field(n, t, n in required) for n, t in hints.items()) 

440 if issubclass(target, enum.Enum): 

441 return None 

442 if _dataclasses.is_dataclass(target): 

443 hints = typing.get_type_hints(target) 

444 return tuple( 

445 Field( 

446 f.name, 

447 hints.get(f.name, str), 

448 f.default is _dataclasses.MISSING 

449 and f.default_factory is _dataclasses.MISSING, 

450 ) 

451 for f in _dataclasses.fields(target) 

452 ) 

453 names = getattr(target, "_fields", None) 

454 if names is not None and issubclass(target, tuple): # a NamedTuple 

455 hints = typing.get_type_hints(target) 

456 defaults = getattr(target, "_field_defaults", {}) 

457 return tuple(Field(n, hints.get(n, str), n not in defaults) for n in names) 

458 try: 

459 sig = inspect.signature(target) 

460 hints = typing.get_type_hints(target.__init__) 

461 except (TypeError, ValueError, NameError): 

462 return None 

463 fields: list[Field] = [] 

464 for param in sig.parameters.values(): 

465 if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): 

466 return None # no declared fields to read 

467 if param.name not in hints: 

468 return None # untyped: see above 

469 fields.append( 

470 Field(param.name, hints[param.name], param.default is param.empty) 

471 ) 

472 return tuple(fields) or None 

473 

474 

475def _spellable(slot: Any) -> bool: 

476 """Whether one command-line token can fill a slot of this type. 

477 

478 A record or a collection cannot say where it ends inside a 

479 comma-separated group — `--line=1,2,3,4` has no way to mark which pair 

480 is the start — so a shape holding one has no command-line spelling and 

481 lives on the document channel, where JSON's own brackets do the saying. 

482 

483 A *one-field* record stays spellable: that is the `T(value)` behaviour a 

484 single-parameter constructor has always had. 

485 """ 

486 if collection_of(slot) is not None: 

487 return False 

488 fields = fields_of(slot) 

489 return fields is None or (len(fields) < 2 and not typing.is_typeddict(slot)) 

490 

491 

492def group_of(ann: Any) -> Group | None: 

493 """The fixed-arity shape *ann* names, or `None` if it is not one. 

494 

495 A one-parameter constructor is deliberately not a group: it keeps 

496 today's `T(value)` behaviour, where the whole token reaches the type. 

497 That is what makes this non-breaking — only shapes that are a hard 

498 error today start grouping. 

499 """ 

500 if typing.get_origin(ann) is tuple: 

501 args = typing.get_args(ann) 

502 if not args or (len(args) == 2 and args[1] is Ellipsis): 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 return None # variadic: a list's grammar, handled by `peel` 

504 if not all(_spellable(a) for a in args): 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 return None 

506 return Group(tuple, None, args, len(args), from_iterable=True) 

507 

508 if typing.is_typeddict(ann): 

509 return None # named keys, not positions: no arity to group by 

510 fields = fields_of(ann) 

511 if fields is None or len(fields) < 2: 

512 return None # one parameter keeps the whole token, as it does today 

513 if not all(_spellable(f.type) for f in fields): 

514 return None # a slot no single token can fill: the document channel 

515 return Group( 

516 ann, 

517 tuple(f.name for f in fields), 

518 tuple(f.type for f in fields), 

519 sum(1 for f in fields if f.required), 

520 from_iterable=False, 

521 ) 

522 

523 

524def is_flag(element: Any) -> bool: 

525 return element is bool 

526 

527 

528# The basic types a default may stand in for. Ordered because `bool` is a 

529# subclass of `int` in Python — and excluded outright, since a bool default 

530# is a flag the splitter resolves before coercion ever runs. 

531_INFERRED_FROM_DEFAULT = (int, float, str, Path) 

532 

533 

534def inferred_type(default: Any) -> type | None: 

535 """The type a bare default stands in for, or `None` to infer nothing. 

536 

537 A parameter written `port=8000` carries no annotation, but Python's own 

538 inference — and every type checker footman gates on — reads it as `int`. 

539 Without this, the command line would hand the body `'99'` while the 

540 checker had already concluded `int`: the same parameter arriving as two 

541 types depending on whether it was passed. 

542 

543 The rule is *infer exactly where the checker infers*, so the cases it 

544 declines to type are declined here too: `None` (it says 

545 `Unknown | None`), containers empty or not (`Unknown`), and anything 

546 exotic. An `Enum` member is excluded despite `IntEnum` passing the 

547 `int` check — the value would coerce, but to the wrong kind of thing. 

548 """ 

549 if default is None or isinstance(default, bool | enum.Enum): 

550 return None 

551 for basic in _INFERRED_FROM_DEFAULT: 

552 if isinstance(default, basic): 

553 return basic 

554 return None 

555 

556 

557def sort_tags(tags: list[str]) -> list[str]: 

558 return sorted(dict.fromkeys(tags), key=lambda t: _TAG_ORDER.get(t, 99)) 

559 

560 

561def element_tags(element: Any) -> list[str]: 

562 """Scalar coercion tags (specificity-sorted); empty for choice/unknown types.""" 

563 if _is_union(element): 

564 tags = [ 

565 t for m in _strip_none(list(typing.get_args(element))) if (t := _tag_of(m)) 

566 ] 

567 else: 

568 tag = _tag_of(element) 

569 tags = [tag] if tag else [] 

570 return sort_tags(tags) 

571 

572 

573_TYPE_PHRASE = { 

574 "bool": "true or false", 

575 "int": "an integer", 

576 "float": "a number", 

577 "path": "a path", 

578 "str": "text", 

579} 

580 

581 

582def type_phrase(tags: list[str]) -> str: 

583 """A human phrase for a list of type tags: `['int']` -> "an integer".""" 

584 return " or ".join(_TYPE_PHRASE.get(t, t) for t in tags) 

585 

586 

587class EnumContractError(ValueError): 

588 """An enum declares a command surface the contract refuses. 

589 

590 Raised wherever the enum is first read — the manifest bake wraps it as 

591 the taught `ManifestError`; an execution path reaching a broken enum 

592 without a manifest reports it as the parameter's failure. Refusal over 

593 precedence, always: every message names the members involved.""" 

594 

595 

596@dataclass(frozen=True) 

597class EnumFace: 

598 """One canonical member's contract faces. 

599 

600 `token` is the one output spelling — the member *name* projected at 

601 declaration time to the neutral identifier grammar (lowercase ASCII, 

602 digits, `-`), the same projection parameter names already use. `aliases` 

603 are Python's duplicate-value bindings, projected the same way: accepted 

604 as input everywhere, taught nowhere. `value_text` is the value face in 

605 the argv door's encoding (a string transport stringifies); the document 

606 door spells the value face in JSON's own types instead.""" 

607 

608 token: str 

609 value_text: str 

610 aliases: tuple[str, ...] 

611 member: enum.Enum 

612 

613 

614def enum_token(name: str) -> str: 

615 """The declaration-time projection: `LOW_PRIORITY` → `low-priority`. 

616 

617 Folding happens here and only here — no runtime folding algorithm, 

618 table, or Unicode dependency exists in any binder or on any wire. A 

619 name that projects outside the grammar is refused at declaration, so 

620 the Turkish-İ class of divergence is excluded by construction.""" 

621 token = name.lower().replace("_", "-") 

622 if not token or not all(c.isascii() and (c.isalnum() or c == "-") for c in token): 

623 raise EnumContractError( 

624 f"enum member {name!r} projects to {token!r}, outside the " 

625 f"contract's token grammar (lowercase ASCII letters, digits, " 

626 f"'-') — rename the member" 

627 ) 

628 return token 

629 

630 

631def enum_faces(element: type[enum.Enum]) -> list[EnumFace]: 

632 """Every canonical member's faces, with the declaration-time refusals. 

633 

634 Python's alias collapse is adopted verbatim: iteration yields canonical 

635 members (first binding wins), `__members__` carries the synonyms. Two 

636 refusals guard the contract: two canonical members projecting to one 

637 token (`LOW` and `Low`), and any cross-member intersection of face 

638 spellings on any door. The argv set — token, aliases, stringified 

639 value — is checked pairwise; the document door's string faces are a 

640 subset of it (duplicate values already collapsed to aliases, and its 

641 value face is JSON-typed), so the argv check covers every door.""" 

642 alias_names: dict[enum.Enum, list[str]] = {} 

643 for name, member in element.__members__.items(): 

644 if name != member.name: # a duplicate-value binding: a synonym 

645 alias_names.setdefault(member, []).append(name) 

646 faces: list[EnumFace] = [] 

647 seen_tokens: dict[str, str] = {} 

648 for member in element: 

649 token = enum_token(member.name) 

650 if token in seen_tokens: 

651 raise EnumContractError( 

652 f"enum members {seen_tokens[token]!r} and {member.name!r} " 

653 f"both project to token {token!r} — rename one" 

654 ) 

655 seen_tokens[token] = member.name 

656 faces.append( 

657 EnumFace( 

658 token=token, 

659 value_text=str(member.value), 

660 aliases=tuple(enum_token(a) for a in alias_names.get(member, ())), 

661 member=member, 

662 ) 

663 ) 

664 spellings = [(f, {f.token, *f.aliases, f.value_text}) for f in faces] 

665 for i, (a, a_set) in enumerate(spellings): 

666 for b, b_set in spellings[i + 1 :]: 

667 clash = a_set & b_set 

668 if clash: 

669 raise EnumContractError( 

670 f"enum members {a.member.name!r} and {b.member.name!r} " 

671 f"spell {sorted(clash)!r} ambiguously on the command " 

672 f"line — the contract refuses rather than picking" 

673 ) 

674 return faces 

675 

676 

677def token_of(member: enum.Enum) -> str: 

678 """The member's one output spelling. An alias-accessed member has no 

679 independent identity (`Level.MINIMAL is Level.LOW`), so `.name` is 

680 always the canonical binding and the projection lands on its token.""" 

681 return enum_token(member.name) 

682 

683 

684def enum_member_for(element: type[enum.Enum], text: str) -> enum.Enum | None: 

685 """The member *text* names on a string door: token, alias, or value 

686 face — the enumerated input set, identical at every door. A raw member 

687 name (`LOW`) is deliberately not a fourth spelling.""" 

688 for face in enum_faces(element): 

689 if text == face.token or text in face.aliases or text == face.value_text: 

690 return face.member 

691 return None 

692 

693 

694def element_choices( 

695 element: Any, 

696) -> tuple[list[str] | None, type[enum.Enum] | None, tuple[Any, ...] | None]: 

697 """(choices as strings, Enum class, Literal values) for a choice element. 

698 

699 Choices speak tokens: a `Literal`'s tokens are its values (the author 

700 chose the values as the meaning), an `Enum`'s tokens are its projected 

701 names (the author named the values). `Level.LOW = 1` therefore shows 

702 `low|high`, never the `1|2` that read as payload.""" 

703 if typing.get_origin(element) is typing.Literal: 

704 values = typing.get_args(element) 

705 return [str(v) for v in values], None, values 

706 if isinstance(element, type) and issubclass(element, enum.Enum): 

707 return [f.token for f in enum_faces(element)], element, None 

708 return None, None, None 

709 

710 

711def enum_accepts(element: Any) -> list[str]: 

712 """The accepted input spellings *beyond* the choices, across a union: 

713 aliases and value faces. Baked into the manifest so every frontend's 

714 eager gate accepts the same set by contract — authored and enumerated, 

715 not leniency.""" 

716 extra: list[str] = [] 

717 for member in union_members(element): 

718 if isinstance(member, type) and issubclass(member, enum.Enum): 

719 for face in enum_faces(member): 

720 for spelling in (*face.aliases, face.value_text): 

721 if spelling != face.token and spelling not in extra: 

722 extra.append(spelling) 

723 return extra 

724 

725 

726def all_choices(element: Any) -> list[str] | None: 

727 """Choice strings gathered across a union's Literal/Enum members (or a 

728 scalar Literal/Enum); `None` if no member contributes choices.""" 

729 out: list[str] = [] 

730 for member in union_members(element): 

731 member_choices, _, _ = element_choices(member) 

732 if member_choices: 

733 out.extend(member_choices) 

734 return out or None 

735 

736 

737def eagerly_checkable(element: Any) -> bool: 

738 """Whether every union member is taggable (bool/int/float/path/str) or a 

739 Literal/Enum — so the splitter can accept/reject a value up front. A member 

740 like `UUID` or `Any` is not eagerly checkable; only binding can coerce it.""" 

741 for member in union_members(element): 

742 if _tag_of(member) is not None: 

743 continue 

744 member_choices, _, _ = element_choices(member) 

745 if member_choices is not None: 

746 continue 

747 return False 

748 return True 

749 

750 

751def coerce_scalar(value: str, tags: list[str]) -> tuple[bool, Any]: 

752 """Try to coerce *value* to one of *tags* in specificity order.""" 

753 for tag in sort_tags(tags): 

754 if tag == "bool": 

755 if value.lower() in _BOOL_TOKENS: 

756 return True, _BOOL_TOKENS[value.lower()] 

757 elif tag == "int": 

758 # `isascii` guards the gap between `str.isdigit` and `int()`: 

759 # "²".isdigit() is true but int("²") raises. 

760 digits = value[1:] if value[:1] in "+-" else value 

761 if digits.isdigit() and digits.isascii(): 

762 return True, int(value) 

763 elif tag == "float": 

764 try: 

765 return True, float(value) 

766 except ValueError: 

767 pass 

768 elif tag == "path": 

769 return True, Path(value) 

770 elif tag == "str": 770 ↛ 753line 770 didn't jump to line 753 because the condition on line 770 was always true

771 return True, value 

772 return False, None 

773 

774 

775def coerce_one(value: str, element: Any) -> Any: 

776 """Coerce a single token to its annotated element type (best effort).""" 

777 _, enum_cls, literal = element_choices(element) 

778 if enum_cls is not None: 

779 # The three-face input set — token, alias, value face — identical 

780 # at every door. A raw member NAME (`LOW`) is deliberately not a 

781 # fourth spelling: the name's one legal form is its token. 

782 member = enum_member_for(enum_cls, value) 

783 if member is not None: 

784 return member 

785 return enum_cls(value) 

786 if literal is not None: 

787 for lit in literal: 

788 if str(lit) == value: 

789 return lit 

790 return value 

791 if _is_union(element): 

792 return _coerce_union(value, element) 

793 tags = element_tags(element) 

794 if tags: 

795 ok, out = coerce_scalar(value, tags) 

796 return out if ok else value 

797 return coerce_custom(value, element) 

798 

799 

800def coerce_checked(value: str, element: Any) -> Any: 

801 """Coerce a token, refusing rather than falling back to the raw string. 

802 

803 `coerce_one` is best-effort on purpose: on the ordinary path the 

804 splitter has already validated the token eagerly, so its fall-through 

805 is unreachable. A grouped position has no such pre-check — the 

806 splitter validates a parameter against one type, and a group's 

807 positions have one each — so it needs the strict form, or a 

808 `tuple[str, int]` would quietly accept `height='tall'`. 

809 """ 

810 _choices, enum_cls, literal = element_choices(element) 

811 if enum_cls is not None: 811 ↛ 812line 811 didn't jump to line 812 because the condition on line 811 was never true

812 return coerce_one(value, element) # `enum_cls(value)` refuses its own 

813 if literal is not None: 813 ↛ 814line 813 didn't jump to line 814 because the condition on line 813 was never true

814 if not any(str(lit) == value for lit in literal): 

815 spelled = "|".join(str(lit) for lit in literal) 

816 raise ValueError(f"must be one of {spelled} (got {value!r})") 

817 return coerce_one(value, element) 

818 tags = element_tags(element) 

819 if tags: 819 ↛ 824line 819 didn't jump to line 824 because the condition on line 819 was always true

820 ok, out = coerce_scalar(value, tags) 

821 if not ok: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true

822 raise ValueError(f"expects {type_phrase(tags)} (got {value!r})") 

823 return out 

824 return coerce_custom(value, element) 

825 

826 

827def _coerce_union(value: str, element: Any) -> Any: 

828 """Coerce a token to the best-matching member of a union (best effort). 

829 

830 Order: an exact Literal/Enum member (so `Literal[5] | str` yields the int 

831 5, not "5"), then scalar tags in specificity order, then any custom-type 

832 member's constructor (so `UUID | int` binds a real UUID); falls back to the 

833 raw string when nothing matches. 

834 """ 

835 members = union_members(element) 

836 for member in members: 

837 _, enum_cls, literal = element_choices(member) 

838 if enum_cls is not None: 

839 for m in enum_cls: 839 ↛ 836line 839 didn't jump to line 836 because the loop on line 839 didn't complete

840 if str(m.value) == value or m.name == value: 840 ↛ 839line 840 didn't jump to line 839 because the condition on line 840 was always true

841 return m 

842 elif literal is not None: 

843 for lit in literal: 

844 if str(lit) == value: 

845 return lit 

846 tags = element_tags(element) 

847 if tags: 847 ↛ 851line 847 didn't jump to line 851 because the condition on line 847 was always true

848 ok, out = coerce_scalar(value, tags) 

849 if ok: 

850 return out 

851 for member in members: 851 ↛ 861line 851 didn't jump to line 861 because the loop on line 851 didn't complete

852 if ( 852 ↛ 851line 852 didn't jump to line 851 because the condition on line 852 was always true

853 isinstance(member, type) 

854 and _tag_of(member) is None 

855 and not issubclass(member, enum.Enum) 

856 ): 

857 try: 

858 return coerce_custom(value, member) 

859 except ValueError: 

860 continue 

861 return value 

862 

863 

864def coerce_token(value: str, element: Any) -> Any: 

865 """Strict `coerce_one` for a token the splitter never validated — an env 

866 fallback or a `--` passthrough value. 

867 

868 Raises `ValueError` when a purely tag-typed element cannot parse the token 

869 (e.g. `JOBS=abc` for an `int`), rather than passing the raw string through 

870 the way `coerce_one` does for CLI tokens the splitter already validated. 

871 Choice and 

872 custom-type membership are left to `coerce_one` (and the caller's own 

873 choices check), so union values keep working. 

874 """ 

875 if element_tags(element) and all_choices(element) is None: 

876 tags = element_tags(element) 

877 ok, out = coerce_scalar(value, tags) 

878 if not ok: 

879 raise ValueError(f"expects {type_phrase(tags)} (got {value!r})") 

880 return out 

881 return coerce_one(value, element) 

882 

883 

884def coerce_custom(value: str, element: Any) -> Any: 

885 """Coerce to a type footman doesn't special-case, via its constructor. 

886 

887 Covers `UUID`, `Decimal`, and any user type whose constructor accepts a 

888 string; `datetime`/`date` use `fromisoformat`. Validated here at 

889 execution time (the splitter only ever sees strings), and raises 

890 `ValueError` on a bad value so footman can report it cleanly. 

891 """ 

892 # `Any`/`object` deliberately mean "take the raw string": both are classes 

893 # on Python >=3.11, so without this guard `Any("x")` would raise. 

894 if element is Any or element is object or not isinstance(element, type): 

895 return value 

896 if element is bytes: 

897 # A CLI token for a bytes parameter is its UTF-8 encoding — the raw 

898 # form arrives via the stdin boundary, where bytes stay bytes. 

899 return value.encode() 

900 try: 

901 if issubclass(element, _datetime.datetime): 

902 return element.fromisoformat(value) 

903 if issubclass(element, _datetime.date): 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true

904 return element.fromisoformat(value) 

905 fields = fields_of(element) 

906 if fields is not None and len(fields) == 1: 

907 # A one-field record spells as `T(value)` — but its field 

908 # *declares a type*, and a dataclass constructor validates 

909 # nothing, so `n: int` silently received the string 'abc' (and 

910 # '5' stayed a string on the happy path). The token must first 

911 # be the field's value. Untyped constructors (UUID, Decimal, a 

912 # user type taking a string) expose no readable fields, so they 

913 # keep the raw token exactly as before. 

914 record: Any = element # the call is deliberately dynamic, as below 

915 ftype = fields[0].type 

916 tags = element_tags(ftype) 

917 if tags: 917 ↛ 924line 917 didn't jump to line 924 because the condition on line 917 was always true

918 ok, out = coerce_scalar(value, tags) 

919 if not ok: 

920 raise ValueError( 

921 f"field {fields[0].name!r} takes {tags[0]}, got {value!r}" 

922 ) 

923 return record(out) 

924 return record(coerce_one(value, ftype)) 

925 # A user constructor: its call signature is its own business (the 

926 # contract is "accepts a string"), so the call is deliberately dynamic. 

927 ctor: Any = element 

928 return ctor(value) 

929 except Exception as exc: 

930 # Not just ValueError/TypeError: `Decimal("abc")` raises 

931 # `decimal.InvalidOperation` (an ArithmeticError), and a user type may 

932 # reject a bad token however it likes. The value came from a command 

933 # line, so a constructor refusing it is a bad *value*, and it reports 

934 # as one — the original exception rides the chain for `-v`. 

935 raise ValueError(f"{value!r} is not a valid {element.__name__}") from exc