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

64 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Public parameter markers, used inside `Annotated` annotations. 

2 

3Dynamic completion (`suggest`), one-or-more (`Many`), comma-split opt-out 

4(`nosplit`), path requirements (`exists`/`isfile`/`isdir`), numeric bounds 

5(`between`, or a bare `range`), environment fallbacks (`env`), custom 

6validators (`check`), and per-parameter help (`doc`). Each carries no runtime 

7weight beyond a small marker object; `footman.coerce.peel` reads them all in 

8one place. 

9""" 

10 

11from __future__ import annotations 

12 

13from collections.abc import Callable 

14from pathlib import Path 

15from typing import Annotated, Any, TypeVar 

16 

17_T = TypeVar("_T") 

18 

19 

20class suggest: 

21 """Attach a dynamic completer to a parameter, via `Annotated`: 

22 

23 ```python 

24 def build(project: Annotated[str, suggest(list_projects)]): ... 

25 ``` 

26 

27 `list_projects() -> list[str]` returns the candidate values. footman runs 

28 it on the execution path — refreshing a cache the completion hot path serves 

29 — and, when *strict* (the default), validates the supplied value against a 

30 fresh call. A bare callable in `Annotated` is treated as `suggest(fn)`. 

31 """ 

32 

33 __slots__ = ("fn", "strict") 

34 

35 def __init__(self, fn: Callable[[], Any], *, strict: bool = True) -> None: 

36 self.fn = fn 

37 self.strict = strict 

38 

39 

40# `Many[T]` is exactly `list[T]`: a parameter that is *always* a list — one or 

41# more values, variadic when positional. It reads more intentfully than a bare 

42# `list[T]` at a call site, but carries no runtime marker of its own. 

43Many = list 

44 

45 

46class _NoSplitMarker: 

47 """Marker for `nosplit`.""" 

48 

49 __slots__ = () 

50 

51 def __repr__(self) -> str: 

52 return "nosplit" 

53 

54 

55nosplit = _NoSplitMarker() 

56"""Opt a list/dict parameter OUT of comma-splitting, via `Annotated`: 

57 

58```python 

59def build(names: Annotated[list[str], nosplit] = ()): ... 

60``` 

61 

62By default a collection parameter splits a single token on commas 

63(`--tag a,b,c` -> `["a", "b", "c"]`) *in addition to* the repeatable form 

64(`--tag a --tag b`). Mark it `nosplit` when a value may itself contain a comma: 

65then only the repeated flag adds items and `--name "a,b"` stays the literal 

66`"a,b"`.""" 

67 

68 

69NoSplit = Annotated[_T, nosplit] 

70"""Shorthand for `Annotated[T, nosplit]`: `NoSplit[list[str]]` opts a collection 

71out of comma-splitting (see `nosplit`).""" 

72 

73 

74class _ForwardMarker: 

75 """Marker for `forward`.""" 

76 

77 __slots__ = () 

78 

79 def __repr__(self) -> str: 

80 return "forward" 

81 

82 

83forward = _ForwardMarker() 

84"""Forward this parameter to the tasks this task dispatches, via `Annotated`: 

85 

86```python 

87@task(pre=[build, lint]) 

88def check(fix: Annotated[bool, forward] = False): ... 

89``` 

90 

91A `forward`-marked parameter's value is passed to every task this one 

92dispatches — its `pre`/`post` prerequisites, and a runnable group's fan-out — 

93that declares a parameter of the same name; tasks that don't declare it run on 

94their own defaults. The forwarded value overrides the callee's default, and it 

95chains through callees that re-declare the marker. Forwarding supplies a 

96*value*, never runnability: a prerequisite must still be independently runnable 

97(every parameter defaulted).""" 

98 

99 

100Forward = Annotated[_T, forward] 

101"""Shorthand for `Annotated[T, forward]`, like `Many[T]` is for a list: 

102 

103```python 

104@task(pre=[build, lint]) 

105def check(fix: Forward[bool] = False): ... 

106``` 

107 

108`Forward[bool]` expands to `Annotated[bool, forward]` — the same marker, less 

109noise on a signature full of forwarded parameters. See `forward`.""" 

110 

111 

112class _PathRequirement: 

113 """Marker for `exists` / `isfile` / `isdir`.""" 

114 

115 __slots__ = ("_name", "kind") 

116 

117 def __init__(self, kind: str, name: str) -> None: 

118 self.kind = kind 

119 self._name = name 

120 

121 def __repr__(self) -> str: 

122 return self._name 

123 

124 

125exists = _PathRequirement("exists", "exists") 

126"""Require a `Path` parameter to name something that exists on disk: 

127 

128```python 

129def rm(target: Annotated[Path, exists]): ... 

130``` 

131 

132Validated eagerly (at parse time) with a taught error. See also `isfile` 

133and `isdir`.""" 

134 

135isfile = _PathRequirement("file", "isfile") 

136"""Require a `Path` parameter to name an existing *file* (see `exists`).""" 

137 

138isdir = _PathRequirement("dir", "isdir") 

139"""Require a `Path` parameter to name an existing *directory* (see `exists`).""" 

140 

141 

142Exists = Annotated[Path, exists] 

143"""Shorthand for `Annotated[Path, exists]` — `target: Exists` requires the path 

144to exist. Type-fixed to `Path`; use `Annotated` directly for a `list[Path]`.""" 

145 

146IsFile = Annotated[Path, isfile] 

147"""Shorthand for `Annotated[Path, isfile]`: require an existing *file*.""" 

148 

149IsDir = Annotated[Path, isdir] 

150"""Shorthand for `Annotated[Path, isdir]`: require an existing *directory*.""" 

151 

152 

153class between: 

154 """Inclusive numeric bounds for an `int`/`float` parameter: 

155 

156 ```python 

157 def test(jobs: Annotated[int, between(1, 32)] = 4): ... 

158 ``` 

159 

160 Validated eagerly with a taught error (`--jobs must be between 1 and 32`). 

161 Either bound may be `None` for open-ended ranges. A bare `range` in 

162 `Annotated` also works for ints, with Python's half-open semantics 

163 (`range(0, 8)` accepts 0 through 7). 

164 """ 

165 

166 __slots__ = ("hi", "lo") 

167 

168 def __init__(self, lo: float | None, hi: float | None) -> None: 

169 self.lo = lo 

170 self.hi = hi 

171 

172 

173class env: 

174 """Fall back to an environment variable when the option isn't given: 

175 

176 ```python 

177 def deploy(target: Annotated[str, env("DEPLOY_ENV")] = "staging"): ... 

178 ``` 

179 

180 Precedence is CLI > `$DEPLOY_ENV` > default. The env value flows through 

181 the same coercion and validation as a command-line token. Only valid on a 

182 parameter with a default — an env fallback *makes* it optional, so it 

183 needs somewhere to fall. 

184 """ 

185 

186 __slots__ = ("var",) 

187 

188 def __init__(self, var: str) -> None: 

189 self.var = var 

190 

191 

192class check: 

193 """A custom validator, run after coercion; raise `ValueError` to reject: 

194 

195 ```python 

196 def tag(version: Annotated[str, check(semver)]): ... 

197 ``` 

198 

199 The callable receives the coerced value (each element, for collections). 

200 Its `ValueError` message is shown to the user, so write it for them. 

201 

202 Declare a second parameter to also receive the **siblings** — the parameters 

203 to this one's left at their *effective* values (a provided value, else the 

204 parameter's own default), coerced and read-only (empty for the first 

205 parameter) — so a check can validate against another input: 

206 

207 ```python 

208 def newer(v, params): 

209 current = current_version(params["name"]) # the package named earlier 

210 if Version(v) <= current: 

211 raise ValueError(f"must be newer than {current}") 

212 

213 def release(name: str, version: Annotated[str, check(newer)]): ... 

214 ``` 

215 """ 

216 

217 __slots__ = ("fn",) 

218 

219 def __init__(self, fn: Callable[..., Any]) -> None: 

220 self.fn = fn 

221 

222 

223class doc: 

224 """Help text for one parameter, via `Annotated`: 

225 

226 ```python 

227 def lint(fix: Annotated[bool, doc("apply fixes in place")] = False): ... 

228 ``` 

229 

230 One line, written for the person at the prompt. It shows in 

231 `fm --help <task>`, as the option's description in shells that render 

232 one (zsh, fish, nushell, PowerShell tooltips), and in the 

233 `fm --json --list` catalog. 

234 """ 

235 

236 __slots__ = ("text",) 

237 

238 def __init__(self, text: str) -> None: 

239 self.text = text 

240 

241 

242class ask: 

243 """Prompt for a parameter's value when it isn't supplied, via `Annotated`: 

244 

245 ```python 

246 def release(version: Annotated[str, ask()]): ... 

247 ``` 

248 

249 A *required* (defaultless) parameter marked `ask()` is prompted for when it 

250 is not given on the command line and no `env` fills it — the answer runs 

251 through the same coercion and validation as a CLI token, re-asking on a bad 

252 value. Precedence stays CLI > env > default > prompt, so `ask()` only 

253 prompts a parameter with **no default** (a default is the answer). Off a 

254 terminal, under `--no-input`, or in `--json` it errors naming the flag 

255 rather than hanging. `secret=True` hides the input (getpass); `prompt="…"` 

256 overrides the question text. 

257 """ 

258 

259 __slots__ = ("prompt", "secret") 

260 

261 def __init__(self, *, secret: bool = False, prompt: str | None = None) -> None: 

262 self.secret = secret 

263 self.prompt = prompt