Spaces:
Sleeping
Sleeping
| """ | |
| exec_validator.py | |
| βββββββββββββββββ | |
| Sandboxed execution of the Python block produced by the pythonisation pipeline, | |
| used to validate at runtime the rules that cannot be checked statically : | |
| β’ rΓ¨gle 4.3 β la propriΓ©tΓ© dΓ©montrΓ©e doit Γͺtre vraie sur 100 % des seeds | |
| (validation multi-seed avec assertions Python). | |
| β’ rΓ¨gle 11.1 β toute variable de tirage utilisΓ©e dans l'Γ©noncΓ© doit Γͺtre | |
| utilisΓ©e dans le tracΓ© matplotlib (analyse AST). | |
| β’ rΓ¨gle 11.4 β pas de mΓ©lange Rational(sympy) * np.array (analyse AST). | |
| β’ rΓ¨gle 11.3 β labels du graphique dans la fenΓͺtre [xlim, ylim] (matplotlib | |
| backend Agg + extraction des Text/Line objects). | |
| Toutes les exΓ©cutions sont : | |
| β’ IsolΓ©es dans un namespace neuf (pas de leak entre seeds) | |
| β’ PrΓ©-seedΓ©es via `random.seed(N)` (dΓ©terministe par seed) | |
| β’ Time-boxΓ©es (signal.alarm, dΓ©faut 5 s par exec) | |
| β’ Avec PyxiScience mockΓ© via `sys.modules` (le vrai package n'est pas | |
| installΓ© localement et n'est de toute faΓ§on pas nΓ©cessaire pour la | |
| validation des tirages / contraintes math / position des labels). | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import builtins | |
| import re | |
| import signal | |
| import sys | |
| import threading | |
| import types | |
| from typing import Any, Optional | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. PyxiScience stubs (the real package isn't installed locally) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _STUBS_INSTALLED = False | |
| def _passthrough(*args, **kwargs): | |
| """Universal callable stub: returns first arg cast to str if any, else ''.""" | |
| if not args: | |
| return "" | |
| # latex(...) and pxsl_* helpers all return strings; mirror that shape. | |
| return str(args[0]) | |
| def _config_stub(*args, **kwargs): | |
| """ | |
| Stub for `pxs_config()`. Must return a `dict` so that downstream code | |
| like `latex(expr, **config_standard)` (where `config_standard = pxs_config()`) | |
| doesn't crash with "argument of type 'str' is not iterable". | |
| """ | |
| return {} | |
| class _StubObject: | |
| """Universal class stub with arbitrary attribute access.""" | |
| def __init__(self, *args, **kwargs): | |
| self._args = args | |
| self._kwargs = kwargs | |
| def print(self): | |
| return "" | |
| def __call__(self, *args, **kwargs): | |
| return _StubObject(*args, **kwargs) | |
| def __getattr__(self, name): | |
| # Any method access returns a no-op callable | |
| return _passthrough | |
| def __repr__(self): | |
| return f"_StubObject({self._args!r})" | |
| # Liste CANONIQUE des helpers connus (catalogue curΓ© + corpus 222 + 33 exemples | |
| # dΓ©clinaisons). Sert au `__all__` des modules stub : `from X import *` ne | |
| # passe PAS par __getattr__ (PEP 562), il lit __all__. PartagΓ©e avec | |
| # validation/harness.py β NE PAS dupliquer ailleurs. | |
| KNOWN_PXS_HELPERS = [ | |
| "pxs_config", "pxsl_latex", "pxsl_sign", "pxsl_format_number", | |
| "pxsl_latex_with_formatting", "pxsl_latex_avec_formatage", | |
| "pxsl_latex_coefficient", "pxsl_to_rational_or_symbol", | |
| "pxsl_solve_general_inequality", "pxsl_Rational", | |
| "pxs_is_reductible_sqrt", "pxs_separate_factors", | |
| "pxs_explain_IBP", "pxsl_par", "pxsl_final_sentence", | |
| "pxsl_pow", "pxsl_matrix", "pxsl_mat", "pxsl_sum_matrix", | |
| "pxsl_prod_scalar_matrix", "pxsl_prod_matrix", "pxsl_ax", | |
| "pxsl_system_lin", "pxsl_double_matrix", "pxsl_lines_op", | |
| "pxsl_resol_system", "pxs_steps_invert_matrix", "pxs_compute_ech", | |
| "pxs_compute_ech_reduite", "pxs_system_simpl", "pxs_commute_matrix", | |
| "pxsl_pow_matrix", "pxs_invertible_matrix", "pxs_diag_matrix", | |
| "randmatrixrect", "pxs_finiterv", "pxsl_law", "pxsl_moment", | |
| "pxsl_scalar_product", "pxs_simul_law", "pxs_fct_finiterv", | |
| "pxsl_res_num", "pxsl_sum_vector", "pxs_nvirgzero", "pxsl_num", | |
| "pxs_gauss_jordan", "pxs_construct_RREF", | |
| "pxs_repeat_generate_sys", "pxs_break_all_colinear_rows", | |
| "pxsl_mult", "pxsl_choose_udv", "pxs_lang", "myst", | |
| "pxs_variation_number", | |
| "pxs_Interval", "pxs_Plotable", | |
| ] | |
| _STUB_CLASS_NAMES = ("pxs_Interval", "pxs_Plotable") | |
| def _make_stub_module(name: str) -> types.ModuleType: | |
| """Module stub PEP 562 : tout attribut inconnu est fourni (passthrough / | |
| classe universelle), et `__all__` couvre les helpers connus pour que | |
| `from X import *` fonctionne.""" | |
| import re as _re | |
| mod = types.ModuleType(name) | |
| def __getattr__(attr): | |
| if attr == "pxs_config": | |
| return _config_stub | |
| if attr == "pxs_variation_number": | |
| return 1 # règle 13.2 : vaut toujours 1 | |
| if attr and (attr[0].isupper() or _re.match(r"pxs_[A-Z]", attr)): | |
| return _StubObject # ressemble Γ une classe | |
| return _passthrough | |
| mod.__getattr__ = __getattr__ | |
| mod.__all__ = list(KNOWN_PXS_HELPERS) | |
| return mod | |
| def install_pyxiscience_stubs() -> None: | |
| """ | |
| Register stub modules for `pyxiscience.*` in sys.modules so that generated | |
| Python blocks can `import` PyxiScience helpers without crashing. Idempotent. | |
| UNIFIΓ avec validation/harness.py (mΓͺme factory PEP 562 + __all__) : les | |
| deux systèmes partagent sys.modules, le premier installé sert aux deux. | |
| """ | |
| global _STUBS_INSTALLED | |
| if _STUBS_INSTALLED: | |
| return | |
| if "pyxiscience" in sys.modules: | |
| _STUBS_INSTALLED = True | |
| return | |
| pyxiscience = _make_stub_module("pyxiscience") | |
| sys.modules["pyxiscience"] = pyxiscience | |
| submodules = [ | |
| "Mes_fctions_generalistes_bis", | |
| "Classes_Extensions", | |
| "Mes_fctions_d_analyse_bis", | |
| "Mes_fctions_d_analyse", # alias without _bis (cf. Exo 2 Am. Sud) | |
| "Mes_fctions_d_alg_lineaire_bis", | |
| "Mes_fctions_probabilistes_bis", | |
| "Mes_fctions_generalistes", # alias historiques | |
| "Mes_fctions_probabilistes", | |
| "Mes_fctions_d_alg_lineaire", | |
| ] | |
| for sub in submodules: | |
| m = _make_stub_module(f"pyxiscience.{sub}") | |
| sys.modules[f"pyxiscience.{sub}"] = m | |
| setattr(pyxiscience, sub, m) | |
| # Top-level convenience attribute (some code does `import pyxiscience`) | |
| pyxiscience.pxs_variation_number = 1 # règle 13.2 | |
| # Stub `src.scripts.pxs_runtime` for `myst()` helper used in conditional text. | |
| # Observed in real exos like the binomiale exercise: | |
| # from src.scripts.pxs_runtime import myst | |
| # shot_name = myst(r"{fr}`...`{en}`...`") | |
| src_mod = types.ModuleType("src") | |
| scripts_mod = types.ModuleType("src.scripts") | |
| runtime_mod = types.ModuleType("src.scripts.pxs_runtime") | |
| runtime_mod.myst = _passthrough | |
| sys.modules["src"] = src_mod | |
| sys.modules["src.scripts"] = scripts_mod | |
| sys.modules["src.scripts.pxs_runtime"] = runtime_mod | |
| src_mod.scripts = scripts_mod | |
| scripts_mod.pxs_runtime = runtime_mod | |
| _STUBS_INSTALLED = True | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. Extract the main python block from an assembled exercise | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Fence {python} Γ 3 OU 4 backticks (l'app Γ©met 4 β convention plateforme β | |
| # mais la lecture reste tolΓ©rante pour les contenus legacy). | |
| _PYTHON_FENCE_RE = re.compile(r"(?ms)^(`{3,4})\{python\}[ \t]*\n(.*?)\n\1[ \t]*$") | |
| def extract_main_python_block(exercise: str) -> Optional[str]: | |
| """ | |
| Return the contents of the FIRST {python} block in the assembled | |
| exercise (which by convention holds the imports + random sampling + | |
| main computations β rΓ¨gle 3.1). Returns None if absent. | |
| """ | |
| m = _PYTHON_FENCE_RE.search(exercise) | |
| return m.group(2) if m else None | |
| def extract_all_python_blocks(exercise: str) -> list[str]: | |
| """Return all {python} block bodies, in order.""" | |
| return [m.group(2) for m in _PYTHON_FENCE_RE.finditer(exercise)] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. Sandboxed exec with timeout | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ExecTimeout(Exception): | |
| """Raised when exec exceeds its time budget.""" | |
| class _ExecKill(BaseException): | |
| """Escalade du timeout : BaseException pour percer les `except Exception` | |
| avaleurs du code gΓ©nΓ©rΓ© (seul un `except:` nu peut encore l'attraper).""" | |
| def run_with_timeout(fn, timeout: float): | |
| """ | |
| Run `fn()` under a timeout β utilisΓ© pour l'exec sandboxΓ© ET pour le | |
| rendu/scan par graine du harnais (un `str()` sympy sur une expression | |
| gΓ©ante peut mouliner des heures : vu au banc du 2026-07-02). | |
| Two strategies: | |
| β’ Main thread β `signal.SIGALRM` avec re-tir pΓ©riodique (interval) : | |
| 1er tir = ExecTimeout ; tirs suivants = _ExecKill (BaseException), | |
| car un `try/except Exception` du code gΓ©nΓ©rΓ© avale ExecTimeout mais | |
| ne peut pas attraper une BaseException. Seul un `except:` nu rΓ©siste. | |
| β’ Background thread (Flask worker) β daemon thread + `Event.wait`. | |
| The daemon thread can't actually be killed in Python; it survives the | |
| timeout but doesn't block subsequent execs since each call spawns a | |
| fresh daemon. Acceptable for short math-only workloads. | |
| """ | |
| if threading.current_thread() is threading.main_thread(): | |
| fired = {"n": 0} | |
| def _handler(signum, frame): | |
| fired["n"] += 1 | |
| if fired["n"] == 1: | |
| raise ExecTimeout("exec exceeded its time budget") | |
| raise _ExecKill | |
| old_handler = signal.signal(signal.SIGALRM, _handler) | |
| signal.setitimer(signal.ITIMER_REAL, timeout, 0.5) | |
| try: | |
| return fn() | |
| except _ExecKill: | |
| raise ExecTimeout( | |
| f"exec tué après {timeout}s (timeout avalé par le code ?)" | |
| ) from None | |
| finally: | |
| signal.setitimer(signal.ITIMER_REAL, 0) | |
| signal.signal(signal.SIGALRM, old_handler) | |
| # Background-thread variant: run in a daemon child thread. | |
| captured: dict[str, object] = {"exc": None, "ret": None} | |
| done = threading.Event() | |
| def _target() -> None: | |
| try: | |
| captured["ret"] = fn() | |
| except BaseException as e: # noqa: BLE001 β re-raised below | |
| captured["exc"] = e | |
| finally: | |
| done.set() | |
| worker = threading.Thread(target=_target, daemon=True) | |
| worker.start() | |
| if not done.wait(timeout): | |
| raise ExecTimeout(f"exec exceeded {timeout}s") | |
| if captured["exc"] is not None: | |
| raise captured["exc"] # type: ignore[misc] | |
| return captured["ret"] | |
| def _exec_with_timeout(code: str, namespace: dict, timeout: float) -> None: | |
| compiled = compile(code, "<sandbox>", "exec") | |
| run_with_timeout(lambda: exec(compiled, namespace), timeout) | |
| def exec_python_block( | |
| code: str, | |
| seed: int = 0, | |
| extra_globals: Optional[dict] = None, | |
| timeout: float = 5.0, | |
| ) -> dict: | |
| """ | |
| Execute `code` once, pre-seeding `random` with `seed`. | |
| Returns: | |
| {"success": bool, "ns": dict | None, "error": str | None} | |
| On success, `ns` contains the namespace after exec (variables available | |
| for assertion evaluation). | |
| Stubs `pyxiscience.*` and disallows obvious filesystem / network builtins | |
| by stripping them from the namespace. | |
| """ | |
| install_pyxiscience_stubs() | |
| namespace: dict[str, Any] = { | |
| "__name__": "__sandbox__", | |
| "__builtins__": _safe_builtins(), | |
| } | |
| if extra_globals: | |
| namespace.update(extra_globals) | |
| # Pre-seed both random and numpy.random (cheap; ignored if numpy not used). | |
| preamble = ( | |
| f"import random as _rnd_internal; _rnd_internal.seed({seed})\n" | |
| f"try:\n" | |
| f" import numpy as _np_internal; _np_internal.random.seed({seed})\n" | |
| f"except Exception:\n" | |
| f" pass\n" | |
| ) | |
| full_code = preamble + code | |
| try: | |
| _exec_with_timeout(full_code, namespace, timeout) | |
| return {"success": True, "ns": namespace, "error": None} | |
| except ExecTimeout as e: | |
| return {"success": False, "ns": None, "error": f"timeout ({timeout}s)"} | |
| except Exception as e: | |
| return {"success": False, "ns": None, "error": f"{type(e).__name__}: {e}"} | |
| def _safe_builtins() -> dict: | |
| """ | |
| Return a copy of builtins with dangerous filesystem/network names removed. | |
| The sandboxed code is generated by an LLM operating on math content; we | |
| don't want it to accidentally `open(...)` or `__import__('subprocess')`. | |
| """ | |
| blocked = { | |
| "open", "input", "exit", "quit", "compile", "eval", "exec", | |
| "__import__", # block dynamic imports β explicit imports in code still work via the import statement | |
| } | |
| safe: dict[str, Any] = {} | |
| for name in dir(builtins): | |
| if name in blocked: | |
| continue | |
| safe[name] = getattr(builtins, name) | |
| # `__import__` we restore but wrap to whitelist | |
| safe["__import__"] = _safe_import | |
| return safe | |
| _ALLOWED_TOP_LEVEL_MODULES = { | |
| "random", "math", "sympy", "numpy", "fractions", "pandas", | |
| "matplotlib", "scipy", "itertools", "functools", "collections", | |
| "decimal", "statistics", "operator", "copy", "re", "json", | |
| "pyxiscience", # stubbed | |
| "src", # stubbed (for `from src.scripts.pxs_runtime import myst`) | |
| } | |
| def _safe_import(name, *args, **kwargs): | |
| top = name.split(".")[0] | |
| if top not in _ALLOWED_TOP_LEVEL_MODULES: | |
| raise ImportError(f"import of {name!r} blocked in sandbox") | |
| return builtins.__import__(name, *args, **kwargs) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. Multi-seed validation for règle 4.3 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def multi_seed_validate( | |
| code: str, | |
| assertions: list[dict], | |
| num_seeds: int = 100, | |
| timeout_per_seed: float = 3.0, | |
| ) -> dict: | |
| """ | |
| Run `code` num_seeds times with seeds 0..num_seeds-1 and evaluate each | |
| assertion in the resulting namespace. Each assertion is a dict with: | |
| {"description": "...", "assertion": "<python boolean expression>"} | |
| Returns: | |
| { | |
| "num_seeds": int, | |
| "num_exec_errors": int, | |
| "violations": [ | |
| {"seed": int, "assertion": "...", "description": "...", "value": "False" | "<exception>"}, | |
| ... # capped at 5 per assertion | |
| ], | |
| "summary_per_assertion": {assertion_str: {"violations": int, "errors": int}} | |
| } | |
| """ | |
| summary: dict[str, dict[str, int]] = { | |
| a["assertion"]: {"violations": 0, "errors": 0} | |
| for a in assertions if "assertion" in a | |
| } | |
| violations: list[dict] = [] | |
| num_exec_errors = 0 | |
| first_exec_error: Optional[str] = None | |
| capped_assertions: set[str] = set() | |
| for seed in range(num_seeds): | |
| res = exec_python_block(code, seed=seed, timeout=timeout_per_seed) | |
| if not res["success"]: | |
| num_exec_errors += 1 | |
| if first_exec_error is None: | |
| first_exec_error = res["error"] | |
| continue | |
| ns = res["ns"] | |
| for a in assertions: | |
| assertion = a.get("assertion") | |
| description = a.get("description", "") | |
| if not isinstance(assertion, str) or not assertion.strip(): | |
| continue | |
| try: | |
| ok = bool(eval(assertion, ns)) | |
| if not ok: | |
| summary[assertion]["violations"] += 1 | |
| if assertion not in capped_assertions and len( | |
| [v for v in violations if v["assertion"] == assertion] | |
| ) < 5: | |
| violations.append({ | |
| "seed": seed, | |
| "assertion": assertion, | |
| "description": description, | |
| "value": "False", | |
| }) | |
| except Exception as e: | |
| summary[assertion]["errors"] += 1 | |
| if assertion not in capped_assertions and len( | |
| [v for v in violations if v["assertion"] == assertion] | |
| ) < 5: | |
| violations.append({ | |
| "seed": seed, | |
| "assertion": assertion, | |
| "description": description, | |
| "value": f"{type(e).__name__}: {e}", | |
| }) | |
| return { | |
| "num_seeds": num_seeds, | |
| "num_exec_errors": num_exec_errors, | |
| "first_exec_error": first_exec_error, | |
| "violations": violations, | |
| "summary_per_assertion": summary, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. Static AST checks for règles 11.1 (unused random vars in plot) | |
| # and 11.4 (Rational sympy * numpy array) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Names of plotting functions to look for (matplotlib API used in PyxiScience). | |
| _PLOT_FN_NAMES = { | |
| "plot", "scatter", "fill_between", "fill", "vlines", "hlines", | |
| "axhline", "axvline", "text", "annotate", "errorbar", "stem", | |
| "step", "imshow", "contour", "contourf", "quiver", "stairs", | |
| } | |
| def static_check_unused_random_vars( | |
| code: str, | |
| random_var_names: list[str], | |
| markdown_text: str = "", | |
| ) -> list[str]: | |
| """ | |
| Règle 11.1 : any variable sampled randomly should be referenced either | |
| in the rest of the Python code (Load context) OR somewhere in the MyST | |
| markdown (`{{var}}` placeholder). Otherwise it's dead code. | |
| `markdown_text` should be the assembled exercise WITHOUT the Python | |
| blocks (or the whole exercise β both work because the regex looks for | |
| `{{var}}` patterns which only appear in MyST sections). | |
| Returns the list of names that have ZERO references anywhere. Empty | |
| list = règle respectée. | |
| """ | |
| if not random_var_names: | |
| return [] | |
| referenced: set[str] = set() | |
| # 1) References in the Python code (Load context). | |
| try: | |
| tree = ast.parse(code) | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): | |
| if node.id in random_var_names: | |
| referenced.add(node.id) | |
| except SyntaxError: | |
| pass # leniency: don't false-positive on parse failures | |
| # 2) References in the MyST markdown ({{var}} or {{var.foo}} or {{f(var)}}). | |
| if markdown_text: | |
| for name in random_var_names: | |
| if name in referenced: | |
| continue | |
| if re.search(rf"\{{\{{[^}}]*\b{re.escape(name)}\b[^}}]*\}}\}}", markdown_text): | |
| referenced.add(name) | |
| return [v for v in random_var_names if v not in referenced] | |
| def static_check_rational_numpy_mix(code: str) -> list[dict]: | |
| """ | |
| Règle 11.4 : detect `Rational(...) * <np.array_expr>` or similar | |
| sympy-Rational β numpy mixes that crash at runtime. | |
| Detection is intentionally narrow to avoid false positives. We flag: | |
| Rational(...) * <expr_referencing_np> | |
| <expr_referencing_np> * Rational(...) | |
| where `<expr_referencing_np>` contains a `np.something` or a name we | |
| recognise as a numpy array (heuristic: contains `_graph` suffix). | |
| """ | |
| issues: list[dict] = [] | |
| try: | |
| tree = ast.parse(code) | |
| except SyntaxError: | |
| return issues | |
| def _is_rational_call(node: ast.AST) -> bool: | |
| return ( | |
| isinstance(node, ast.Call) | |
| and isinstance(node.func, ast.Name) | |
| and node.func.id == "Rational" | |
| ) | |
| def _references_numpy(node: ast.AST) -> bool: | |
| for sub in ast.walk(node): | |
| if isinstance(sub, ast.Attribute) and isinstance(sub.value, ast.Name): | |
| if sub.value.id in {"np", "numpy"}: | |
| return True | |
| if isinstance(sub, ast.Name) and ( | |
| sub.id.endswith("_graph") or sub.id.endswith("_arr") | |
| ): | |
| return True | |
| return False | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mult, ast.Add, ast.Sub)): | |
| left, right = node.left, node.right | |
| if (_is_rational_call(left) and _references_numpy(right)) or ( | |
| _is_rational_call(right) and _references_numpy(left) | |
| ): | |
| issues.append({ | |
| "rule": "11.4", | |
| "message": ( | |
| "MΓ©lange Rational(sympy) β numpy dΓ©tectΓ© Γ la ligne " | |
| f"{getattr(node, 'lineno', '?')} β convertir Rational en float() " | |
| "AVANT toute opΓ©ration numpy." | |
| ), | |
| }) | |
| return issues | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. Dynamic matplotlib check for règle 11.3 (labels in plot window) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def dynamic_check_matplotlib(code: str, timeout: float = 8.0) -> list[dict]: | |
| """ | |
| Execute `code` with a headless matplotlib backend, then inspect every | |
| Text artist on every axis and flag any whose position falls outside | |
| [xlim, ylim] (règle 11.3). | |
| Notes: | |
| β’ Only runs if `matplotlib` is imported in the code (avoid pointless exec). | |
| β’ Uses `matplotlib.use("Agg", force=True)` BEFORE the user code imports | |
| matplotlib β this is achieved by pre-importing pyplot in the namespace | |
| with the Agg backend already set. | |
| β’ `plt.show()` becomes a no-op under Agg, so the user code runs to | |
| completion without opening a window. | |
| """ | |
| if "matplotlib" not in code: | |
| return [] | |
| import matplotlib | |
| matplotlib.use("Agg", force=True) | |
| import matplotlib.pyplot as plt | |
| # Reset figure state to isolate runs (close any leftovers). | |
| plt.close("all") | |
| # Inject `plt`/`matplotlib` already set up into the namespace so the user | |
| # code's `import matplotlib.pyplot as plt` finds the Agg backend. | |
| extra = {} | |
| res = exec_python_block(code, seed=0, extra_globals=extra, timeout=timeout) | |
| issues: list[dict] = [] | |
| if not res["success"]: | |
| # Don't fault the user; runtime errors are caught elsewhere. | |
| plt.close("all") | |
| return [] | |
| for fig_num in plt.get_fignums(): | |
| fig = plt.figure(fig_num) | |
| for ax in fig.get_axes(): | |
| try: | |
| xmin, xmax = ax.get_xlim() | |
| ymin, ymax = ax.get_ylim() | |
| except Exception: | |
| continue | |
| for text_artist in ax.texts: | |
| try: | |
| x, y = text_artist.get_position() | |
| except Exception: | |
| continue | |
| # Numeric only; skip annotations with non-numeric positions. | |
| if not (isinstance(x, (int, float)) and isinstance(y, (int, float))): | |
| continue | |
| out_of_bounds = (x < xmin or x > xmax or y < ymin or y > ymax) | |
| if out_of_bounds: | |
| label = text_artist.get_text() | |
| snippet = label.strip()[:40].replace("\n", " ") | |
| issues.append({ | |
| "rule": "11.3", | |
| "message": ( | |
| f"Label Β« {snippet} Β» Γ ({x:.2f}, {y:.2f}) sort de la fenΓͺtre " | |
| f"[{xmin:.1f}, {xmax:.1f}] Γ [{ymin:.1f}, {ymax:.1f}]. " | |
| "Matplotlib va Γ©tendre l'axe et compresser le graphique." | |
| ), | |
| }) | |
| plt.close("all") | |
| return issues | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. Smoke test (run module directly: `python utils/exec_validator.py`) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| code = """ | |
| import random as rd | |
| from sympy import Rational | |
| a = rd.randint(1, 10) | |
| b = rd.choice([2, 3, 4]) | |
| result = Rational(a, b) | |
| """ | |
| print("[smoke] running 10 seeds with simple code...") | |
| out = multi_seed_validate( | |
| code, | |
| assertions=[ | |
| {"description": "a est positif", "assertion": "a > 0"}, | |
| {"description": "b est dans {2,3,4}", "assertion": "b in (2, 3, 4)"}, | |
| {"description": "a < b (BUG attendu sur certains seeds)", "assertion": "a < b"}, | |
| ], | |
| num_seeds=10, | |
| ) | |
| print(out) | |