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
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Annotation normalization and value coercion.
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.
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"""
15from __future__ import annotations
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
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
46_TAG_ORDER = {"bool": 0, "int": 1, "float": 2, "path": 3, "str": 4}
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}
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
76def _is_union(ann: Any) -> bool:
77 return typing.get_origin(ann) in (typing.Union, types.UnionType)
80def _strip_none(members: list[Any]) -> list[Any]:
81 return [m for m in members if m is not type(None)]
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]
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
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
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."""
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
143def collection_of(ann: Any) -> type | None:
144 """The collection *ann* names, or `None` if it names none.
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`.
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
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
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
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
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 }
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 )
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)
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
333 return Peeled(False, ann, completer, is_nosplit, **markers) # plain scalar
336def emitted(ann: Any) -> tuple[bool, Any]:
337 """Whether a *return* annotation carries the `stdout` marker, and the
338 type inside it.
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
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"
372@dataclass(frozen=True)
373class Group:
374 """A fixed-arity shape: what to build, from which positions.
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 """
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)`
388 @property
389 def total(self) -> int:
390 return len(self.types)
392 def build(self, values: list[Any]) -> Any:
393 return self.target(values) if self.from_iterable else self.target(*values)
395 def label(self) -> str:
396 """How the arity reads in an error: `width,height`, or `2 values`.
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"
406@dataclass(frozen=True)
407class Field:
408 """One field a record declares: its name, its type, whether it must be
409 given."""
411 name: str
412 type: Any
413 required: bool
416def fields_of(target: Any) -> tuple[Field, ...] | None:
417 """The fields *target* declares, or `None` if it is not a record.
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.
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
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
475def _spellable(slot: Any) -> bool:
476 """Whether one command-line token can fill a slot of this type.
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.
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))
492def group_of(ann: Any) -> Group | None:
493 """The fixed-arity shape *ann* names, or `None` if it is not one.
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)
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 )
524def is_flag(element: Any) -> bool:
525 return element is bool
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)
534def inferred_type(default: Any) -> type | None:
535 """The type a bare default stands in for, or `None` to infer nothing.
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.
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
557def sort_tags(tags: list[str]) -> list[str]:
558 return sorted(dict.fromkeys(tags), key=lambda t: _TAG_ORDER.get(t, 99))
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)
573_TYPE_PHRASE = {
574 "bool": "true or false",
575 "int": "an integer",
576 "float": "a number",
577 "path": "a path",
578 "str": "text",
579}
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)
587class EnumContractError(ValueError):
588 """An enum declares a command surface the contract refuses.
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."""
596@dataclass(frozen=True)
597class EnumFace:
598 """One canonical member's contract faces.
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."""
608 token: str
609 value_text: str
610 aliases: tuple[str, ...]
611 member: enum.Enum
614def enum_token(name: str) -> str:
615 """The declaration-time projection: `LOW_PRIORITY` → `low-priority`.
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
631def enum_faces(element: type[enum.Enum]) -> list[EnumFace]:
632 """Every canonical member's faces, with the declaration-time refusals.
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
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)
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
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.
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
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
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
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
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
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)
800def coerce_checked(value: str, element: Any) -> Any:
801 """Coerce a token, refusing rather than falling back to the raw string.
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)
827def _coerce_union(value: str, element: Any) -> Any:
828 """Coerce a token to the best-matching member of a union (best effort).
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
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.
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)
884def coerce_custom(value: str, element: Any) -> Any:
885 """Coerce to a type footman doesn't special-case, via its constructor.
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