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

118 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Misuse notes with levels: the registry, the policy, the record. 

2 

3Everything footman has to say about how a task treats the process — a raw 

4`subprocess` spawn, an `os.environ` write, a `getcwd()` that answers the 

5wrong directory — flows through `emit` here (via `_globals._note`, the 

6door every interception already used). Each note has a **kind**: a family 

7plus, where one exists, an instance tail naming *what happened* 

8(`environ-write:JAVA_HOME`, `popen-inject:git`, `lane-wait:serial`) — 

9never the task, which every note already carries as half its dedup key 

10and which the config addresses as its own axis. 

11 

12Each kind resolves to a **level** — `trace` / `info` / `warning` / 

13`error` — from the project's `[tool.footman.notes]` table, most specific 

14match first, else the kind's default from `KINDS` below. `trace` prints 

15only under `-v` (there is no `off`: invisible-unless-asked is the mute); 

16`info` and `warning` print once per (task, kind); `error` prints too and 

17fails the task **at its boundary**, listing every banned note with its 

18site — all issues in one run, never fix-one-see-the-next. Whatever 

19printed, every fired note is recorded on the task's context and rides its 

20row in the `--json` envelope (trace included: the machine channel ignores 

21print gating). 

22 

23The design's reasoning and every ruled alternative: 

24`notes/20260831-misuse-notes-levels.md`. 

25""" 

26 

27from __future__ import annotations 

28 

29import os 

30import threading 

31from dataclasses import dataclass 

32from typing import TYPE_CHECKING, Any 

33 

34if TYPE_CHECKING: 

35 from footman.context import Context 

36 

37LEVELS = ("trace", "info", "warning", "error") 

38 

39# The registry the runtime, the config validator, and the generated docs 

40# table all read — one source, so a kind cannot ship undocumented or be 

41# refused as unknown while it fires. Columns: family, instance tail 

42# (`""` for a bare kind), default level, when it fires + the fix. 

43KINDS: tuple[tuple[str, str, str, str], ...] = ( 

44 ( 

45 "environ-write", 

46 "variable", 

47 "info", 

48 "A task sets a variable via `os.environ`. {config} scoped the write " 

49 "to the task (children see it, siblings don't); say it on purpose " 

50 "with `env=` or `ctx.env`.", 

51 ), 

52 ( 

53 "popen-inject", 

54 "program", 

55 "warning", 

56 "A task spawns via raw `subprocess` and {config} filled in `cwd`/" 

57 "`env` from the task context. Prefer `run()` for capture and " 

58 "reporting, or pass `cwd=`/`env=` to make it deliberate.", 

59 ), 

60 ( 

61 "getcwd", 

62 "", 

63 "info", 

64 "A task reads the process working directory while its own directory " 

65 "is elsewhere — a parallel run never chdirs. `cwd()` is the " 

66 "directory you want.", 

67 ), 

68 ( 

69 "fork", 

70 "", 

71 "warning", 

72 "A task forks the process. Forking a threaded process is unsafe " 

73 "(the child can inherit held locks); spawn a subprocess instead.", 

74 ), 

75 ( 

76 "mp-start", 

77 "", 

78 "warning", 

79 "A task starts multiprocessing workers in-process. They inherit the " 

80 "real environment, not the task's overlay; a tool that parallelises " 

81 "itself loses little in the serial lane — mark the task `serial`.", 

82 ), 

83 ( 

84 "global-read", 

85 "option", 

86 "warning", 

87 "A task reads a global option without declaring it — say " 

88 "`@task(uses=[...])` so its help and provenance show it.", 

89 ), 

90 ( 

91 "global-unread", 

92 "option", 

93 "info", 

94 "A task declares a global option in `uses=` but never read it this " 

95 "run. Read a declared option unconditionally and branch on the " 

96 "*value*, so a conditional consumer never reads as stale — or prune " 

97 "the declaration if it truly is.", 

98 ), 

99 ( 

100 "lane-wait", 

101 "lane", 

102 "info", 

103 "A task has been waiting on a sole-ownership lane for more than a " 

104 "couple of seconds; the message names the holder. Visibility, not " 

105 "misuse — a lane wait must never be a silent hang.", 

106 ), 

107 ( 

108 "recorded-title", 

109 "", 

110 "info", 

111 "`title=` on a `recorded=False` call: there is no receipt to label. " 

112 "`.opts()` merges along a chain, so neither author wrote the " 

113 "contradiction — said once so one of them can resolve it.", 

114 ), 

115 ( 

116 "pre-record-recorded", 

117 "", 

118 "info", 

119 "`pre_record` on a `recorded=False` call: there is no record to " 

120 "review. Same merge shape as `recorded-title`.", 

121 ), 

122 ( 

123 "hook-return", 

124 "plugin", 

125 "warning", 

126 "A plugin's pre hook returned a value; a pre hook's return channel " 

127 "is reserved (for a pre that supplies the task's result) — keep " 

128 "per-task state on `task.state`.", 

129 ), 

130) 

131 

132_FAMILIES = {family for family, _, _, _ in KINDS} 

133_DEFAULTS = {family: default for family, _, default, _ in KINDS} 

134 

135 

136@dataclass 

137class Note: 

138 """One fired note — the record the terminal line, the task's boundary 

139 verdict, and the envelope row are all views of.""" 

140 

141 kind: str 

142 level: str 

143 site: str # "file.py:41", or "" when no frame outside footman was found 

144 text: str 

145 

146 

147class BannedNotes(Exception): 

148 """A task fired notes its project classifies as errors.""" 

149 

150 

151# --- policy: the [tool.footman.notes] table ----------------------------------- 

152 

153 

154def validate(table: object) -> str | None: 

155 """The refusal a broken notes table earns, or None for a sound one. 

156 

157 Keys are `[task/]kind`, either side `*`; the kind's family half must be 

158 a registered family (an instance tail is runtime data and cannot be 

159 checked). Values are levels. Anything else is refused by name — an 

160 ignored policy line is a wall someone thinks is up. 

161 """ 

162 from footman import _paths 

163 

164 # This runner's table, not footman's: a branded CLI reads 

165 # `[tool.<its own stem>]` and nothing else, so naming footman's here 

166 # would send someone to configure a table their runner never opens. 

167 stem = _paths.config_table() 

168 if not isinstance(table, dict): 

169 return ( 

170 f"the [tool.{stem}] notes key is a table of levels — " 

171 f'[tool.{stem}.notes] with entries like "popen-inject" = "error"' 

172 ) 

173 for key, value in table.items(): 

174 if not isinstance(value, str) or value not in LEVELS: 

175 return ( 

176 f"[tool.{stem}.notes] {key} = {value!r}: the level is one " 

177 f"of {', '.join(LEVELS)}" 

178 ) 

179 _, kind_pat = _split_key(str(key)) 

180 family = kind_pat.split(":", 1)[0] 

181 if family != "*" and family not in _FAMILIES: 

182 known = ", ".join(sorted(_FAMILIES)) 

183 return ( 

184 f"[tool.{stem}.notes] {key}: unknown note kind " 

185 f"{family!r} — the kinds are {known} (see the notes docs " 

186 f"page), '*' for all" 

187 ) 

188 return None 

189 

190 

191def _split_key(key: str) -> tuple[str, str]: 

192 """A config key's two axes: `(task, kind)`, either possibly `*`.""" 

193 if key == "*": 

194 return "*", "*" 

195 if "/" in key: 

196 task, _, kind = key.partition("/") 

197 return task or "*", kind or "*" 

198 return "*", key 

199 

200 

201def install_policy(table: object) -> None: 

202 """Point this process's note resolution at one project's table. 

203 

204 Anything but a dict of rules means "the defaults" — the caller has 

205 already refused a malformed table loudly (`validate`); this door stays 

206 permissive so an embedded invocation can always reset it.""" 

207 global _policy 

208 rules: dict[tuple[str, str], str] = {} 

209 if isinstance(table, dict): 

210 for key, value in table.items(): 

211 rules[_split_key(str(key))] = str(value) 

212 _policy = rules 

213 

214 

215def resolve(task: str, kind: str) -> str: 

216 """The level *kind* carries for *task*: most specific rule first, then 

217 the family's default. The ladder is the whole contract — a whitelist 

218 entry outranks the `"*"` blanket, which is what makes flipping the 

219 blanket to `error` safe for already-audited instances.""" 

220 family = kind.split(":", 1)[0] 

221 for probe in ( 

222 (task, kind), 

223 (task, family), 

224 (task, "*"), 

225 ("*", kind), 

226 ("*", family), 

227 ("*", "*"), 

228 ): 

229 if (level := _policy.get(probe)) is not None: 

230 return level 

231 return _DEFAULTS.get(family, "info") 

232 

233 

234_policy: dict[tuple[str, str], str] = {} 

235 

236 

237# --- emission ----------------------------------------------------------------- 

238 

239_lock = threading.Lock() 

240_noted: set[tuple[str, str]] = set() # (task, kind): teach-once dedup 

241 

242 

243def reset() -> None: 

244 """A new run starts clean: every note may teach once more.""" 

245 with _lock: 

246 _noted.clear() 

247 

248 

249def emit(kind: str, text: str, ctx: Context | None = None) -> None: 

250 """Fire one note: dedup, resolve its level, record it, say it. 

251 

252 *ctx* is the task the note belongs to — the current context when the 

253 interception runs inside the task (the usual case, and the default), 

254 passed explicitly where the caller holds a context that may not be 

255 current (the executor's hook machinery). 

256 """ 

257 from footman.context import current, real_stderr 

258 

259 if ctx is None: 

260 ctx = current() 

261 task = ctx.task or "?" 

262 with _lock: 

263 if (task, kind) in _noted: 

264 return 

265 _noted.add((task, kind)) 

266 level = resolve(task, kind) 

267 note = Note(kind, level, _site(), text) 

268 ctx.notes.append(note) 

269 if level == "trace" and not ctx.verbose: 

270 return # recorded (the envelope still carries it), not printed 

271 where = f" [{note.site}]" if note.site else "" 

272 real_stderr().write(f"{level}: {text}{where}\n") 

273 

274 

275def _site() -> str: 

276 """Where the noted call was made: the nearest frame outside footman. 

277 

278 `file:line`, path relative to the cwd when it is under it — the form a 

279 whitelist audit reads and an agent can jump to. Empty when every frame 

280 is footman's own (an interception fired with no user code on the 

281 stack), and never an error: a note must not fail for want of a frame. 

282 """ 

283 import sys 

284 

285 package = os.path.dirname(os.path.abspath(__file__)) 

286 try: 

287 frame: Any = sys._getframe(1) 

288 while frame is not None: 288 ↛ 302line 288 didn't jump to line 302 because the condition on line 288 was always true

289 filename = frame.f_code.co_filename 

290 if not os.path.abspath(filename).startswith(package): 

291 path = filename 

292 try: 

293 rel = os.path.relpath(path) 

294 if not rel.startswith(".."): 

295 path = rel 

296 except ValueError: 

297 pass # Windows: another drive 

298 return f"{path}:{frame.f_lineno}" 

299 frame = frame.f_back 

300 except Exception: 

301 pass 

302 return "" 

303 

304 

305def boundary_error(ctx: Context) -> BannedNotes | None: 

306 """The failure a task's banned notes add up to, or None. 

307 

308 Asked at the task boundary, after the body ran to completion: every 

309 interception already did the safe thing at its site, so nothing is 

310 gained by stopping early — and everything is gained by not stopping, 

311 which is how all of a task's issues surface in one run. Same bargain 

312 `keep-going` embodies: see everything, then fail honestly. 

313 """ 

314 banned = [n for n in ctx.notes if n.level == "error"] 

315 if not banned: 

316 return None 

317 listed = "; ".join( 

318 f"{n.kind}" + (f" at {n.site}" if n.site else "") for n in banned 

319 ) 

320 count = len(banned) 

321 plural = "note" if count == 1 else "notes" 

322 from footman import _paths 

323 

324 return BannedNotes( 

325 f"{count} banned {plural}: {listed} — fix the site, or classify a " 

326 f"known-harmless instance in [tool.{_paths.config_table()}.notes]" 

327 ) 

328 

329 

330def program_name(args: Any) -> str: 

331 """The instance tail of a raw spawn: the program's basename, best-effort. 

332 

333 Never raises — a note must not fail for want of a name.""" 

334 try: 

335 first: Any 

336 if isinstance(args, (list, tuple)): 

337 if not args: 

338 return "?" 

339 first = args[0] 

340 if isinstance(first, bytes): 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 first = os.fsdecode(first) 

342 elif not isinstance(first, str): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 first = os.fsdecode(os.fspath(first)) 

344 elif isinstance(args, (str, bytes)): 

345 text = os.fsdecode(args) if isinstance(args, bytes) else args 

346 parts = text.split() 

347 first = parts[0] if parts else text 

348 else: 

349 first = os.fsdecode(os.fspath(args)) 

350 return os.path.basename(first.strip()) or "?" 

351 except Exception: 

352 return "?"