learnstat 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
learnstat/recovery.py ADDED
@@ -0,0 +1,1021 @@
1
+ """Runtime error diagnosis and guarded automatic recovery.
2
+
3
+ Design rules (from the project philosophy):
4
+
5
+ * Code is executed one top-level statement at a time. When statement *k*
6
+ fails, only statement *k* is corrected and retried - statements that already
7
+ ran are never re-executed, so side effects are not duplicated.
8
+ * Errors are classified (syntax, name, attribute, argument, data quality,
9
+ statistical method, environment, other runtime). Only mechanical mistakes -
10
+ misspelled names, columns, attributes, keywords and option values, a missing
11
+ import, an unclosed bracket - are corrected automatically, and only when the
12
+ correction is unambiguous: a slip that is one keystroke from two valid names
13
+ is refused, and a replacement method must accept the call as written.
14
+ * Data-quality and statistical-method errors are *explained*, never "fixed":
15
+ converting types, dropping missing values or switching tests are analytical
16
+ decisions that belong to the user.
17
+ * Every candidate fix is validated before it runs: it must compile, must not
18
+ introduce a destructive operation, must not change assignment targets, must
19
+ not add data-altering calls and must not swap statistical methods.
20
+ * Code that runs but returns a silent problem (a NaN test result, a SciPy
21
+ small-sample warning) is left unchanged and explained in a note.
22
+ * Retries are bounded. If a fix fails or the same error recurs, recovery stops
23
+ and reports an actionable next step. The user's source is never modified;
24
+ the corrected code is shown as a diff.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import ast
30
+ import builtins
31
+ import difflib
32
+ import inspect
33
+ import re
34
+ import sys
35
+ import traceback
36
+ import warnings
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Callable
39
+
40
+ import pandas as pd
41
+
42
+ from . import safety
43
+ from .advisor import call_name, dotted_name
44
+
45
+ COMMON_IMPORTS = {
46
+ "pd": "import pandas as pd",
47
+ "np": "import numpy as np",
48
+ "plt": "import matplotlib.pyplot as plt",
49
+ "stats": "from scipy import stats",
50
+ "sns": "import seaborn as sns",
51
+ "sm": "import statsmodels.api as sm",
52
+ "math": "import math",
53
+ "ls": "import learnstat as ls",
54
+ }
55
+
56
+ # Keyword arguments that matplotlib/pandas accept through **kwargs, so they are
57
+ # not visible in inspect.signature().
58
+ COMMON_KWARGS = {
59
+ "color", "colors", "label", "labels", "alpha", "linewidth", "linestyle", "marker", "markersize",
60
+ "figsize", "bins", "title", "xlabel", "ylabel", "edgecolor", "facecolor", "cmap", "kind", "ax",
61
+ "legend", "grid", "rot", "fontsize", "density", "orientation", "stacked", "autopct", "startangle",
62
+ "subplots", "sharex", "sharey", "xlim", "ylim", "logx", "logy", "width", "height", "align",
63
+ "dpi", "loc", "ncol", "zorder", "hue", "palette", "equal_var", "alternative", "nan_policy",
64
+ "method", "axis", "ascending", "normalize", "dropna", "numeric_only", "inplace", "subset",
65
+ "how", "on", "columns", "index", "values", "aggfunc", "sep", "header", "usecols", "parse_dates",
66
+ }
67
+ KWARG_ALIASES = {"colour": "color", "colours": "colors", "fig_size": "figsize", "figSize": "figsize",
68
+ "line_width": "linewidth", "line_style": "linestyle", "colormap": "cmap", "xlab": "xlabel",
69
+ "ylab": "ylabel", "main": "title", "lwd": "linewidth", "col": "color"}
70
+
71
+ STAT_FUNCS = {
72
+ "ttest_ind", "ttest_rel", "ttest_1samp", "mannwhitneyu", "wilcoxon", "pearsonr", "spearmanr",
73
+ "kendalltau", "f_oneway", "kruskal", "chi2_contingency", "chisquare", "fisher_exact", "shapiro",
74
+ "normaltest", "kstest", "anderson", "levene", "bartlett", "linregress", "ols", "OLS", "logit",
75
+ "Logit", "glm", "GLM", "anova_lm", "corr", "mean", "median", "std", "var", "describe",
76
+ }
77
+ DATA_ALTERING = {
78
+ "dropna", "fillna", "drop", "drop_duplicates", "replace", "astype", "to_numeric", "to_datetime",
79
+ "interpolate", "clip", "sample", "query", "rename", "where", "mask", "bfill", "ffill", "update",
80
+ }
81
+ _STAT_MODULES = ("scipy", "statsmodels", "sklearn", "pingouin")
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Data classes
86
+
87
+
88
+ @dataclass
89
+ class Diagnosis:
90
+ category: str # syntax | name | attribute | argument | data_quality | statistical | environment | runtime
91
+ exc_type: str
92
+ message: str
93
+ line: int | None
94
+ statement: str
95
+ explanation: str
96
+ next_step: str
97
+
98
+ @property
99
+ def label(self) -> str:
100
+ return {
101
+ "syntax": "syntax error",
102
+ "name": "undefined name",
103
+ "attribute": "invalid attribute",
104
+ "argument": "invalid function argument",
105
+ "column": "column not found",
106
+ "data_quality": "data-quality problem",
107
+ "statistical": "statistical-method error",
108
+ "environment": "environment / missing package",
109
+ "runtime": "runtime exception",
110
+ }.get(self.category, self.category)
111
+
112
+
113
+ @dataclass
114
+ class Fix:
115
+ kind: str # name | import | attribute | column | keyword | option | syntax | ai
116
+ description: str
117
+ fixed: str
118
+ renames: dict[str, str] = field(default_factory=dict)
119
+ needs_confirmation: bool = False
120
+
121
+
122
+ @dataclass
123
+ class Attempt:
124
+ diagnosis: Diagnosis
125
+ fix: Fix | None = None
126
+ rejected_reasons: list[str] = field(default_factory=list)
127
+ applied: bool = False
128
+ succeeded: bool = False
129
+ diff: str = ""
130
+
131
+
132
+ @dataclass
133
+ class RunResult:
134
+ ok: bool
135
+ original_code: str
136
+ final_code: str
137
+ attempts: list[Attempt] = field(default_factory=list)
138
+ error: BaseException | None = None
139
+ blocked: list[safety.SafetyFinding] = field(default_factory=list)
140
+ value: Any = None
141
+ stopped_at_line: int | None = None
142
+ notes: list[str] = field(default_factory=list) # silent problems, e.g. a test that returned NaN
143
+
144
+ @property
145
+ def changed(self) -> bool:
146
+ return self.final_code != self.original_code
147
+
148
+ @property
149
+ def last_diagnosis(self) -> Diagnosis | None:
150
+ return self.attempts[-1].diagnosis if self.attempts else None
151
+
152
+ def report(self) -> str:
153
+ if self.blocked:
154
+ lines = ["Execution blocked: this code has side effects that need your permission:"]
155
+ lines += [f" - {f}" for f in self.blocked]
156
+ lines.append("Nothing was executed. Re-run and confirm if this is intended.")
157
+ return "\n".join(lines)
158
+ if not self.attempts:
159
+ if self.notes:
160
+ return "\n".join(["Ran, but the result needs a second look:"] + [f" - {n}" for n in self.notes])
161
+ return "Ran without errors."
162
+ out: list[str] = []
163
+ for i, a in enumerate(self.attempts, 1):
164
+ d = a.diagnosis
165
+ where = f"line {d.line}" if d.line else "code"
166
+ out.append(f"[{i}] {d.exc_type} at {where} - {d.label}")
167
+ out.append(f" {d.message}")
168
+ out.append(f" Why: {d.explanation}")
169
+ if a.fix:
170
+ status = "applied" if a.applied else ("rejected" if a.rejected_reasons else "proposed, not applied")
171
+ out.append(f" Fix ({status}): {a.fix.description}")
172
+ if a.diff:
173
+ out += [f" {ln}" for ln in a.diff.splitlines()]
174
+ for r in a.rejected_reasons:
175
+ out.append(f" x {r}")
176
+ if a.applied:
177
+ out.append(" Result: " + ("re-ran successfully" if a.succeeded else "still failing"))
178
+ if not a.succeeded:
179
+ out.append(f" Next step: {d.next_step}")
180
+ if self.notes:
181
+ out.append("")
182
+ out += ["Note: " + n for n in self.notes]
183
+ if self.ok and self.changed:
184
+ out.append("")
185
+ out.append("Recovered. Your original code was not modified; corrected version:")
186
+ out += [" " + ln for ln in self.final_code.splitlines()]
187
+ elif not self.ok:
188
+ out.append("")
189
+ out.append(
190
+ f"Stopped at line {self.stopped_at_line}; statements after it were not run."
191
+ if self.stopped_at_line else "Nothing was run."
192
+ )
193
+ return "\n".join(out)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Helpers
198
+
199
+
200
+ def _edit_distance(a: str, b: str) -> int:
201
+ """Optimal string alignment distance: insertions, deletions, substitutions and adjacent swaps."""
202
+ prev2, prev = None, list(range(len(b) + 1))
203
+ for i in range(1, len(a) + 1):
204
+ cur = [i] + [0] * len(b)
205
+ for j in range(1, len(b) + 1):
206
+ cost = a[i - 1] != b[j - 1]
207
+ cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
208
+ if prev2 is not None and i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
209
+ cur[j] = min(cur[j], prev2[j - 2] + 1)
210
+ prev2, prev = prev, cur
211
+ return prev[-1]
212
+
213
+
214
+ def _close(word: str, candidates, cutoff: float = 0.75) -> str | None:
215
+ """Unique high-similarity match, preferring case/whitespace-insensitive equality.
216
+
217
+ A name that is a single typing slip (one insertion, deletion, substitution
218
+ or swap of adjacent letters) away from two or more valid names is
219
+ ambiguous, and nothing is guessed. A name one slip from exactly one valid
220
+ name gets that name, provided it also passes the similarity cutoff.
221
+ Otherwise the best difflib match is used unless the top two nearly tie.
222
+ """
223
+ candidates = [str(c) for c in candidates]
224
+ norm = {c.lower().strip().replace(" ", "_"): c for c in candidates}
225
+ key = word.lower().strip().replace(" ", "_")
226
+ if key in norm and norm[key] != word:
227
+ return norm[key]
228
+ one_slip = [c for c in set(candidates) if c != word and _edit_distance(word, c) == 1]
229
+ if len(one_slip) > 1:
230
+ return None
231
+ matches = difflib.get_close_matches(word, candidates, n=2, cutoff=cutoff)
232
+ if not matches:
233
+ return None
234
+ if one_slip:
235
+ return one_slip[0] if one_slip[0] in matches else None
236
+ if len(matches) == 2:
237
+ r1 = difflib.SequenceMatcher(None, word, matches[0]).ratio()
238
+ r2 = difflib.SequenceMatcher(None, word, matches[1]).ratio()
239
+ if abs(r1 - r2) < 0.05: # near tie with no single-slip candidate: do not guess
240
+ return None
241
+ return matches[0] if matches[0] != word else None
242
+
243
+
244
+ def _fits_call(obj: Any, attr: str, call: ast.Call | None) -> bool:
245
+ """Whether ``obj.attr`` can be used the way the user's code uses it.
246
+
247
+ For a call such as ``s.modw()`` the replacement must be callable and
248
+ accept the same number of positional arguments and the same keywords;
249
+ ``s.mod()`` needs an argument and ``s.axes`` is not callable, so neither
250
+ can stand in for it.
251
+ """
252
+ if call is None:
253
+ return True
254
+ try:
255
+ value = getattr(obj, attr)
256
+ except Exception: # noqa: BLE001 - a property that raises is not a usable replacement
257
+ return False
258
+ if not callable(value):
259
+ return False
260
+ if any(isinstance(a, ast.Starred) for a in call.args) or any(k.arg is None for k in call.keywords):
261
+ return True
262
+ try:
263
+ inspect.signature(value).bind(*call.args, **{k.arg: None for k in call.keywords})
264
+ except TypeError:
265
+ return False
266
+ except ValueError:
267
+ return True # no signature available (some builtins); do not exclude
268
+ return True
269
+
270
+
271
+ def _diff(a: str, b: str) -> str:
272
+ return "\n".join(
273
+ ln for ln in difflib.unified_diff(a.splitlines(), b.splitlines(), lineterm="", n=0)
274
+ if not ln.startswith(("---", "+++", "@@"))
275
+ )
276
+
277
+
278
+ def _replace_spans(src: str, spans: list[tuple[int, int, int, int, str]]) -> str:
279
+ """Replace (lineno, col, end_lineno, end_col, text) spans; line numbers are 1-based within src."""
280
+ lines = src.splitlines(keepends=True)
281
+ offsets = [0]
282
+ for ln in lines:
283
+ offsets.append(offsets[-1] + len(ln))
284
+
285
+ def pos(line: int, col: int) -> int:
286
+ # ast col offsets are UTF-8 byte offsets
287
+ text = lines[line - 1].encode("utf-8")[:col].decode("utf-8", errors="ignore")
288
+ return offsets[line - 1] + len(text)
289
+
290
+ out = src
291
+ for lineno, col, end_lineno, end_col, text in sorted(spans, key=lambda s: (s[0], s[1]), reverse=True):
292
+ a, b = pos(lineno, col), pos(end_lineno, end_col)
293
+ out = out[:a] + text + out[b:]
294
+ return out
295
+
296
+
297
+ def _safe_resolve(node: ast.AST, ns: dict) -> Any:
298
+ """Resolve Name/Attribute chains (e.g. ``stats.ttest_ind``) without calling anything."""
299
+ if isinstance(node, ast.Name):
300
+ if node.id in ns:
301
+ return ns[node.id]
302
+ return getattr(builtins, node.id, None)
303
+ if isinstance(node, ast.Attribute):
304
+ base = _safe_resolve(node.value, ns)
305
+ if base is None:
306
+ return None
307
+ try:
308
+ return getattr(base, node.attr, None)
309
+ except Exception: # properties can raise
310
+ return None
311
+ if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant):
312
+ base = _safe_resolve(node.value, ns)
313
+ if isinstance(base, pd.DataFrame) and node.slice.value in base.columns:
314
+ return base[node.slice.value]
315
+ return None
316
+
317
+
318
+ def _frames_in(tree: ast.AST, ns: dict) -> list[tuple[str, pd.DataFrame]]:
319
+ out = []
320
+ for node in ast.walk(tree):
321
+ if isinstance(node, ast.Name) and isinstance(ns.get(node.id), pd.DataFrame):
322
+ if node.id not in [n for n, _ in out]:
323
+ out.append((node.id, ns[node.id]))
324
+ return out
325
+
326
+
327
+ def _from_stat_library(tb) -> bool:
328
+ for frame in traceback.extract_tb(tb):
329
+ fn = frame.filename.replace("\\", "/")
330
+ if any(f"/{m}/" in fn for m in _STAT_MODULES):
331
+ return True
332
+ return False
333
+
334
+
335
+ def _is_simple(stmt: ast.stmt) -> bool:
336
+ """Statements that either fully complete or have no effect when they raise."""
337
+ return isinstance(stmt, (ast.Expr, ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Import, ast.ImportFrom))
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Diagnosis
342
+
343
+
344
+ _DATA_QUALITY_PATTERNS = [
345
+ (r"could not convert string to float|Unable to parse string|invalid literal for int",
346
+ "A column that should be numeric contains text values."),
347
+ (r"unsupported operand type|can only concatenate str|must be real number, not str|"
348
+ r"Could not convert .* to numeric|agg function failed|not supported between instances of 'str'",
349
+ "An operation mixed numbers with text - usually a numeric column stored as strings (dtype object)."),
350
+ (r"Cannot convert non-finite values|cannot convert float NaN to integer|Cannot convert NA to integer",
351
+ "The data contains missing values (NaN) where whole numbers were expected."),
352
+ (r"time data .* does not match format|Unknown datetime string format|Out of bounds nanosecond",
353
+ "Some date values do not match the expected date format."),
354
+ ]
355
+ _STATISTICAL_PATTERNS = [
356
+ (r"expected frequenc", "Some cells of the contingency table have zero (or very low) expected counts, "
357
+ "so the chi-square approximation is not valid."),
358
+ (r"at least (2|two|3|three)|must have length at least|need at least|too few|not enough",
359
+ "There are too few observations for this test (often because a filter or dropna left a group almost empty)."),
360
+ (r"infs or NaNs|contains NaN|nan values|NaN", "The input contains missing or infinite values, which this method cannot handle."),
361
+ (r"zero-size array|empty|0 samples", "The data passed to the method is empty."),
362
+ (r"singular matrix|Singular|perfect separation|collinear",
363
+ "The model's predictors are perfectly correlated or separate the outcome perfectly."),
364
+ (r"constant|identical", "The input has no variation (all values equal), so the statistic is undefined."),
365
+ (r"negative|non-negative", "This method requires non-negative values (e.g. counts)."),
366
+ ]
367
+
368
+
369
+ _WARNING_PATTERNS = [
370
+ (r"too small|SmallSample|at least \d|fewer than", "the sample (or one group) is too small for this method, "
371
+ "so it returned NaN. Check group sizes with `len()` or `.value_counts()` before testing."),
372
+ (r"Degrees of freedom <= 0|ddof", "there are not enough observations to estimate a variance."),
373
+ (r"Precision loss|catastrophic cancellation|nearly identical", "the values are (almost) constant, so the "
374
+ "statistic is not reliable."),
375
+ (r"invalid value encountered|divide by zero|Mean of empty slice", "a calculation produced NaN or infinity, "
376
+ "usually because of missing values or an empty selection."),
377
+ (r"[Cc]hi-?squared approximation|expected frequenc", "some expected counts are very low, so the chi-square "
378
+ "approximation may be invalid."),
379
+ ]
380
+
381
+
382
+ def explain_warnings(caught) -> list[str]:
383
+ """Turn warnings raised by statistical code into plain-language notes (deduplicated)."""
384
+ notes: list[str] = []
385
+ for w in caught:
386
+ text = f"{w.category.__name__}: {w.message}"
387
+ from_stats = any(f"/{m}/" in str(w.filename).replace("\\", "/") for m in _STAT_MODULES + ("numpy", "pandas"))
388
+ for pattern, why in _WARNING_PATTERNS:
389
+ if re.search(pattern, text) and (from_stats or "SmallSample" in text):
390
+ note = f"{text.splitlines()[0][:160]} - this means {why}"
391
+ if note not in notes:
392
+ notes.append(note)
393
+ break
394
+ return notes
395
+
396
+
397
+ def _stat_calls(stmt: ast.AST) -> list[ast.Call]:
398
+ return [n for n in ast.walk(stmt) if isinstance(n, ast.Call)
399
+ and (dotted_name(n.func) or "").split(".")[0] in ("stats", "scipy", "st")]
400
+
401
+
402
+ def _nan_result_notes(stmt: ast.stmt, value: Any, ns: dict, returned: list | None = None) -> list[str]:
403
+ """A statistical call that ran but returned NaN (SciPy's default nan_policy) gets a note.
404
+
405
+ ``returned`` holds values returned by calls made directly from the
406
+ statement, so a test nested in print(...) or an f-string is checked too.
407
+ """
408
+ calls = _stat_calls(stmt)
409
+ if not calls:
410
+ return []
411
+ results = [value] + list(returned or [])
412
+ if isinstance(stmt, ast.Assign):
413
+ results += [ns.get(t.id) for t in stmt.targets if isinstance(t, ast.Name)]
414
+ for res in results:
415
+ stat = getattr(res, "statistic", None)
416
+ p = getattr(res, "pvalue", None)
417
+ try:
418
+ if any(v is not None and pd.isna(v) for v in (stat, p)):
419
+ name = call_name(calls[0])
420
+ return [f"`{name}` returned NaN. Its inputs probably contain missing values, which SciPy passes "
421
+ "through by default (nan_policy='propagate'). Decide how to handle them, for example "
422
+ "nan_policy='omit' or an explicit dropna on the columns used; learnstat will not choose for you."]
423
+ except (TypeError, ValueError):
424
+ continue
425
+ return []
426
+
427
+
428
+ def diagnose(exc: BaseException, statement: str, line: int | None, ns: dict) -> Diagnosis:
429
+ et = type(exc).__name__
430
+ msg = str(exc).strip() or et
431
+ d = Diagnosis("runtime", et, msg, line, statement, "", "")
432
+
433
+ if isinstance(exc, SyntaxError):
434
+ d.category = "syntax"
435
+ d.message = f"{exc.msg} (line {exc.lineno})"
436
+ d.line = exc.lineno
437
+ d.explanation = "Python could not parse the code, so nothing was run."
438
+ d.next_step = "Look at the line shown (and the one before it) for an unclosed bracket, quote or missing ':'."
439
+ return d
440
+ if isinstance(exc, ImportError):
441
+ d.category = "environment"
442
+ found = re.search(r"No module named '([\w.]+)'|^(\w+) is required", msg)
443
+ mod = getattr(exc, "name", None) or (found and (found.group(1) or found.group(2))) or "the module"
444
+ d.explanation = f"`{mod}` is not installed in this Python environment (or the import name is wrong)."
445
+ d.next_step = f"Install it yourself (e.g. `pip install {mod.split('.')[0]}`); learnstat never installs packages."
446
+ return d
447
+ if isinstance(exc, NameError):
448
+ d.category = "name"
449
+ name = getattr(exc, "name", None) or (re.findall(r"'(\w+)'", msg) or ["?"])[0]
450
+ d.explanation = f"`{name}` was used before it was defined or imported (or it is misspelled)."
451
+ d.next_step = f"Define or import `{name}` before this line, or check its spelling."
452
+ return d
453
+ if isinstance(exc, (TypeError, AttributeError)) and "unexpected keyword argument" in msg:
454
+ # matplotlib reports bad style keywords as AttributeError from Artist.set()
455
+ d.category = "argument"
456
+ d.explanation = "A function was given a keyword argument it does not accept (often a misspelling)."
457
+ d.next_step = "Check the function's parameters with `help(function)`."
458
+ return d
459
+ if isinstance(exc, AttributeError):
460
+ d.category = "attribute"
461
+ name = getattr(exc, "name", None) or "?"
462
+ obj = getattr(exc, "obj", None)
463
+ d.explanation = f"`{type(obj).__name__ if obj is not None else 'the object'}` has no attribute `{name}`."
464
+ d.next_step = "Check the spelling, or use `dir(obj)` / `help(obj)` to list what is available."
465
+ if isinstance(obj, pd.DataFrame):
466
+ d.next_step += " For columns, `df['name']` is safer than `df.name`."
467
+ return d
468
+ if isinstance(exc, KeyError):
469
+ d.category = "column"
470
+ key = exc.args[0] if exc.args else "?"
471
+ d.explanation = f"The key {key!r} does not exist - for DataFrames this usually means a misspelled column name."
472
+ d.next_step = "Print `list(df.columns)` and use the exact column name."
473
+ return d
474
+ if isinstance(exc, (TypeError, ValueError)) and re.search(r"missing \d+ required positional|takes \d+ positional|"
475
+ r"is not a valid|Invalid|invalid value|must be one of|"
476
+ r"must be either|was supplied|expected one of", msg):
477
+ d.category = "argument"
478
+ d.explanation = "A function was called with an argument value or number of arguments it does not accept."
479
+ d.next_step = "Compare the call with the function's documentation (`help(function)`)."
480
+ return d
481
+ for pattern, why in _DATA_QUALITY_PATTERNS:
482
+ if re.search(pattern, msg, re.I):
483
+ d.category = "data_quality"
484
+ d.explanation = why
485
+ d.next_step = _data_quality_next_step(statement, ns)
486
+ return d
487
+ if _from_stat_library(exc.__traceback__) or isinstance(exc, (ValueError, ZeroDivisionError, FloatingPointError)):
488
+ for pattern, why in _STATISTICAL_PATTERNS:
489
+ if re.search(pattern, msg, re.I):
490
+ d.category = "statistical"
491
+ d.explanation = why
492
+ d.next_step = (
493
+ "Check the input sizes and values (`len()`, `.isna().sum()`, `.value_counts()`), then decide "
494
+ "whether the method suits this data - see /alternatives. learnstat will not change the method for you."
495
+ )
496
+ return d
497
+ if _from_stat_library(exc.__traceback__):
498
+ d.category = "statistical"
499
+ d.explanation = "The statistical routine rejected its input."
500
+ d.next_step = "Read the message above, check the inputs, and use /describe or /alternatives for options."
501
+ return d
502
+ d.explanation = "The code raised an exception while running."
503
+ d.next_step = "Read the message, inspect the variables involved on that line, and fix the logic."
504
+ return d
505
+
506
+
507
+ def _data_quality_next_step(statement: str, ns: dict) -> str:
508
+ try:
509
+ tree = ast.parse(statement)
510
+ except SyntaxError:
511
+ tree = None
512
+ if tree is not None:
513
+ for node in ast.walk(tree):
514
+ if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant):
515
+ obj = _safe_resolve(node, ns)
516
+ if isinstance(obj, pd.Series) and not pd.api.types.is_numeric_dtype(obj):
517
+ frame = dotted_name(node.value) or "df"
518
+ col = node.slice.value
519
+ return (
520
+ f"`{frame}['{col}']` has dtype {obj.dtype}. Inspect the offending values with "
521
+ f"`{frame}[pd.to_numeric({frame}['{col}'], errors='coerce').isna()]['{col}'].unique()`, "
522
+ f"then decide how to convert them (e.g. `pd.to_numeric(..., errors='coerce')`) and "
523
+ "record that decision with `/memory decide ...`. learnstat will not change your data automatically."
524
+ )
525
+ return (
526
+ "Inspect the column types with `df.dtypes` and the offending values, then decide how to clean them. "
527
+ "learnstat will not change your data automatically."
528
+ )
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # Fix proposal (rule-based)
533
+
534
+
535
+ def propose_fix(exc: BaseException, d: Diagnosis, stmt_src: str, ns: dict) -> Fix | None:
536
+ if d.category == "syntax":
537
+ return _fix_syntax(exc, stmt_src)
538
+ try:
539
+ tree = ast.parse(stmt_src)
540
+ except SyntaxError:
541
+ return None
542
+
543
+ if d.category == "name":
544
+ name = getattr(exc, "name", None) or (re.findall(r"'(\w+)'", str(exc)) or [None])[0]
545
+ if not name:
546
+ return None
547
+ if name in COMMON_IMPORTS:
548
+ imp = COMMON_IMPORTS[name]
549
+ return Fix("import", f"added the missing import `{imp}`", f"{imp}\n{stmt_src}")
550
+ candidates = [k for k in ns if not k.startswith("_")] + dir(builtins)
551
+ new = _close(name, candidates, cutoff=0.8)
552
+ if new:
553
+ spans = [(n.lineno, n.col_offset, n.end_lineno, n.end_col_offset, new)
554
+ for n in ast.walk(tree) if isinstance(n, ast.Name) and n.id == name]
555
+ return Fix("name", f"renamed `{name}` to the existing name `{new}`", _replace_spans(stmt_src, spans), {name: new})
556
+ return None
557
+
558
+ if d.category == "attribute":
559
+ name, obj = getattr(exc, "name", None), getattr(exc, "obj", None)
560
+ if not name or obj is None:
561
+ return None
562
+ nodes = [n for n in ast.walk(tree) if isinstance(n, ast.Attribute) and n.attr == name]
563
+ if not nodes:
564
+ return None
565
+ public = [a for a in dir(obj) if not a.startswith("_")]
566
+ if isinstance(obj, pd.DataFrame):
567
+ col = _close(name, list(obj.columns), cutoff=0.8)
568
+ if col is not None and _close(name, public, cutoff=0.8) is None:
569
+ spans = [(n.value.end_lineno, n.value.end_col_offset, n.end_lineno, n.end_col_offset, f"[{col!r}]")
570
+ for n in nodes]
571
+ return Fix("column", f"`{name}` is not an attribute; selected the column `{col}` with `[{col!r}]`",
572
+ _replace_spans(stmt_src, spans), {name: col})
573
+ call = next((c for c in ast.walk(tree) if isinstance(c, ast.Call) and c.func in nodes), None)
574
+ near = [a for a in public if _edit_distance(name, a) == 1
575
+ or difflib.SequenceMatcher(None, name, a).ratio() >= 0.75 or a.lower() == name.lower()]
576
+ new = _close(name, [a for a in near if _fits_call(obj, a, call)], cutoff=0.75)
577
+ if new:
578
+ spans = [(n.value.end_lineno, n.value.end_col_offset, n.end_lineno, n.end_col_offset, f".{new}")
579
+ for n in nodes]
580
+ return Fix("attribute", f"`{name}` -> `{new}` (closest attribute of {type(obj).__name__})",
581
+ _replace_spans(stmt_src, spans), {name: new})
582
+ return None
583
+
584
+ if d.category == "column":
585
+ key = exc.args[0] if exc.args else None
586
+ if isinstance(key, str) and re.search(r"not in index|not in columns", key):
587
+ keys = re.findall(r"'([^']+)'", key) # list selection: "['Income'] not in index"
588
+ elif isinstance(key, str):
589
+ keys = [key]
590
+ else:
591
+ keys = re.findall(r"'([^']+)'", str(key))
592
+ frames = _frames_in(tree, ns)
593
+ columns = [str(c) for _, f in frames for c in f.columns]
594
+ if not columns:
595
+ return None
596
+ spans, renames = [], {}
597
+ for k in keys:
598
+ if k in columns:
599
+ continue
600
+ new = _close(k, columns, cutoff=0.75)
601
+ if not new:
602
+ continue
603
+ renames[k] = new
604
+ for n in ast.walk(tree):
605
+ if isinstance(n, ast.Constant) and n.value == k:
606
+ spans.append((n.lineno, n.col_offset, n.end_lineno, n.end_col_offset, repr(new)))
607
+ if spans:
608
+ desc = ", ".join(f"'{a}' -> '{b}'" for a, b in renames.items())
609
+ return Fix("column", f"corrected column name {desc} (closest existing column)", _replace_spans(stmt_src, spans), renames)
610
+ return None
611
+
612
+ if d.category == "argument":
613
+ m = re.search(r"unexpected keyword argument '(\w+)'", str(exc))
614
+ if m:
615
+ bad = m.group(1)
616
+ for call in (n for n in ast.walk(tree) if isinstance(n, ast.Call)):
617
+ kw = next((k for k in call.keywords if k.arg == bad), None)
618
+ if not kw:
619
+ continue
620
+ params: set[str] = set()
621
+ func = _safe_resolve(call.func, ns)
622
+ if func is not None:
623
+ try:
624
+ params = {p for p in inspect.signature(func).parameters if p not in ("args", "kwargs")}
625
+ except (TypeError, ValueError):
626
+ pass
627
+ new = KWARG_ALIASES.get(bad) or _close(bad, params | COMMON_KWARGS, cutoff=0.75)
628
+ if new and new != bad:
629
+ end_col = kw.value.col_offset
630
+ span = (kw.lineno, kw.col_offset, kw.value.lineno, end_col, f"{new}=")
631
+ return Fix("keyword", f"keyword `{bad}=` -> `{new}=`", _replace_spans(stmt_src, [span]), {bad: new})
632
+ return None
633
+ # invalid option value, e.g. kind='barr' or method='pearsn'
634
+ msg = str(exc)
635
+ valid = set(re.findall(r"'([A-Za-z_][\w-]*)'", msg)) | set(re.findall(r"\b([a-z_]{2,})\b", msg.split(":")[-1]))
636
+ for n in ast.walk(tree):
637
+ if isinstance(n, ast.Constant) and isinstance(n.value, str) and n.value and n.value in msg:
638
+ options = valid - {n.value}
639
+ new = _close(n.value, options, cutoff=0.75)
640
+ if new:
641
+ return Fix("option", f"option value '{n.value}' -> '{new}' (closest valid choice)",
642
+ _replace_spans(stmt_src, [(n.lineno, n.col_offset, n.end_lineno, n.end_col_offset, repr(new))]),
643
+ {n.value: new})
644
+ return None
645
+
646
+
647
+ def _fix_syntax(exc: BaseException, code: str) -> Fix | None:
648
+ msg = getattr(exc, "msg", str(exc))
649
+ lineno = getattr(exc, "lineno", None) or 1
650
+ lines = code.splitlines()
651
+
652
+ def split_comment(s: str) -> tuple[str, str]:
653
+ in_str = None
654
+ for i, ch in enumerate(s):
655
+ if in_str:
656
+ if ch == in_str:
657
+ in_str = None
658
+ elif ch in "\"'":
659
+ in_str = ch
660
+ elif ch == "#":
661
+ return s[:i].rstrip(), " " + s[i:]
662
+ return s.rstrip(), ""
663
+
664
+ if "expected ':'" in msg and 0 < lineno <= len(lines):
665
+ body, comment = split_comment(lines[lineno - 1])
666
+ lines[lineno - 1] = body + ":" + comment
667
+ return Fix("syntax", f"added the missing ':' at the end of line {lineno}", "\n".join(lines))
668
+ m = re.search(r"Missing parentheses in call to '(\w+)'", msg)
669
+ if m and 0 < lineno <= len(lines):
670
+ body, comment = split_comment(lines[lineno - 1])
671
+ new = re.sub(rf"\b{m.group(1)}\s+(.+)$", rf"{m.group(1)}(\1)", body)
672
+ lines[lineno - 1] = new + comment
673
+ return Fix("syntax", f"added parentheses to the `{m.group(1)}` call", "\n".join(lines))
674
+ if "was never closed" in msg and 0 < lineno <= len(lines):
675
+ body, comment = split_comment(lines[lineno - 1])
676
+ stack, in_str = [], None # (closer, index of opener)
677
+ pairs = {"(": ")", "[": "]", "{": "}"}
678
+ for i, ch in enumerate(body):
679
+ if in_str:
680
+ if ch == in_str:
681
+ in_str = None
682
+ elif ch in "\"'":
683
+ in_str = ch
684
+ elif ch in pairs:
685
+ stack.append((pairs[ch], i))
686
+ elif ch in pairs.values() and stack and stack[-1][0] == ch:
687
+ stack.pop()
688
+ if not stack:
689
+ return None
690
+
691
+ def build(text: str) -> str:
692
+ out = list(lines)
693
+ out[lineno - 1] = text + comment
694
+ return "\n".join(out)
695
+
696
+ # Default: close at the end of the line. That is what people usually forget.
697
+ at_end = body + "".join(c for c, _ in reversed(stack))
698
+ if _plausible(build(at_end)):
699
+ return Fix("syntax", f"closed the unclosed bracket at the end of line {lineno}", build(at_end))
700
+ if len(stack) != 1:
701
+ return None
702
+ # Closing at the end gives code that cannot be right (e.g. a method called on a
703
+ # list literal). Try every other place where the bracket could close, and
704
+ # apply only if exactly one of them is plausible.
705
+ closer, start = stack[0]
706
+ options, depth, in_str = [], 0, None
707
+ for i in range(start + 1, len(body)):
708
+ ch = body[i]
709
+ if in_str:
710
+ if ch == in_str:
711
+ in_str = None
712
+ elif ch in "\"'":
713
+ in_str = ch
714
+ elif ch in pairs:
715
+ depth += 1
716
+ elif ch in pairs.values():
717
+ depth -= 1
718
+ if depth == 0 and i + 1 < len(body):
719
+ candidate = body[: i + 1] + closer + body[i + 1:]
720
+ if _plausible(build(candidate)):
721
+ options.append((i + 1, candidate))
722
+ if len(options) == 1:
723
+ pos, candidate = options[0]
724
+ return Fix("syntax", f"closed the unclosed bracket after `{body[start:pos].strip()}` on line {lineno} "
725
+ "(closing it at the end of the line would not make sense)", build(candidate))
726
+ return None
727
+
728
+
729
+ def _plausible(code: str) -> bool:
730
+ """Compiles, and does not use a method a literal list/tuple/dict/str cannot have."""
731
+ try:
732
+ tree = ast.parse(code)
733
+ except SyntaxError:
734
+ return False
735
+ literal_types = {ast.List: list, ast.Tuple: tuple, ast.Dict: dict, ast.Set: set}
736
+ for n in ast.walk(tree):
737
+ if isinstance(n, ast.Attribute):
738
+ base = n.value
739
+ kind = literal_types.get(type(base)) or (type(base.value) if isinstance(base, ast.Constant) else None)
740
+ if kind is not None and not hasattr(kind, n.attr):
741
+ return False
742
+ return True
743
+
744
+
745
+ # ---------------------------------------------------------------------------
746
+ # Validation
747
+
748
+
749
+ def _assign_targets(tree: ast.AST) -> set[str]:
750
+ out = set()
751
+ for n in ast.walk(tree):
752
+ targets = n.targets if isinstance(n, ast.Assign) else [n.target] if isinstance(n, (ast.AugAssign, ast.AnnAssign)) else []
753
+ out |= {ast.dump(t) for t in targets}
754
+ return out
755
+
756
+
757
+ def _call_names(tree: ast.AST) -> list[str]:
758
+ return [call_name(n) for n in ast.walk(tree) if isinstance(n, ast.Call)]
759
+
760
+
761
+ def validate_fix(original: str, fix: Fix) -> list[str]:
762
+ """Return reasons to reject the fix (empty list = acceptable)."""
763
+ problems: list[str] = []
764
+ try:
765
+ new_tree = ast.parse(fix.fixed)
766
+ except SyntaxError as e:
767
+ return [f"the corrected code does not compile ({e.msg})"]
768
+ try:
769
+ old_tree = ast.parse(original)
770
+ except SyntaxError:
771
+ # syntax fixes: the original cannot be scanned, so check only the lines the fix changed
772
+ old_lines, new_lines = original.splitlines(), fix.fixed.splitlines()
773
+ changed = {i + 1 for i, ln in enumerate(new_lines) if i >= len(old_lines) or old_lines[i] != ln}
774
+ return [f"it would introduce a side effect: {f}" for f in safety.scan(fix.fixed) if f.line in changed]
775
+ for f in safety.introduced_by(original, fix.fixed):
776
+ problems.append(f"it would introduce a side effect: {f}")
777
+
778
+ def renamed(names):
779
+ return [fix.renames.get(n, n) for n in names]
780
+
781
+ if _assign_targets(old_tree) != _assign_targets(new_tree) and fix.kind not in ("column", "name"):
782
+ problems.append("it changes which variables are assigned")
783
+ old_calls, new_calls = renamed(_call_names(old_tree)), _call_names(new_tree)
784
+ added_data_ops = set(new_calls) & DATA_ALTERING - set(old_calls)
785
+ if added_data_ops:
786
+ problems.append(f"it would alter the data ({', '.join(sorted(added_data_ops))}) - that is your decision to make")
787
+ if sorted(c for c in old_calls if c in STAT_FUNCS) != sorted(c for c in new_calls if c in STAT_FUNCS):
788
+ problems.append("it would change the statistical method")
789
+ old_kw = {(k.arg, ast.dump(k.value)) for n in ast.walk(old_tree) if isinstance(n, ast.Call) for k in n.keywords}
790
+ new_kw = {(fix.renames.get(k.arg, k.arg), ast.dump(k.value)) for n in ast.walk(new_tree) if isinstance(n, ast.Call) for k in n.keywords}
791
+ if fix.kind == "ai" and old_kw - new_kw:
792
+ problems.append("it changes the arguments of a call")
793
+ return problems
794
+
795
+
796
+ # ---------------------------------------------------------------------------
797
+ # Execution
798
+
799
+
800
+ Confirm = Callable[[str], bool]
801
+
802
+
803
+ class Executor:
804
+ """Runs code statement by statement with bounded, validated recovery."""
805
+
806
+ def __init__(
807
+ self,
808
+ namespace: dict,
809
+ max_attempts: int = 3,
810
+ auto_fix: bool = True,
811
+ confirm: Confirm | None = None,
812
+ ai_fixer: Callable[[Diagnosis, str], Fix | None] | None = None,
813
+ filename: str = "<learnstat>",
814
+ ):
815
+ self.ns = namespace
816
+ self.max_attempts = max_attempts
817
+ self.auto_fix = auto_fix
818
+ self.confirm = confirm
819
+ self.ai_fixer = ai_fixer
820
+ self.filename = filename
821
+ self._caught: list = []
822
+ self._returned: list = []
823
+
824
+ # -- public -----------------------------------------------------------
825
+ def run(self, code: str, start_line: int | None = None, initial_exc: BaseException | None = None) -> RunResult:
826
+ """Run ``code``. With ``start_line``/``initial_exc``, resume after an
827
+ error raised elsewhere (e.g. by IPython) at that line: earlier statements
828
+ are treated as already executed."""
829
+ result = RunResult(ok=False, original_code=code, final_code=code)
830
+
831
+ tree, code = self._parse_with_recovery(code, result)
832
+ if tree is None:
833
+ return result
834
+ result.final_code = code
835
+
836
+ if initial_exc is None:
837
+ findings = safety.scan(code)
838
+ if findings and not self._ask("This code has side effects:\n" + "\n".join(f" - {f}" for f in findings) + "\nRun it?"):
839
+ result.blocked = findings
840
+ return result
841
+
842
+ replacements: dict[int, str] = {}
843
+ lines = code.splitlines()
844
+ for idx, stmt in enumerate(tree.body):
845
+ if start_line is not None and stmt.end_lineno < start_line:
846
+ continue # already executed by the caller
847
+ src = "\n".join(lines[stmt.lineno - 1: stmt.end_lineno])
848
+ exc = initial_exc if (initial_exc is not None and stmt.lineno <= start_line <= stmt.end_lineno) else None
849
+ ok, value, new_src = self._run_statement(stmt, src, result, pending_exc=exc)
850
+ if new_src != src:
851
+ replacements[idx] = new_src
852
+ if not ok:
853
+ result.stopped_at_line = stmt.lineno
854
+ result.final_code = self._rebuild(code, tree, replacements)
855
+ return result
856
+ result.value = value
857
+ result.ok = True
858
+ result.final_code = self._rebuild(code, tree, replacements)
859
+ return result
860
+
861
+ # -- internals --------------------------------------------------------
862
+ def _ask(self, question: str) -> bool:
863
+ return bool(self.confirm and self.confirm(question))
864
+
865
+ def _parse_with_recovery(self, code: str, result: RunResult) -> tuple[ast.Module | None, str]:
866
+ for _ in range(self.max_attempts + 1):
867
+ try:
868
+ return ast.parse(code, filename=self.filename), code
869
+ except SyntaxError as e:
870
+ d = diagnose(e, code, e.lineno, self.ns)
871
+ attempt = Attempt(d)
872
+ result.attempts.append(attempt)
873
+ result.error = e
874
+ if not self.auto_fix:
875
+ return None, code
876
+ fix = _fix_syntax(e, code) or self._ai_fix(d, code)
877
+ if not fix:
878
+ return None, code
879
+ attempt.fix, attempt.diff = fix, _diff(code, fix.fixed)
880
+ attempt.rejected_reasons = validate_fix(code, fix)
881
+ if attempt.rejected_reasons or not self._approved(fix, attempt.diff):
882
+ return None, code
883
+ try:
884
+ ast.parse(fix.fixed)
885
+ except SyntaxError:
886
+ attempt.applied = True
887
+ code = fix.fixed # progress was made; try the next syntax error
888
+ continue
889
+ attempt.applied = attempt.succeeded = True
890
+ code = fix.fixed
891
+ return None, code
892
+
893
+ def _approved(self, fix: Fix, diff: str) -> bool:
894
+ if fix.needs_confirmation:
895
+ return self._ask(f"Apply this correction?\n {fix.description}\n{diff}")
896
+ return True
897
+
898
+ def _ai_fix(self, d: Diagnosis, src: str) -> Fix | None:
899
+ if not self.ai_fixer:
900
+ return None
901
+ try:
902
+ fix = self.ai_fixer(d, src)
903
+ except Exception:
904
+ return None
905
+ if fix:
906
+ fix.needs_confirmation = True # AI-proposed edits always need explicit approval
907
+ return fix
908
+
909
+ def _exec(self, src_tree: ast.Module) -> Any:
910
+ """Run one statement. Warnings are recorded for explanation and then passed on unchanged.
911
+
912
+ For a statement that calls SciPy, the values returned by calls made
913
+ directly from the statement are recorded too, so that a NaN test
914
+ result inside print(...) can be explained. The code itself runs as written.
915
+ """
916
+ body = src_tree.body
917
+ self._returned = []
918
+ previous = sys.getprofile()
919
+ if _stat_calls(src_tree):
920
+ def tap(frame, event, arg):
921
+ if event == "return" and frame.f_back is not None and frame.f_back.f_code.co_filename == self.filename and (hasattr(arg, "pvalue") or hasattr(arg, "statistic")):
922
+ self._returned.append(arg)
923
+ sys.setprofile(tap)
924
+ try:
925
+ with warnings.catch_warnings(record=True) as caught:
926
+ warnings.simplefilter("always")
927
+ if len(body) == 1 and isinstance(body[0], ast.Expr):
928
+ expr = ast.Expression(body[0].value)
929
+ ast.copy_location(expr, body[0])
930
+ return eval(compile(expr, self.filename, "eval"), self.ns) # noqa: S307 - the user's own code
931
+ exec(compile(src_tree, self.filename, "exec"), self.ns) # noqa: S102 - the user's own code
932
+ return None
933
+ finally:
934
+ sys.setprofile(previous)
935
+ self._caught = list(caught)
936
+ for w in self._caught: # pass them on under the caller's own warning filters
937
+ warnings.warn_explicit(w.message, w.category, w.filename, w.lineno)
938
+
939
+ def _module_for(self, src: str, first_line: int) -> ast.Module:
940
+ tree = ast.parse(src, filename=self.filename)
941
+ ast.increment_lineno(tree, first_line - 1)
942
+ return tree
943
+
944
+ def _run_statement(self, stmt: ast.stmt, src: str, result: RunResult, pending_exc: BaseException | None = None):
945
+ current = src
946
+ seen_errors: set[tuple[str, str]] = set()
947
+ for attempt_no in range(self.max_attempts + 1):
948
+ if pending_exc is None:
949
+ try:
950
+ value = self._exec(self._module_for(current, stmt.lineno))
951
+ self._note_warnings(result, stmt, value)
952
+ return True, value, current
953
+ except Exception as e: # noqa: BLE001 - we diagnose anything the user's code raises
954
+ exc = e
955
+ else:
956
+ exc, pending_exc = pending_exc, None
957
+
958
+ result.error = exc
959
+ line = self._error_line(exc, stmt)
960
+ d = diagnose(exc, current, line, self.ns)
961
+ attempt = Attempt(d)
962
+ result.attempts.append(attempt)
963
+
964
+ signature = (type(exc).__name__, str(exc))
965
+ if signature in seen_errors:
966
+ d.next_step = "The same error came back after a correction, so recovery stopped. " + d.next_step
967
+ return False, None, current
968
+ seen_errors.add(signature)
969
+
970
+ if not self.auto_fix or attempt_no >= self.max_attempts:
971
+ if attempt_no >= self.max_attempts:
972
+ d.next_step = f"Stopped after {self.max_attempts} correction attempts. " + d.next_step
973
+ return False, None, current
974
+ if d.category in ("data_quality", "statistical", "environment"):
975
+ return False, None, current # explain, never auto-change data or methods
976
+
977
+ fix = propose_fix(exc, d, current, self.ns) or self._ai_fix(d, current)
978
+ if not fix:
979
+ return False, None, current
980
+ attempt.fix, attempt.diff = fix, _diff(current, fix.fixed)
981
+ attempt.rejected_reasons = validate_fix(current, fix)
982
+ if attempt.rejected_reasons:
983
+ return False, None, current
984
+ if not _is_simple(stmt) and not fix.needs_confirmation:
985
+ fix.needs_confirmation = True # re-running a loop/block could repeat side effects
986
+ if fix.needs_confirmation and not self._ask(
987
+ f"Proposed correction for line {stmt.lineno}: {fix.description}\n{attempt.diff}\nApply and re-run this statement?"
988
+ ):
989
+ return False, None, current
990
+ attempt.applied = True
991
+ current = fix.fixed
992
+ try:
993
+ value = self._exec(self._module_for(current, stmt.lineno))
994
+ attempt.succeeded = True
995
+ self._note_warnings(result, stmt, value)
996
+ return True, value, current
997
+ except Exception as e: # noqa: BLE001
998
+ pending_exc = e
999
+ return False, None, current
1000
+
1001
+ def _note_warnings(self, result: RunResult, stmt: ast.stmt | None = None, value: Any = None) -> None:
1002
+ notes = explain_warnings(self._caught)
1003
+ if stmt is not None:
1004
+ notes += _nan_result_notes(stmt, value, self.ns, self._returned)
1005
+ result.notes += [n for n in notes if n not in result.notes]
1006
+
1007
+ def _error_line(self, exc: BaseException, stmt: ast.stmt) -> int | None:
1008
+ for frame in reversed(traceback.extract_tb(exc.__traceback__)):
1009
+ if frame.filename == self.filename:
1010
+ return frame.lineno
1011
+ return stmt.lineno
1012
+
1013
+ @staticmethod
1014
+ def _rebuild(code: str, tree: ast.Module, replacements: dict[int, str]) -> str:
1015
+ if not replacements:
1016
+ return code
1017
+ lines = code.splitlines()
1018
+ for idx in sorted(replacements, reverse=True):
1019
+ stmt = tree.body[idx]
1020
+ lines[stmt.lineno - 1: stmt.end_lineno] = replacements[idx].splitlines()
1021
+ return "\n".join(lines) + ("\n" if code.endswith("\n") else "")