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
« 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.
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.
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).
23The design's reasoning and every ruled alternative:
24`notes/20260831-misuse-notes-levels.md`.
25"""
27from __future__ import annotations
29import os
30import threading
31from dataclasses import dataclass
32from typing import TYPE_CHECKING, Any
34if TYPE_CHECKING:
35 from footman.context import Context
37LEVELS = ("trace", "info", "warning", "error")
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)
132_FAMILIES = {family for family, _, _, _ in KINDS}
133_DEFAULTS = {family: default for family, _, default, _ in KINDS}
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."""
141 kind: str
142 level: str
143 site: str # "file.py:41", or "" when no frame outside footman was found
144 text: str
147class BannedNotes(Exception):
148 """A task fired notes its project classifies as errors."""
151# --- policy: the [tool.footman.notes] table -----------------------------------
154def validate(table: object) -> str | None:
155 """The refusal a broken notes table earns, or None for a sound one.
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
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
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
201def install_policy(table: object) -> None:
202 """Point this process's note resolution at one project's table.
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
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")
234_policy: dict[tuple[str, str], str] = {}
237# --- emission -----------------------------------------------------------------
239_lock = threading.Lock()
240_noted: set[tuple[str, str]] = set() # (task, kind): teach-once dedup
243def reset() -> None:
244 """A new run starts clean: every note may teach once more."""
245 with _lock:
246 _noted.clear()
249def emit(kind: str, text: str, ctx: Context | None = None) -> None:
250 """Fire one note: dedup, resolve its level, record it, say it.
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
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")
275def _site() -> str:
276 """Where the noted call was made: the nearest frame outside footman.
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
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 ""
305def boundary_error(ctx: Context) -> BannedNotes | None:
306 """The failure a task's banned notes add up to, or None.
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
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 )
330def program_name(args: Any) -> str:
331 """The instance tail of a raw spawn: the program's basename, best-effort.
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 "?"