Coverage for src/footman/app.py: 100%

21 statements  

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

1"""Public application entry: build a custom-branded CLI on top of footman. 

2 

3footman's own `fm` / `footman` commands are just the default-branded 

4`App`. Point your own console script at an `App` carrying your 

5project's names and version, and every message the user sees — errors, 

6`--version`, the completion hint — uses them: 

7 

8```python 

9# acme/cli.py 

10from footman import App 

11 

12app = App(name="Acme", prog="acme", version="1.4.0") 

13 

14def main() -> None: 

15 raise SystemExit(app.run()) 

16``` 

17 

18```toml 

19# your pyproject.toml 

20[project.scripts] 

21acme = "acme.cli:main" 

22``` 

23 

24Tasks are discovered exactly as they are for `fm`: the `tasks.py` cascade 

25from the repo root down to the current directory. 

26 

27This module is kept import-light so the completion hot path stays fast: nothing 

28here imports the registry, the manifest, or the execution layer at module load — 

29those are deferred into `App.run`. 

30""" 

31 

32from __future__ import annotations 

33 

34import sys 

35from dataclasses import dataclass 

36 

37from footman import __version__ 

38 

39 

40@dataclass(frozen=True) 

41class Brand: 

42 """The names a CLI shows the user. 

43 

44 `name` is the long / display name (the `--version` banner); `prog` is 

45 the short command name (the error prefix and hints); `version` is *your* 

46 version string; `tasks_file` is the filename your users write tasks in 

47 (config `tasks` still overrides it per project). 

48 """ 

49 

50 name: str = "footman" 

51 prog: str = "fm" 

52 version: str = __version__ 

53 tasks_file: str = "tasks.py" 

54 

55 

56DEFAULT_BRAND = Brand() 

57 

58 

59class App: 

60 """A branded footman CLI — call `run` from your console-script entry.""" 

61 

62 def __init__( 

63 self, 

64 name: str = "footman", 

65 prog: str = "fm", 

66 version: str | None = None, 

67 tasks_file: str = "tasks.py", 

68 ) -> None: 

69 self.brand = Brand( 

70 name=name, 

71 prog=prog, 

72 version=version or __version__, 

73 tasks_file=tasks_file, 

74 ) 

75 

76 def run(self, argv: list[str] | None = None) -> int: 

77 """Resolve and run the CLI, returning the process exit code. 

78 

79 Handles the stdlib-only `--complete` hot path before importing the 

80 framework, so completion stays fast even through a custom entry point. 

81 """ 

82 args = list(sys.argv[1:] if argv is None else argv) 

83 if args and args[0] == "--complete": 

84 from footman._complete import complete_cli 

85 

86 return complete_cli(args[1:]) 

87 from footman import _app 

88 

89 return _app.run(args, brand=self.brand)