|
| 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