Skip to content

Commit 0473d9a

Browse files
authored
feat(#441): lot 4, instrumentation assistée des chemins froids
summarize() et scripts/instrument_loguru.py ; 159 fonctions instrumentées hors chemins chauds.
1 parent 4018589 commit 0473d9a

65 files changed

Lines changed: 1035 additions & 45 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/class-diagram.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
> Il remplace l'ancienne section 3 de `docs/features-backlog.md`, tenue à la main, qui avait dérivé
1212
> (voir `docs/sessions/session-36.md`, issue #140).
1313
14-
160 modules · 241 classes · 545 fonctions publiques de module.
14+
160 modules · 241 classes · 546 fonctions publiques de module.
1515

1616
Conventions : `+` public, `-` privé (préfixe `_`) ; `int?` = `int | None` ; `list~str~` = `list[str]` ;
1717
`<<module>>` regroupe les fonctions publiques d'un module ; `A --> B : champ` = `A` a un champ annoté
@@ -1160,6 +1160,7 @@ classDiagram
11601160
+add_debug_argument(parser) None
11611161
+apply_debug_argument(args) None
11621162
+get_logger(name)
1163+
+summarize(value, name) str
11631164
}
11641165
11651166
%% ===== netcross_core.lua_doc =====

‎docs/journalisation.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,11 @@ Ces règles sont vérifiées par `tests/test_loguru_regles.py` et `tests/test_lo
8787
- les messages de log ne sont pas traduits : ils restent en français.
8888

8989
Les `print()` qui produisent la sortie d'une commande (rapport texte, résumé NetFlow, messages d'erreur d'usage des CLI) ne sont pas des logs et restent des `print()`.
90+
91+
### Résumer un argument : `summarize`
92+
93+
`netcross_core.logging_config.summarize(valeur, nom)` rend un argument sous une forme sûre pour un log : scalaires tels quels, chaînes courtes (identifiants d'URL masqués), chaînes longues et octets par leur taille, collections par leur type et leur nombre d'éléments, autres objets par leur type. Un paramètre dont le nom évoque un secret (`password`, `token`, `api_key`...) est toujours rendu `***`.
94+
95+
### Instrumentation assistée (issue #441)
96+
97+
`scripts/instrument_loguru.py FICHIER...` (option `--dry-run` pour ne rien écrire) ajoute, dans les fonctions non triviales sans aucun log, une trace DEBUG d'entrée qui résume les arguments avec `summarize`, et une trace avant chaque `raise` explicite. Il n'ajoute jamais de trace d'entrée sur un chemin chaud : fonction appelée dans une boucle sur des unités fines (paquets, lignes, flux, événements...), référencée comme rappel ou valeur d'une table de dispatch, ou appelée depuis une fonction elle-même chaude. Sur ces fonctions, seuls les refus (`raise`) sont tracés, en TRACE. La liste des fonctions écartées est affichée : elles s'instrumentent à la main, en TRACE, sur les seuls cas particuliers.

‎scripts/instrument_loguru.py‎

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
#!/usr/bin/env python3
2+
"""Instrumentation Loguru assistée (issue #441).
3+
4+
Ajoute, dans les fonctions non triviales qui n'ont encore aucun appel
5+
``logger.*`` :
6+
7+
- une trace DEBUG d'entrée, avec un résumé sûr de chaque argument
8+
(``netcross_core.logging_config.summarize`` : tailles et types, jamais
9+
d'objet entier, secrets masqués) ;
10+
- une trace DEBUG avant chaque ``raise`` explicite (refus), avec le type
11+
de l'exception.
12+
13+
Chemins chauds exclus : une fonction appelée dans une boucle ou une
14+
compréhension, passée comme rappel (clé de tri, signal GTK...), appelée
15+
depuis une fonction elle-même chaude (fermeture transitive), une fonction
16+
imbriquée, une méthode spéciale ou une propriété n'est PAS instrumentée.
17+
L'analyse se fait par nom sur tout ``src/`` : une homonymie classe la
18+
fonction comme chaude, jamais l'inverse -- aucun log n'est ajouté par
19+
erreur dans une boucle par paquet.
20+
21+
Usage : ``python3 scripts/instrument_loguru.py [--dry-run] FICHIER...``
22+
(affiche les fonctions instrumentées et les chemins chauds écartés).
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import ast
29+
import re
30+
import sys
31+
from pathlib import Path
32+
33+
SRC = Path(__file__).resolve().parent.parent / "src"
34+
TRIVIAL_LINES = 3
35+
IMPORT_LINE = "from netcross_core.logging_config import summarize"
36+
LOGGER_NAMES = ("logger", "_logger", "log")
37+
# Iterables consideres comme « fins » (une iteration par paquet, enregistrement,
38+
# ligne, octet, flux, evenement, document...) : leurs corps sont des chemins chauds.
39+
_HOT_ITER_RE = re.compile(
40+
r"pkt|packet|paquet|record|frame|trame|line|ligne|row|byte|octet|payload|layer|field|champ|"
41+
r"flow|flux|event|evenement|doc|entr|item|answer|query|queries|match|conv|sample|char|token|block|chunk|"
42+
r"stream|segment|session|message|msg|host|addr|ip|port|cert|header|key|value|values|keys|finding|rule|"
43+
r"transaction|request|response|call|stat|node|edge|objects|objs|children|iter|enumerate|zip|range|sorted|"
44+
r"reader|walk|glob|rglob|listdir|splitlines|split",
45+
re.IGNORECASE,
46+
)
47+
48+
49+
def _calls_logger(node: ast.AST) -> bool:
50+
return any(
51+
isinstance(c, ast.Call)
52+
and isinstance(c.func, ast.Attribute)
53+
and isinstance(c.func.value, ast.Name)
54+
and c.func.value.id in LOGGER_NAMES
55+
for c in ast.walk(node)
56+
)
57+
58+
59+
def _called_name(func: ast.expr) -> str | None:
60+
if isinstance(func, ast.Name):
61+
return func.id
62+
if isinstance(func, ast.Attribute):
63+
return func.attr
64+
return None
65+
66+
67+
class _Usage(ast.NodeVisitor):
68+
"""Collecte, sur tout src/, les noms appelés en boucle, passés en rappel,
69+
et le graphe « fonction -> noms appelés »."""
70+
71+
def __init__(self) -> None:
72+
self.in_loop: set[str] = set()
73+
self.referenced: set[str] = set()
74+
self.calls: dict[str, set[str]] = {}
75+
self._loop_depth = 0
76+
self._func: list[str] = []
77+
78+
def _visit_loop(self, node: ast.AST) -> None:
79+
self._loop_depth += 1
80+
self.generic_visit(node)
81+
self._loop_depth -= 1
82+
83+
def _visit_iter(self, node: ast.AST, iters: list[ast.expr]) -> None:
84+
"""Boucle chaude seulement si elle parcourt des unites fines (paquets,
85+
enregistrements, lignes, flux...) -- une boucle sur quelques
86+
captures ou points de capture reste froide."""
87+
if any(_HOT_ITER_RE.search(ast.unparse(it)) for it in iters):
88+
self._visit_loop(node)
89+
else:
90+
self.generic_visit(node)
91+
92+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None: # noqa: N802
93+
self._visit_iter(node, [node.iter])
94+
95+
visit_AsyncFor = visit_For # noqa: N815
96+
97+
def visit_While(self, node: ast.While) -> None: # noqa: N802
98+
# boucle de lecture/reception : toujours supposee chaude
99+
self._visit_loop(node)
100+
101+
def _visit_comp(self, node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp) -> None:
102+
self._visit_iter(node, [g.iter for g in node.generators])
103+
104+
visit_ListComp = visit_SetComp = visit_DictComp = visit_GeneratorExp = _visit_comp # noqa: N815
105+
106+
def _visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
107+
self._func.append(node.name)
108+
depth, self._loop_depth = self._loop_depth, 0
109+
self.calls.setdefault(node.name, set())
110+
self.generic_visit(node)
111+
self._loop_depth = depth
112+
self._func.pop()
113+
114+
visit_FunctionDef = visit_AsyncFunctionDef = _visit_func # noqa: N815
115+
116+
def visit_Lambda(self, node: ast.Lambda) -> None: # noqa: N802
117+
# corps de lambda : rappel potentiellement appelé en boucle
118+
self._visit_loop(node)
119+
120+
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
121+
name = _called_name(node.func)
122+
if name:
123+
if self._loop_depth:
124+
self.in_loop.add(name)
125+
if self._func:
126+
self.calls[self._func[-1]].add(name)
127+
# le nom appele lui-meme n'est pas une « reference » : on ne visite
128+
# que la cible de l'attribut, puis les arguments
129+
if isinstance(node.func, ast.Attribute):
130+
self.visit(node.func.value)
131+
elif not isinstance(node.func, ast.Name):
132+
self.visit(node.func)
133+
for arg in [*node.args, *(k.value for k in node.keywords)]:
134+
self.visit(arg)
135+
136+
def _reference(self, name: str, ctx: ast.expr_context) -> None:
137+
# toute reference non appelee (rappel, valeur de dict de dispatch,
138+
# clé de tri, gestionnaire de signal...) : potentiellement chaude
139+
if isinstance(ctx, ast.Load):
140+
self.referenced.add(name)
141+
142+
def visit_Name(self, node: ast.Name) -> None: # noqa: N802
143+
self._reference(node.id, node.ctx)
144+
145+
def visit_Attribute(self, node: ast.Attribute) -> None: # noqa: N802
146+
self._reference(node.attr, node.ctx)
147+
self.visit(node.value)
148+
149+
150+
def _parse_or_empty(path: Path) -> ast.Module:
151+
try:
152+
return ast.parse(path.read_text(encoding="utf-8"))
153+
except SyntaxError:
154+
print(f"{path}: syntaxe invalide, ignore", file=sys.stderr)
155+
return ast.Module(body=[], type_ignores=[])
156+
157+
158+
def hot_names(src: Path = SRC) -> set[str]:
159+
usage = _Usage()
160+
for path in src.rglob("*.py"):
161+
usage.visit(_parse_or_empty(path))
162+
hot = set(usage.in_loop) | set(usage.referenced)
163+
frontier = list(hot)
164+
while frontier:
165+
name = frontier.pop()
166+
for callee in usage.calls.get(name, ()):
167+
if callee not in hot:
168+
hot.add(callee)
169+
frontier.append(callee)
170+
return hot
171+
172+
173+
def _params(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
174+
a = fn.args
175+
names = [p.arg for p in (*a.posonlyargs, *a.args, *a.kwonlyargs)]
176+
if a.vararg:
177+
names.append(a.vararg.arg)
178+
if a.kwarg:
179+
names.append(a.kwarg.arg)
180+
return [n for n in names if n not in ("self", "cls")]
181+
182+
183+
def _is_excluded(fn: ast.FunctionDef | ast.AsyncFunctionDef, hot: set[str]) -> str | None:
184+
if fn.name.startswith("__") and fn.name.endswith("__") and fn.name != "__init__":
185+
return "méthode spéciale"
186+
for dec in fn.decorator_list:
187+
if _called_name(dec) in ("property", "setter", "cached_property", "getter"):
188+
return "propriété"
189+
if isinstance(dec, ast.Call) or _called_name(dec) not in ("staticmethod", "classmethod", None):
190+
return "décorée"
191+
if fn.name in hot:
192+
return "chemin chaud"
193+
return None
194+
195+
196+
def _body_start(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.stmt | None:
197+
body = fn.body
198+
first = body[0]
199+
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str):
200+
return body[1] if len(body) > 1 else None
201+
return first
202+
203+
204+
def _raises(fn: ast.AST) -> list[ast.Raise]:
205+
found: list[ast.Raise] = []
206+
207+
def walk(node: ast.AST) -> None:
208+
for child in ast.iter_child_nodes(node):
209+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
210+
continue
211+
if isinstance(child, ast.Raise) and child.exc is not None:
212+
found.append(child)
213+
walk(child)
214+
215+
walk(fn)
216+
return found
217+
218+
219+
def _exc_name(node: ast.Raise) -> str:
220+
exc = node.exc
221+
if isinstance(exc, ast.Call):
222+
exc = exc.func
223+
return _called_name(exc) if isinstance(exc, (ast.Name, ast.Attribute)) else "exception"
224+
225+
226+
def _qualname(stack: list[str], name: str) -> str:
227+
return ".".join([*stack, name])
228+
229+
230+
def instrument_source(text: str, hot: set[str]) -> tuple[str, list[str], list[str]]:
231+
tree = ast.parse(text)
232+
lines = text.splitlines(keepends=True)
233+
if not any(
234+
isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "logger" for t in n.targets)
235+
for n in tree.body
236+
) and not any(
237+
isinstance(n, ast.ImportFrom) and any((a.asname or a.name) == "logger" for a in n.names) for n in tree.body
238+
):
239+
return text, [], ["module sans logger"]
240+
241+
inserts: list[tuple[int, str]] = [] # (index de ligne 0-based, texte), inséré avant
242+
done: list[str] = []
243+
done_hot: list[str] = []
244+
skipped: list[str] = []
245+
246+
def visit(node: ast.AST, stack: list[str], nested: bool) -> None:
247+
for child in ast.iter_child_nodes(node):
248+
if isinstance(child, ast.ClassDef):
249+
visit(child, [*stack, child.name], nested)
250+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
251+
handle(child, stack, nested)
252+
visit(child, [*stack, child.name], True)
253+
254+
def handle(fn: ast.FunctionDef | ast.AsyncFunctionDef, stack: list[str], nested: bool) -> None:
255+
if (fn.end_lineno or fn.lineno) - fn.lineno <= TRIVIAL_LINES or _calls_logger(fn):
256+
return
257+
qual = _qualname(stack, fn.name)
258+
reason = "fonction imbriquée" if nested else _is_excluded(fn, hot)
259+
if reason == "chemin chaud":
260+
# pas de trace d'entree, mais un refus (raise) reste un evenement
261+
# rare meme sur un chemin chaud : TRACE juste avant, cout nul sinon
262+
added = _raise_logs(fn, qual, "trace")
263+
if added:
264+
inserts.extend(added)
265+
done_hot.append(qual)
266+
return
267+
if reason:
268+
skipped.append(f"{qual} ({reason})")
269+
return
270+
start = _body_start(fn)
271+
if start is None or start.lineno == fn.lineno:
272+
skipped.append(f"{qual} (corps sur la ligne du def)")
273+
return
274+
indent = lines[start.lineno - 1][: start.col_offset]
275+
if indent.strip():
276+
skipped.append(f"{qual} (indentation non standard)")
277+
return
278+
params = _params(fn)
279+
if params:
280+
fmt = " ".join(f"{p}={{}}" for p in params)
281+
args = ", ".join(f'summarize({p}, "{p}")' for p in params)
282+
line = f'{indent}logger.debug("{qual}: {fmt}", {args})\n'
283+
if len(line.rstrip("\n")) > 120:
284+
args_lines = "".join(f'{indent} summarize({p}, "{p}"),\n' for p in params)
285+
line = f'{indent}logger.debug(\n{indent} "{qual}: {fmt}",\n{args_lines}{indent})\n'
286+
else:
287+
line = f'{indent}logger.debug("{qual}()")\n'
288+
inserts.append((start.lineno - 1, line))
289+
inserts.extend(_raise_logs(fn, qual, "debug"))
290+
done.append(qual)
291+
292+
def _raise_logs(fn: ast.AST, qual: str, level: str) -> list[tuple[int, str]]:
293+
out: list[tuple[int, str]] = []
294+
for r in _raises(fn):
295+
r_line = lines[r.lineno - 1]
296+
r_indent = r_line[: r.col_offset]
297+
if r_indent.strip():
298+
continue # `if x: raise ...` sur une ligne : laisse tel quel
299+
out.append((r.lineno - 1, f'{r_indent}logger.{level}("{qual}: refus, {_exc_name(r)}")\n'))
300+
return out
301+
302+
visit(tree, [], False)
303+
done.extend(f"{q} (chemin chaud : refus en TRACE seulement)" for q in done_hot)
304+
if not inserts:
305+
return text, done, skipped
306+
for index, line in sorted(inserts, key=lambda x: x[0], reverse=True):
307+
lines.insert(index, line)
308+
out = "".join(lines)
309+
if IMPORT_LINE not in out and any("summarize(" in line for _i, line in inserts):
310+
out = _add_import(out)
311+
return out, done, skipped
312+
313+
314+
def _add_import(text: str) -> str:
315+
tree = ast.parse(text)
316+
last = None
317+
for node in tree.body:
318+
if isinstance(node, (ast.Import, ast.ImportFrom)):
319+
last = node
320+
elif last is not None and not isinstance(node, (ast.Expr, ast.If, ast.Try)):
321+
break
322+
lines = text.splitlines(keepends=True)
323+
at = (last.end_lineno if last else 0) or 0
324+
lines.insert(at, IMPORT_LINE + "\n")
325+
return "".join(lines)
326+
327+
328+
def main(argv: list[str] | None = None) -> int:
329+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
330+
parser.add_argument("files", nargs="+", type=Path)
331+
parser.add_argument("--dry-run", action="store_true")
332+
args = parser.parse_args(argv)
333+
hot = hot_names()
334+
for path in args.files:
335+
text = path.read_text(encoding="utf-8")
336+
new, done, skipped = instrument_source(text, hot)
337+
print(f"{path}: {len(done)} instrumentée(s), {len(skipped)} écartée(s)")
338+
for s in skipped:
339+
print(f" écartée : {s}")
340+
if not args.dry_run and new != text:
341+
path.write_text(new, encoding="utf-8")
342+
return 0
343+
344+
345+
if __name__ == "__main__":
346+
sys.exit(main())

0 commit comments

Comments
 (0)