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 ADDED
@@ -0,0 +1,80 @@
1
+ """learnstat - an AI-assisted learning companion for statistics in Python.
2
+
3
+ You write ordinary pandas / NumPy / SciPy / Matplotlib code; learnstat profiles
4
+ your data, suggests better approaches, explains its reasoning and helps you
5
+ recover from mistakes.
6
+
7
+ import learnstat as ls
8
+ df = ls.read_csv("survey.csv") # a normal pandas DataFrame, now profiled
9
+ ls.check("df['income'].mean()") # suggestions, without running anything
10
+ print(ls.command("/describe")) # why?
11
+
12
+ In Jupyter: ``%load_ext learnstat``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .advisor import CodeAdvisor
18
+ from .memory import MemoryStore
19
+ from .profile import DatasetProfile, profile_dataframe
20
+ from .recovery import Executor, RunResult
21
+ from .session import Session, get_session, set_session
22
+ from .suggestions import Suggestion
23
+
24
+ __version__ = "0.2.0"
25
+
26
+
27
+ def read_csv(path, name: str | None = None, **kwargs):
28
+ """``pandas.read_csv`` plus dataset profiling and persistent memory."""
29
+ return get_session().read_csv(path, name=name, **kwargs)
30
+
31
+
32
+ def read_excel(path, name: str | None = None, **kwargs):
33
+ return get_session().read_excel(path, name=name, **kwargs)
34
+
35
+
36
+ def read_parquet(path, name: str | None = None, **kwargs):
37
+ return get_session().read_parquet(path, name=name, **kwargs)
38
+
39
+
40
+ def read_json(path, name: str | None = None, **kwargs):
41
+ return get_session().read_json(path, name=name, **kwargs)
42
+
43
+
44
+ def track(df, name: str | None = None, **kwargs):
45
+ """Profile a DataFrame you already have."""
46
+ return get_session().track(df, name=name, **kwargs)
47
+
48
+
49
+ def check(code: str, show: bool = True):
50
+ """Suggestions for ``code`` (it is not executed)."""
51
+ return get_session().check(code, show=show)
52
+
53
+
54
+ def run(code: str):
55
+ """Run ``code`` in the default session with suggestions and guarded recovery."""
56
+ return get_session().run(code)
57
+
58
+
59
+ def command(text: str) -> str:
60
+ """Run a slash command such as ``/describe`` and return its text."""
61
+ return get_session().command(text)
62
+
63
+
64
+ def load_ipython_extension(ip):
65
+ from .ipython_ext import load_ipython_extension as _load
66
+
67
+ _load(ip)
68
+
69
+
70
+ def unload_ipython_extension(ip):
71
+ from .ipython_ext import unload_ipython_extension as _unload
72
+
73
+ _unload(ip)
74
+
75
+
76
+ __all__ = [
77
+ "Session", "get_session", "set_session", "read_csv", "read_excel", "read_parquet", "read_json",
78
+ "track", "check", "run", "command", "profile_dataframe", "DatasetProfile", "MemoryStore",
79
+ "CodeAdvisor", "Executor", "RunResult", "Suggestion",
80
+ ]
learnstat/advisor.py ADDED
@@ -0,0 +1,502 @@
1
+ """Context-aware code advice.
2
+
3
+ The advisor reads the user's code with ``ast`` (it never executes it), works
4
+ out which dataset columns each call touches, and checks the call against the
5
+ dataset profile. Every rule returns a :class:`Suggestion` that explains the
6
+ problem, the alternative, the reason and how to implement it. The user's code
7
+ is never modified.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import difflib
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Iterator
16
+
17
+ from .profile import ColumnProfile, DatasetProfile
18
+ from .suggestions import Suggestion
19
+
20
+ PIE_MAX_CATEGORIES = 6
21
+ SKEW_THRESHOLD = 1.0
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # AST helpers
26
+
27
+
28
+ def dotted_name(node: ast.AST) -> str | None:
29
+ """``plt.pie`` -> 'plt.pie', ``stats.ttest_ind`` -> 'stats.ttest_ind'."""
30
+ parts = []
31
+ while isinstance(node, ast.Attribute):
32
+ parts.append(node.attr)
33
+ node = node.value
34
+ if isinstance(node, ast.Name):
35
+ parts.append(node.id)
36
+ return ".".join(reversed(parts))
37
+ return None
38
+
39
+
40
+ def call_name(call: ast.Call) -> str:
41
+ """Last attribute of a call: ``df['a'].value_counts().plot.pie()`` -> 'pie'."""
42
+ f = call.func
43
+ if isinstance(f, ast.Attribute):
44
+ return f.attr
45
+ if isinstance(f, ast.Name):
46
+ return f.id
47
+ return ""
48
+
49
+
50
+ def kwarg(call: ast.Call, name: str) -> ast.AST | None:
51
+ return next((k.value for k in call.keywords if k.arg == name), None)
52
+
53
+
54
+ def const_value(node: ast.AST | None):
55
+ return node.value if isinstance(node, ast.Constant) else None
56
+
57
+
58
+ def frame_base(node: ast.AST) -> ast.Name | None:
59
+ """The DataFrame variable behind ``df`` or a row filter like ``df[df['g'] == 'a']``."""
60
+ while isinstance(node, ast.Subscript) and not isinstance(const_value(node.slice), str):
61
+ node = node.value
62
+ return node if isinstance(node, ast.Name) else None
63
+
64
+
65
+ @dataclass
66
+ class ColumnRef:
67
+ frame: str | None # variable name of the DataFrame, if known
68
+ column: str
69
+ profile: DatasetProfile
70
+ info: ColumnProfile
71
+
72
+
73
+ class Resolver:
74
+ """Maps ``df['col']`` / ``df.col`` expressions to profiled columns."""
75
+
76
+ def __init__(self, frames: dict[str, DatasetProfile], profiles: list[DatasetProfile]):
77
+ self.frames = frames # variable name -> profile
78
+ self.profiles = profiles # every known profile (fallback lookup by column name)
79
+ self.aliases: dict[str, ColumnRef] = {} # treated = df[df.g == 'a']['score']
80
+
81
+ def learn_aliases(self, tree: ast.AST) -> None:
82
+ for node in ast.walk(tree):
83
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
84
+ ref = self.ref(node.value)
85
+ if ref:
86
+ self.aliases[node.targets[0].id] = ref
87
+
88
+ def _lookup(self, frame: str | None, column: str) -> ColumnRef | None:
89
+ if frame and frame in self.frames:
90
+ prof = self.frames[frame]
91
+ info = prof.get(column)
92
+ return ColumnRef(frame, column, prof, info) if info else None
93
+ for prof in self.profiles:
94
+ info = prof.get(column)
95
+ if info:
96
+ return ColumnRef(frame, column, prof, info)
97
+ return None
98
+
99
+ def ref(self, node: ast.AST) -> ColumnRef | None:
100
+ if isinstance(node, ast.Name) and node.id in self.aliases:
101
+ return self.aliases[node.id]
102
+ if isinstance(node, ast.Subscript):
103
+ base = frame_base(node.value)
104
+ col = const_value(node.slice)
105
+ if base is not None and isinstance(col, str):
106
+ return self._lookup(base.id, col)
107
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
108
+ name = node.value.id
109
+ if name in self.frames or (not self.frames and self.profiles):
110
+ return self._lookup(name, node.attr)
111
+ return None
112
+
113
+ def refs_in(self, node: ast.AST) -> list[ColumnRef]:
114
+ out, seen = [], set()
115
+ for sub in ast.walk(node):
116
+ r = self.ref(sub)
117
+ if r and (r.frame, r.column) not in seen:
118
+ seen.add((r.frame, r.column))
119
+ out.append(r)
120
+ return out
121
+
122
+ def first_ref(self, node: ast.AST) -> ColumnRef | None:
123
+ refs = self.refs_in(node)
124
+ return refs[0] if refs else None
125
+
126
+ def is_known_frame(self, name: str) -> bool:
127
+ return name in self.frames
128
+
129
+
130
+ def _src(code: str, node: ast.AST) -> str:
131
+ return (ast.get_source_segment(code, node) or "").strip()
132
+
133
+
134
+ def _calls(tree: ast.AST) -> Iterator[ast.Call]:
135
+ for node in ast.walk(tree):
136
+ if isinstance(node, ast.Call):
137
+ yield node
138
+
139
+
140
+ def _expr_text(ref: ColumnRef) -> str:
141
+ return f"{ref.frame or 'df'}['{ref.column}']"
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Rules. Each takes (call, resolver, code) and returns a Suggestion or None.
146
+
147
+ Rule = Callable[[ast.Call, Resolver, str], "Suggestion | None"]
148
+
149
+
150
+ def rule_pie_many_categories(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
151
+ name = call_name(call)
152
+ is_pie = name == "pie" or (name == "plot" and const_value(kwarg(call, "kind")) == "pie")
153
+ if not is_pie:
154
+ return None
155
+ ref = r.first_ref(call)
156
+ if not ref or ref.info.kind not in ("categorical", "boolean", "text", "numeric"):
157
+ return None
158
+ k = ref.info.n_unique
159
+ if k <= PIE_MAX_CATEGORIES:
160
+ return None
161
+ e = _expr_text(ref)
162
+ return Suggestion(
163
+ rule="pie_many_categories",
164
+ concept="part_to_whole_charts",
165
+ title=f"Pie chart with {k} categories",
166
+ problem=f"`{ref.column}` has {k} categories; slices this numerous are hard to compare by angle.",
167
+ alternative="A horizontal bar chart of the counts, sorted by size.",
168
+ rationale=(
169
+ "Bars share a common baseline, so readers compare lengths, which is far more accurate than "
170
+ f"comparing angles. Pies only work for about {PIE_MAX_CATEGORIES} or fewer parts of one whole."
171
+ ),
172
+ code=(
173
+ f"counts = {e}.value_counts()\n"
174
+ "ax = counts.sort_values().plot.barh()\n"
175
+ f"ax.set_xlabel('Count'); ax.set_title('{ref.column}')"
176
+ ),
177
+ severity="warning",
178
+ columns=[ref.column],
179
+ )
180
+
181
+
182
+ def rule_mean_of_skewed(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
183
+ if call_name(call) != "mean" or not isinstance(call.func, ast.Attribute):
184
+ return None
185
+ ref = r.ref(call.func.value)
186
+ if not ref or ref.info.kind != "numeric":
187
+ return None
188
+ skew = ref.info.skew
189
+ if skew is None or abs(skew) < SKEW_THRESHOLD:
190
+ return None
191
+ s = ref.info.summary
192
+ e = _expr_text(ref)
193
+ return Suggestion(
194
+ rule="mean_of_skewed",
195
+ concept="center_of_skewed_data",
196
+ title=f"Mean of a skewed variable (`{ref.column}`)",
197
+ problem=(
198
+ f"`{ref.column}` is skewed (skew={skew}); its mean ({s.get('mean')}) differs from its "
199
+ f"median ({s.get('median')}) and may not describe a typical value."
200
+ ),
201
+ alternative="Report the median and IQR alongside (or instead of) the mean.",
202
+ rationale="The median is not pulled by the long tail, so it represents a typical observation better.",
203
+ code=(
204
+ f"{e}.median()\n"
205
+ f"{e}.quantile([0.25, 0.75]) # IQR\n"
206
+ f"{e}.describe() # mean and quartiles side by side"
207
+ ),
208
+ columns=[ref.column],
209
+ )
210
+
211
+
212
+ def rule_ttest_equal_var(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
213
+ if call_name(call) != "ttest_ind" or kwarg(call, "equal_var") is not None:
214
+ return None
215
+ args = ", ".join(_src(code, a) for a in call.args) or "a, b"
216
+ return Suggestion(
217
+ rule="ttest_equal_var",
218
+ concept="welch_t_test",
219
+ title="t-test assumes equal variances",
220
+ problem="`ttest_ind` defaults to Student's t-test, which assumes both groups have equal variance.",
221
+ alternative="Welch's t-test: the same function with `equal_var=False`.",
222
+ rationale=(
223
+ "Welch's test stays valid when variances or group sizes differ and costs almost nothing when "
224
+ "they are equal, so it is the safer default."
225
+ ),
226
+ code=f"stats.ttest_ind({args}, equal_var=False)",
227
+ severity="warning",
228
+ columns=[x.column for x in r.refs_in(call)],
229
+ )
230
+
231
+
232
+ _SCIPY_TESTS = {
233
+ "ttest_ind", "ttest_rel", "ttest_1samp", "mannwhitneyu", "wilcoxon", "pearsonr", "spearmanr",
234
+ "kendalltau", "f_oneway", "kruskal", "shapiro", "normaltest", "linregress",
235
+ }
236
+
237
+
238
+ def rule_nan_in_scipy(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
239
+ name = call_name(call)
240
+ if name not in _SCIPY_TESTS or kwarg(call, "nan_policy") is not None:
241
+ return None
242
+ text = _src(code, call)
243
+ if "dropna" in text:
244
+ return None
245
+ missing = [x for x in r.refs_in(call) if x.info.n_missing > 0]
246
+ if not missing:
247
+ return None
248
+ cols = ", ".join(f"`{x.column}` ({x.info.n_missing} missing)" for x in missing)
249
+ return Suggestion(
250
+ rule="nan_in_scipy",
251
+ concept="missing_values_in_tests",
252
+ title=f"Missing values passed to `{name}`",
253
+ problem=f"{cols}. SciPy may return `nan` or fail instead of skipping them like pandas does.",
254
+ alternative="Handle missing values explicitly - drop them for this test, or use `nan_policy='omit'`.",
255
+ rationale=(
256
+ "Which rows enter a test is an analytical decision. Making it explicit keeps the result "
257
+ "reproducible and lets you report the actual sample size."
258
+ ),
259
+ code=(
260
+ f"# option 1: drop missing values for this test only\n"
261
+ f"{_expr_text(missing[0])}.dropna()\n"
262
+ f"# option 2: let SciPy skip them (supported by most scipy.stats tests)\n"
263
+ f"{name}(..., nan_policy='omit')"
264
+ ),
265
+ severity="warning",
266
+ columns=[x.column for x in missing],
267
+ )
268
+
269
+
270
+ def rule_pearson_on_skewed(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
271
+ name = call_name(call)
272
+ if name == "corr":
273
+ method = const_value(kwarg(call, "method")) or "pearson"
274
+ if method != "pearson":
275
+ return None
276
+ refs = r.refs_in(call)
277
+ if not refs and isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
278
+ prof = r.frames.get(call.func.value.id)
279
+ refs = [ColumnRef(call.func.value.id, c, prof, prof.columns[c]) for c in prof.numeric] if prof else []
280
+ elif name == "pearsonr":
281
+ refs = r.refs_in(call)
282
+ else:
283
+ return None
284
+ skewed = [x for x in refs if x.info.kind == "numeric" and x.info.skew is not None and abs(x.info.skew) >= SKEW_THRESHOLD]
285
+ if not skewed:
286
+ return None
287
+ cols = ", ".join(f"`{x.column}` (skew={x.info.skew})" for x in skewed[:4])
288
+ fix = (
289
+ "stats.spearmanr(x, y)" if name == "pearsonr" else _src(code, call).replace("corr()", "corr(method='spearman')")
290
+ )
291
+ if fix == _src(code, call):
292
+ fix = "df.corr(method='spearman', numeric_only=True)"
293
+ return Suggestion(
294
+ rule="pearson_on_skewed",
295
+ concept="correlation_method",
296
+ title="Pearson correlation on skewed data",
297
+ problem=f"Pearson's r is sensitive to skew and outliers, and these variables are skewed: {cols}.",
298
+ alternative="Spearman's rank correlation (or check a scatter plot before trusting Pearson's r).",
299
+ rationale="Spearman works on ranks, so a few extreme values cannot dominate the coefficient.",
300
+ code=fix,
301
+ columns=[x.column for x in skewed],
302
+ )
303
+
304
+
305
+ def rule_categorical_on_numeric_chart(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
306
+ name = call_name(call)
307
+ func = dotted_name(call.func) or ""
308
+ if name == "hist":
309
+ ref = r.first_ref(call)
310
+ if ref and ref.info.kind in ("categorical", "boolean", "text"):
311
+ return Suggestion(
312
+ rule="hist_categorical",
313
+ concept="histogram_categorical",
314
+ title=f"Histogram of a categorical column (`{ref.column}`)",
315
+ problem=f"`{ref.column}` is categorical ({ref.info.n_unique} levels); histograms need a numeric scale.",
316
+ alternative="A bar chart of counts per category.",
317
+ rationale="Each category is a separate group, not a range of values that can be binned.",
318
+ code=f"{_expr_text(ref)}.value_counts().plot.bar()",
319
+ columns=[ref.column],
320
+ )
321
+ if name == "plot" and func.endswith("plt.plot") and call.args:
322
+ ref = r.ref(call.args[0])
323
+ if ref and ref.info.kind in ("categorical", "text"):
324
+ y = _src(code, call.args[1]) if len(call.args) > 1 else "df['y']"
325
+ return Suggestion(
326
+ rule="line_categorical_x",
327
+ concept="categorical_axis",
328
+ title=f"Line plot over a categorical x-axis (`{ref.column}`)",
329
+ problem="A line connects neighbouring points, implying an order and trend that categories do not have.",
330
+ alternative="A bar chart of a summary per category, or a boxplot of the distribution per category.",
331
+ rationale=f"`{ref.column}` is nominal ({ref.info.n_unique} levels), so neighbouring categories are not related.",
332
+ code=(
333
+ f"{ref.frame or 'df'}.groupby('{ref.column}')[...].mean().plot.bar()\n"
334
+ f"# or: {ref.frame or 'df'}.boxplot(column=..., by='{ref.column}')"
335
+ ),
336
+ columns=[ref.column],
337
+ )
338
+ if name == "scatter":
339
+ refs = [x for x in r.refs_in(call) if x.info.kind in ("categorical", "text")]
340
+ if refs and len(r.refs_in(call)) >= 2:
341
+ c = refs[0]
342
+ return Suggestion(
343
+ rule="scatter_categorical",
344
+ concept="categorical_axis",
345
+ title=f"Scatter plot with a categorical axis (`{c.column}`)",
346
+ problem="Scatter plots assume both axes are numeric; points for a category pile up on one line.",
347
+ alternative="A boxplot (or strip plot) of the numeric variable for each category.",
348
+ rationale="Boxplots show the centre, spread and outliers of each group side by side.",
349
+ code=f"{c.frame or 'df'}.boxplot(column=<numeric column>, by='{c.column}')",
350
+ columns=[c.column],
351
+ )
352
+ return None
353
+
354
+
355
+ def rule_global_dropna(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
356
+ if call_name(call) != "dropna" or not isinstance(call.func, ast.Attribute):
357
+ return None
358
+ if call.args or kwarg(call, "subset") is not None or kwarg(call, "axis") is not None:
359
+ return None
360
+ target = call.func.value
361
+ if not isinstance(target, ast.Name) or not r.is_known_frame(target.id):
362
+ return None
363
+ prof = r.frames[target.id]
364
+ lost = prof.n_rows - prof.n_complete_rows
365
+ if prof.n_rows == 0 or lost / prof.n_rows < 0.05:
366
+ return None
367
+ worst = sorted(prof.columns.values(), key=lambda c: -c.n_missing)[:3]
368
+ worst_s = ", ".join(f"`{c.name}` ({c.n_missing})" for c in worst if c.n_missing)
369
+ return Suggestion(
370
+ rule="global_dropna",
371
+ concept="listwise_deletion",
372
+ title=f"`{target.id}.dropna()` removes {lost} of {prof.n_rows} rows",
373
+ problem=(
374
+ f"Dropping every row with any missing value loses {100 * lost / prof.n_rows:.0f}% of the data; "
375
+ f"most missing values come from {worst_s}."
376
+ ),
377
+ alternative="Drop only on the columns your analysis uses, and keep the original DataFrame.",
378
+ rationale="Listwise deletion can shrink and bias the sample; restricting it keeps rows you still need.",
379
+ code=f"clean = {target.id}.dropna(subset=[...]) # the columns used in this analysis\nprint(len({target.id}), '->', len(clean))",
380
+ severity="warning",
381
+ )
382
+
383
+
384
+ def rule_text_numbers(call: ast.Call, r: Resolver, code: str) -> Suggestion | None:
385
+ if call_name(call) not in {"mean", "sum", "std", "median", "hist", "corr", "ttest_ind", "pearsonr"}:
386
+ return None
387
+ for ref in r.refs_in(call):
388
+ if ref.info.kind in ("categorical", "text") and any("looks numeric" in n for n in ref.info.notes):
389
+ e = _expr_text(ref)
390
+ return Suggestion(
391
+ rule="text_numbers",
392
+ concept="text_numbers",
393
+ title=f"`{ref.column}` is numbers stored as text",
394
+ problem=f"`{ref.column}` has dtype {ref.info.dtype}; numeric operations will fail or misbehave.",
395
+ alternative="Convert it explicitly with `pd.to_numeric`, after checking which values do not parse.",
396
+ rationale="Seeing the bad values first stops you silently turning real data into NaN.",
397
+ code=(
398
+ f"converted = pd.to_numeric({e}, errors='coerce')\n"
399
+ f"print({e}[converted.isna() & {e}.notna()].unique()) # values that failed\n"
400
+ f"{ref.frame or 'df'}['{ref.column}_num'] = converted"
401
+ ),
402
+ severity="warning",
403
+ columns=[ref.column],
404
+ )
405
+ return None
406
+
407
+
408
+ RULES: list[Rule] = [
409
+ rule_pie_many_categories,
410
+ rule_mean_of_skewed,
411
+ rule_ttest_equal_var,
412
+ rule_nan_in_scipy,
413
+ rule_pearson_on_skewed,
414
+ rule_categorical_on_numeric_chart,
415
+ rule_global_dropna,
416
+ rule_text_numbers,
417
+ ]
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Column-name checks (catch KeyErrors before running)
422
+
423
+
424
+ def unknown_columns(tree: ast.AST, r: Resolver, code: str) -> list[Suggestion]:
425
+ out = []
426
+ for node in ast.walk(tree):
427
+ if not isinstance(node, ast.Subscript):
428
+ continue
429
+ # df['x'] or a row-filtered frame such as df[df['g'] == 'a']['x']
430
+ base = frame_base(node.value)
431
+ if base is None:
432
+ continue
433
+ prof = r.frames.get(base.id)
434
+ col = const_value(node.slice)
435
+ if not prof or not isinstance(col, str) or col in prof.columns:
436
+ continue
437
+ # assignment creates a new column: df['new'] = ...
438
+ if isinstance(node.ctx, ast.Store):
439
+ continue
440
+ match = difflib.get_close_matches(col, list(prof.columns), n=1, cutoff=0.6)
441
+ lowered = {c.lower().strip(): c for c in prof.columns}
442
+ match = [lowered[col.lower().strip()]] if col.lower().strip() in lowered else match
443
+ hint = f"Did you mean `{match[0]}`?" if match else f"Available columns: {', '.join(list(prof.columns)[:12])}"
444
+ out.append(
445
+ Suggestion(
446
+ rule="unknown_column",
447
+ concept="column_names",
448
+ title=f"`{base.id}` has no column `{col}`",
449
+ problem=f"This will raise a KeyError. {hint}",
450
+ alternative=f"Use the exact column name{f' `{match[0]}`' if match else ''}.",
451
+ rationale="Column labels are exact strings - case, spaces and underscores matter.",
452
+ code=f"{base.id}['{match[0]}']" if match else f"print(list({base.id}.columns))",
453
+ line=getattr(node, "lineno", None),
454
+ severity="warning",
455
+ columns=[col],
456
+ )
457
+ )
458
+ return out
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+
463
+
464
+ class CodeAdvisor:
465
+ def __init__(self, rules: list[Rule] | None = None):
466
+ self.rules = rules if rules is not None else list(RULES)
467
+
468
+ def check(
469
+ self,
470
+ code: str,
471
+ frames: dict[str, DatasetProfile] | None = None,
472
+ profiles: list[DatasetProfile] | None = None,
473
+ ) -> list[Suggestion]:
474
+ """Return suggestions for ``code``. Returns [] for code that does not parse."""
475
+ try:
476
+ tree = ast.parse(code)
477
+ except SyntaxError:
478
+ return []
479
+ frames = frames or {}
480
+ profiles = profiles if profiles is not None else list(frames.values())
481
+ resolver = Resolver(frames, profiles)
482
+ resolver.learn_aliases(tree)
483
+ out: list[Suggestion] = []
484
+ lines = code.splitlines()
485
+ for call in _calls(tree):
486
+ for rule in self.rules:
487
+ try:
488
+ s = rule(call, resolver, code)
489
+ except Exception: # a broken rule must never break the user's workflow
490
+ s = None
491
+ if s:
492
+ s.line = s.line or call.lineno
493
+ s.source_code = lines[call.lineno - 1].strip() if call.lineno <= len(lines) else ""
494
+ out.append(s)
495
+ for s in unknown_columns(tree, resolver, code):
496
+ s.source_code = lines[s.line - 1].strip() if s.line and s.line <= len(lines) else ""
497
+ out.append(s)
498
+ # one suggestion per (rule, line): nested calls can trigger a rule twice
499
+ unique: dict[tuple[str, int | None], Suggestion] = {}
500
+ for s in out:
501
+ unique.setdefault((s.rule, s.line), s)
502
+ return sorted(unique.values(), key=lambda s: (s.line or 0, s.severity != "warning"))