Coverage for src/footman/__init__.py: 100%
64 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"""footman — a task runner with typed commands and instant completion.
3Typed function signatures become real flags and positionals, modules become
4nested command groups, and shell completion answers from a cached manifest
5without importing your code. Building that manifest does import it, in a
6detached subprocess: the first <kbd>Tab</kbd> in a fresh directory, and the
7background rebuild once the cache goes stale.
9The console-script entry lives here and is deliberately thin: completion must
10dispatch to the stdlib-only hot path before importing the framework or the
11user's tasks, so `main` checks `--complete` first and everything else is
12imported lazily. A bare `import footman` pays for nothing but this module.
13"""
15from __future__ import annotations
17# The literal-False spelling both checkers honour without importing `typing`
18# (~1.4 ms) — the completion dispatch runs through this module on every TAB
19# press, and a bare `import footman` should pay for nothing it doesn't use.
20TYPE_CHECKING = False
21if TYPE_CHECKING:
22 # Give type-checkers the real types for the lazily re-exported names below;
23 # at runtime these are served by `__getattr__` without importing registry
24 # on a bare `import footman` (the completion hot path).
25 from footman import docstrings as docstrings
26 from footman import markdown as markdown
27 from footman._fetch import FetchError as FetchError
28 from footman._fetch import fetch as fetch
29 from footman._globals import Lane as Lane
30 from footman._globals import console_lane as console_lane
31 from footman._globals import cwd_lane as cwd_lane
32 from footman._globals import make_lane as lane
33 from footman._step import step as step
34 from footman.app import App as App
35 from footman.app import Brand as Brand
36 from footman.compose import include as include
37 from footman.compose import plugin as plugin
38 from footman.context import Argv as Argv
39 from footman.context import AuditEntry as AuditEntry
40 from footman.context import Context as Context
41 from footman.context import Failed as Failed
42 from footman.context import Result as Result
43 from footman.context import ResultView as ResultView
44 from footman.context import RunFailed as RunFailed
45 from footman.context import Section as Section
46 from footman.context import Stream as Stream
47 from footman.context import TimedOut as TimedOut
48 from footman.context import attended as attended
49 from footman.context import cache_dir as cache_dir
50 from footman.context import chdir as chdir
51 from footman.context import colored as colored
52 from footman.context import config_dir as config_dir
53 from footman.context import config_file as config_file
54 from footman.context import confirm as confirm
55 from footman.context import cwd as cwd
56 from footman.context import data_dir as data_dir
57 from footman.context import dist as dist
58 from footman.context import fail as fail
59 from footman.context import given as given
60 from footman.context import inherited as inherited
61 from footman.context import mark as mark
62 from footman.context import parallel as parallel
63 from footman.context import passthrough as passthrough
64 from footman.context import prog as prog
65 from footman.context import progress as progress
66 from footman.context import project_root as project_root
67 from footman.context import prompt as prompt
68 from footman.context import run as run
69 from footman.context import section as section
70 from footman.context import select as select
71 from footman.context import stream as stream
72 from footman.context import track as track
73 from footman.context import tty as tty
74 from footman.context import use_context as use_context
75 from footman.context import user_tasks_file as user_tasks_file
76 from footman.invocation import Invocation as Invocation
77 from footman.params import Arg as Arg
78 from footman.params import Exists as Exists
79 from footman.params import Forward as Forward
80 from footman.params import Hidden as Hidden
81 from footman.params import IsDir as IsDir
82 from footman.params import IsFile as IsFile
83 from footman.params import Many as Many
84 from footman.params import NoSplit as NoSplit
85 from footman.params import Secret as Secret
86 from footman.params import Stdin as Stdin
87 from footman.params import Stdout as Stdout
88 from footman.params import ask as ask
89 from footman.params import between as between
90 from footman.params import check as check
91 from footman.params import default as default
92 from footman.params import doc as doc
93 from footman.params import env as env
94 from footman.params import exists as exists
95 from footman.params import forward as forward
96 from footman.params import hidden as hidden
97 from footman.params import isdir as isdir
98 from footman.params import isfile as isfile
99 from footman.params import matching as matching
100 from footman.params import nosplit as nosplit
101 from footman.params import stdin as stdin
102 from footman.params import stdout as stdout
103 from footman.params import suggest as suggest
104 from footman.registry import GlobalOption as GlobalOption
105 from footman.registry import Group as Group
106 from footman.registry import Tasks as Tasks
107 from footman.registry import TaskView as TaskView
108 from footman.registry import capture as capture
109 from footman.registry import config_section as config_section
110 from footman.registry import expose as expose
111 from footman.registry import group as group
112 from footman.registry import post_task as post_task
113 from footman.registry import post_tasks as post_tasks
114 from footman.registry import pre_bind as pre_bind
115 from footman.registry import pre_record as pre_record
116 from footman.registry import pre_task as pre_task
117 from footman.registry import pre_tasks as pre_tasks
118 from footman.registry import requires as requires
119 from footman.registry import requires_dep as requires_dep
120 from footman.registry import requires_env as requires_env
121 from footman.registry import requires_tool as requires_tool
122 from footman.registry import task as task
123 from footman.registry import wrap_bind as wrap_bind
124 from footman.registry import wrap_task as wrap_task
125 from footman.testing import Runner as Runner
126 from footman.testing import recording as recording
128__version__ = "0.52.1"
130BUILTIN = ("footman.new", "footman.self")
131"""Stock footman's built-in task providers — what a project-less `fm` offers.
133Named here, beside `main()`, because BOTH doors need it: the `App` the
134execution path builds, and the `--complete` dispatch, which configures
135`_paths` with it before the hot path decides whether this directory has a
136global tree. A branded CLI passes its own through `App(builtin=…)`.
137"""
138__all__ = [
139 "App",
140 "Arg",
141 "Argv",
142 "AuditEntry",
143 "Brand",
144 "Context",
145 "Exists",
146 "Failed",
147 "FetchError",
148 "Forward",
149 "GlobalOption",
150 "Group",
151 "Hidden",
152 "Invocation",
153 "IsDir",
154 "IsFile",
155 "Lane",
156 "Many",
157 "NoSplit",
158 "Result",
159 "ResultView",
160 "RunFailed",
161 "Runner",
162 "Secret",
163 "Section",
164 "Stdin",
165 "Stdout",
166 "Stream",
167 "TaskView",
168 "Tasks",
169 "TimedOut",
170 "__version__",
171 "ask",
172 "attended",
173 "between",
174 "cache_dir",
175 "capture",
176 "chdir",
177 "check",
178 "colored",
179 "config_dir",
180 "config_file",
181 "config_section",
182 "confirm",
183 "console_lane",
184 "cwd",
185 "cwd_lane",
186 "data_dir",
187 "default",
188 "dist",
189 "doc",
190 "docstrings",
191 "env",
192 "exists",
193 "expose",
194 "fail",
195 "fetch",
196 "forward",
197 "given",
198 "group",
199 "hidden",
200 "include",
201 "inherited",
202 "isdir",
203 "isfile",
204 "lane",
205 "main",
206 "mark",
207 "markdown",
208 "matching",
209 "nosplit",
210 "parallel",
211 "passthrough",
212 "plugin",
213 "post_task",
214 "post_tasks",
215 "pre_bind",
216 "pre_record",
217 "pre_task",
218 "pre_tasks",
219 "prog",
220 "progress",
221 "project_root",
222 "prompt",
223 "recording",
224 "requires",
225 "requires_dep",
226 "requires_env",
227 "requires_tool",
228 "run",
229 "section",
230 "select",
231 "stdin",
232 "stdout",
233 "step",
234 "stream",
235 "suggest",
236 "task",
237 "track",
238 "tty",
239 "use_context",
240 "user_tasks_file",
241 "wrap_bind",
242 "wrap_task",
243]
246def main(tasks_file: str | None = None) -> None:
247 """Console-script entry for `footman` and `fm`.
249 `tasks_file` makes a tasks file its own command. Ending a file with
251 if __name__ == "__main__":
252 footman.main(__file__)
254 turns it into a runnable script — `./deploy.py build` — reading its own
255 tasks whatever the directory, which is what pairs with a PEP 723
256 header and a `#!/usr/bin/env -S uv run --script` shebang. An explicit
257 `-f` on the command line still wins.
258 """
259 import sys
261 argv = sys.argv[1:]
262 if argv and argv[0] == "--complete":
263 # Still first: the hot path answers before anything else is decided,
264 # and `footman.app` (with its dataclass machinery) stays off a TAB
265 # press entirely. `_paths`' module defaults *are* stock footman's
266 # locations — except the built-ins, which live on the `App` below
267 # and no default can know. Telling `_paths` about them is two
268 # attribute writes, and without it the hot path cannot see that a
269 # project-less directory has a global tree at all: the fallback is
270 # skipped, the refresh child writes nothing, and every TAB in a
271 # directory like $HOME pays the full cold bound for empty output.
272 from footman import _paths
273 from footman._complete import complete_cli
275 # `brand_version` too, not just the built-ins: the global-mode
276 # manifest is keyed by (prog, version, builtins), and the execution
277 # path configures the real version — leaving the default "" here
278 # would give completion and execution two different global caches.
279 _paths.configure(builtin=BUILTIN, brand_version=__version__)
280 raise SystemExit(complete_cli(argv[1:]))
281 # Past the hot path, so the TAB press above pays nothing for it (and
282 # `_complete` writes bytes anyway): everything from here on prints text,
283 # and a locale-encoded stdout defaults to errors='strict'. A task name, a
284 # docstring, `--tree`'s own branch glyphs or the em dash in a header is
285 # then unencodable on an ascii or cp1252 console, and a listing dies
286 # half-written with a raw UnicodeEncodeError. Degrade those glyphs to '?'
287 # instead. This is the same reconfigure `context.routing()` installs
288 # around a run — hoisted here so the listings, which never start one, are
289 # covered too.
290 import contextlib
292 for stream in (sys.stdout, sys.stderr):
293 with contextlib.suppress(Exception):
294 # getattr, not hasattr-then-call: hasattr narrowing is not
295 # portable across checkers, the getattr is.
296 reconfigure = getattr(stream, "reconfigure", None)
297 if reconfigure is not None:
298 reconfigure(errors="replace")
299 if tasks_file is not None and not any(
300 a.startswith(("-f=", "--tasks-file=")) for a in argv
301 ):
302 argv = [f"--tasks-file={tasks_file}", *argv]
303 # Past the completion hot path, this process exists to run one command and
304 # exit, so it should not spend its startup collecting garbage it has not
305 # made yet. `_globals` turns the collector back on — with everything
306 # loaded by then frozen — at the last moment before task bodies run.
307 from footman import _globals
308 from footman.app import App
310 _globals.defer_gc()
312 # `dist` names the distribution this console script ships in — what a
313 # project's lockfile pins, and what a tasks file carrying its own
314 # dependencies must declare. A branded CLI passes its own.
315 #
316 # The stock pins — `FOOTMAN_*` variables, `footman.toml` config, the
317 # long words a two-letter command cannot derive — are `App`'s own: any
318 # `App` that keeps the `fm` command gets them, this one included.
319 # `fm new` in an empty directory: footman declares its own built-in,
320 # the same way any branded CLI would.
321 raise SystemExit(App(dist="footman", builtin=BUILTIN).run(argv))
324def __getattr__(name: str) -> object:
325 # Lazy re-export: `from footman import task, group` works without paying the
326 # registry import on a bare `import footman` (the completion hot path).
327 if name in (
328 "task",
329 "group",
330 "Group",
331 "capture",
332 "config_section",
333 "GlobalOption",
334 "pre_tasks",
335 "pre_record",
336 "pre_bind",
337 "pre_task",
338 "post_task",
339 "post_tasks",
340 "wrap_task",
341 "wrap_bind",
342 "Tasks",
343 "TaskView",
344 "expose",
345 "requires",
346 "requires_dep",
347 "requires_env",
348 "requires_tool",
349 ):
350 from footman import registry
352 return getattr(registry, name)
353 if name == "step":
354 from footman import _step
356 return _step.step
357 if name in ("Lane", "cwd_lane", "console_lane", "lane"):
358 from footman import _globals
360 return _globals.make_lane if name == "lane" else getattr(_globals, name)
361 if name in ("Runner", "recording"):
362 from footman import testing
364 return getattr(testing, name)
365 if name == "docstrings":
366 import footman.docstrings
368 return footman.docstrings
369 if name == "markdown":
370 import footman.markdown
372 return footman.markdown
373 if name in ("fetch", "FetchError"):
374 from footman import _fetch
376 return getattr(_fetch, name)
377 if name in ("include", "plugin"):
378 from footman import compose
380 return getattr(compose, name)
381 if name in (
382 "Arg",
383 "suggest",
384 "Many",
385 "nosplit",
386 "exists",
387 "isfile",
388 "isdir",
389 "matching",
390 "between",
391 "env",
392 "check",
393 "default",
394 "doc",
395 "ask",
396 "forward",
397 "Forward",
398 "hidden",
399 "Hidden",
400 "NoSplit",
401 "Secret",
402 "stdin",
403 "Stdin",
404 "stdout",
405 "Stdout",
406 "Exists",
407 "IsFile",
408 "IsDir",
409 ):
410 from footman import params
412 return getattr(params, name)
413 if name in (
414 "run",
415 "parallel",
416 "Context",
417 "Argv",
418 "Result",
419 "ResultView",
420 "AuditEntry",
421 "inherited",
422 "given",
423 "passthrough",
424 "progress",
425 "prompt",
426 "chdir",
427 "confirm",
428 "cache_dir",
429 "attended",
430 "tty",
431 "colored",
432 "cwd",
433 "data_dir",
434 "config_dir",
435 "config_file",
436 "prog",
437 "dist",
438 "user_tasks_file",
439 "project_root",
440 "select",
441 "track",
442 "RunFailed",
443 "Failed",
444 "TimedOut",
445 "fail",
446 "use_context",
447 "section",
448 "stream",
449 "mark",
450 "Section",
451 "Stream",
452 ):
453 from footman import context
455 return getattr(context, name)
456 if name in ("App", "Brand"):
457 from footman import app
459 return getattr(app, name)
460 if name == "Invocation":
461 from footman import invocation
463 return invocation.Invocation
464 raise AttributeError(f"module 'footman' has no attribute {name!r}")