Coverage for src/footman/config.py: 97%

84 statements  

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

1"""Behavioural settings, discovered the same way tasks are. 

2 

3footman reads `[tool.footman]` from `pyproject.toml` and a standalone 

4`footman.toml` (whole-file), walking from the repo root down to the current 

5directory. Nearer files win, so a package can override repo-wide defaults; a 

6`--config PATH` on the command line overrides everything. Recognised keys: 

7 

8* `tasks` — name of the task file to look for in the cascade (default 

9 `tasks.py`). 

10* `sequential` — run tasks one at a time by default (`fm` still overrides 

11 with `-s` / a parallel default). 

12* `color` — `always` | `never` | `auto` (default): when to emit ANSI colour, 

13 for footman's own output and the tools it spawns. `--color` / `--no-color` 

14 override it; `NO_COLOR` / `FORCE_COLOR` in the environment are consulted 

15 below it. 

16* `plugins` — `footman.tasks` entry points to mount as command groups 

17 (opt-in; installing a package never adds tasks by itself). A plugin's 

18 name is its command path: `["footman.tools"]` mounts as `fm footman 

19 tools …`, nesting one group per dotted segment. 

20 

21Unknown keys are kept but ignored, so newer settings never break an older 

22footman. 

23""" 

24 

25from __future__ import annotations 

26 

27import tomllib 

28from collections.abc import Callable 

29from pathlib import Path 

30from typing import Any 

31 

32from footman import _paths 

33 

34# Filenames read in each directory of the cascade. Within one directory the 

35# dedicated `footman.toml` wins over `pyproject.toml`'s `[tool.footman]`. 

36PYPROJECT = "pyproject.toml" 

37FOOTMAN_TOML = "footman.toml" 

38 

39# Keys that only make sense in the user-level file: they govern shared, 

40# machine-wide behaviour (the cache collector sweeps one cache for every 

41# project), so a per-project value would be a lie waiting to confuse 

42# someone. Stripped from cascade files, with a note under -v; an explicit 

43# `--config` file keeps them — the user named that file on purpose. 

44USER_LEVEL_KEYS = frozenset({"gc"}) 

45 

46 

47class ConfigError(Exception): 

48 """A config TOML file exists but cannot be parsed.""" 

49 

50 

51def _read_toml(path: Path, required: bool = False) -> dict[str, Any] | None: 

52 """Parse *path*; `None` if absent/unreadable, `ConfigError` if malformed. 

53 

54 A missing file is normal (most directories have no config); a file that 

55 exists but doesn't parse is a user mistake that must not be silently 

56 read as "no settings". When *required* (an explicit `--config`), an 

57 unreadable file is loud too, not silently skipped. 

58 """ 

59 try: 

60 text = path.read_text("utf-8") 

61 except OSError as exc: 

62 if required: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 raise ConfigError(f"{path}: {exc.strerror or exc}") from exc 

64 return None 

65 try: 

66 data = tomllib.loads(text) 

67 except tomllib.TOMLDecodeError as exc: 

68 raise ConfigError(f"{path}: {exc}") from exc 

69 return data if isinstance(data, dict) else None 

70 

71 

72def _footman_table(path: Path, required: bool = False) -> dict[str, Any]: 

73 """The footman settings in *path* — `[tool.footman]` for a pyproject, 

74 the whole file for anything else. Empty dict if absent/unreadable.""" 

75 data = _read_toml(path, required=required) 

76 if data is None: 

77 return {} 

78 if path.name == PYPROJECT: 

79 tool = data.get("tool") 

80 table = tool.get("footman") if isinstance(tool, dict) else None 

81 return table if isinstance(table, dict) else {} 

82 return data 

83 

84 

85def _dir_config( 

86 directory: Path, 

87 on_warning: Callable[[str], None] | None, 

88 on_note: Callable[[str], None] | None = None, 

89) -> dict[str, Any]: 

90 """Merged footman settings for one directory (footman.toml wins). 

91 

92 A malformed file in the discovered cascade is warned about and skipped — 

93 one broken pyproject.toml between the repo root and the cwd should not 

94 brick every `fm` invocation. User-level-only keys are stripped here, 

95 with an advisory through *on_note* (verbose runs wire it; quiet ones 

96 don't) pointing at where the key belongs. 

97 """ 

98 merged: dict[str, Any] = {} 

99 for name in (PYPROJECT, FOOTMAN_TOML): 

100 try: 

101 merged.update(_footman_table(directory / name)) 

102 except ConfigError as exc: 

103 if on_warning is not None: 

104 on_warning(f"ignoring malformed config: {exc}") 

105 for key in USER_LEVEL_KEYS & merged.keys(): 

106 del merged[key] 

107 if on_note is not None: 

108 on_note( 

109 f"`{key}` is a user-level setting — it belongs in " 

110 f"{_paths.footman_config_file()}; ignoring it in {directory}" 

111 ) 

112 return merged 

113 

114 

115DEFAULT_COMPLETION_MAX_AGE_S = 600 # 10 minutes 

116 

117 

118def _parse_duration(value: object) -> int | None: 

119 """Seconds from a duration (`"10m"`, `"30s"`, `"1h"`, or a plain int); `None` 

120 to disable (`off`/`0`/negative). An unparseable value falls back to the 

121 default rather than crashing the completion build.""" 

122 if value is None: 

123 return DEFAULT_COMPLETION_MAX_AGE_S 

124 if isinstance(value, bool): # bool is an int subclass — treat as on/off 

125 return DEFAULT_COMPLETION_MAX_AGE_S if value else None 

126 if isinstance(value, int): 

127 return value if value > 0 else None 

128 if not isinstance(value, str): 

129 return DEFAULT_COMPLETION_MAX_AGE_S 

130 text = value.strip().lower() 

131 if text in ("off", "none", ""): 

132 return None 

133 units = {"s": 1, "m": 60, "h": 3600, "d": 86400} 

134 unit = units.get(text[-1:]) 

135 try: 

136 n = int(text[:-1]) if unit else int(text) 

137 except ValueError: 

138 return DEFAULT_COMPLETION_MAX_AGE_S 

139 seconds = n * (unit or 1) 

140 return seconds if seconds > 0 else None 

141 

142 

143def completion_max_age(cfg: dict[str, Any]) -> int | None: 

144 """Seconds before the completion cache is considered stale, or `None` if 

145 disabled. Reads `[tool.footman] completion.max_age`; default 10 minutes.""" 

146 completion = cfg.get("completion") 

147 raw = completion.get("max_age") if isinstance(completion, dict) else None 

148 return _parse_duration(raw) 

149 

150 

151def load_config( 

152 cwd: Path, 

153 ceiling: Path, 

154 cli_path: str | None = None, 

155 on_warning: Callable[[str], None] | None = None, 

156 on_note: Callable[[str], None] | None = None, 

157) -> dict[str, Any]: 

158 """Merge config from *ceiling* down to *cwd*; *cli_path* overrides all. 

159 

160 A malformed discovered file warns (via *on_warning*) and is skipped; a 

161 missing or malformed explicit *cli_path* raises `ConfigError` — the user 

162 named that file on purpose, so it failing quietly (a typo silently ignored) 

163 is not an option. *on_note* carries advisories (a user-level key found in 

164 a project file) — verbose runs wire it, others leave it `None`. 

165 """ 

166 if cli_path: 

167 # The explicit file is total control: it replaces the global file 

168 # and the cascade both — the user named exactly what applies. 

169 path = Path(cli_path).expanduser() 

170 if not path.is_file(): 

171 raise ConfigError(f"{path}: no such file") 

172 return _footman_table(path, required=True) 

173 

174 merged: dict[str, Any] = {} 

175 try: 

176 # The bottom rung: the user-level file. Whole-file footman settings, 

177 # like footman.toml; every project layer cascades over it. 

178 merged.update(_footman_table(_paths.footman_config_file())) 

179 except ConfigError as exc: 

180 if on_warning is not None: 180 ↛ 182line 180 didn't jump to line 182 because the condition on line 180 was always true

181 on_warning(f"ignoring malformed config: {exc}") 

182 for directory in _paths.dir_chain(cwd, ceiling): 

183 merged.update(_dir_config(directory, on_warning, on_note)) 

184 return merged