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/__init__.py +80 -0
- learnstat/advisor.py +502 -0
- learnstat/cli.py +338 -0
- learnstat/commands.py +317 -0
- learnstat/config.py +223 -0
- learnstat/ipython_ext.py +104 -0
- learnstat/knowledge.py +233 -0
- learnstat/llm.py +280 -0
- learnstat/memory.py +193 -0
- learnstat/profile.py +344 -0
- learnstat/providers.py +78 -0
- learnstat/recovery.py +1021 -0
- learnstat/safety.py +100 -0
- learnstat/session.py +307 -0
- learnstat/setup.py +308 -0
- learnstat/suggestions.py +64 -0
- learnstat-0.2.0.dist-info/METADATA +199 -0
- learnstat-0.2.0.dist-info/RECORD +22 -0
- learnstat-0.2.0.dist-info/WHEEL +5 -0
- learnstat-0.2.0.dist-info/entry_points.txt +2 -0
- learnstat-0.2.0.dist-info/licenses/LICENSE +21 -0
- learnstat-0.2.0.dist-info/top_level.txt +1 -0
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 "")
|