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/safety.py ADDED
@@ -0,0 +1,100 @@
1
+ """Static safety checks for code that learnstat executes or generates.
2
+
3
+ This is a policy layer, not a sandbox: user code runs in the user's own Python
4
+ process with the user's permissions, exactly as if they had run it themselves.
5
+ What the checks guarantee is that
6
+
7
+ * code with potentially destructive side effects (deleting/overwriting files,
8
+ shell commands, network access, dynamic ``eval``/``exec``) is flagged and
9
+ only runs after the user confirms, and
10
+ * an automatic correction can never *introduce* such an operation - a fix
11
+ that adds one is rejected outright.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ from dataclasses import dataclass
18
+
19
+ from .advisor import call_name, const_value, dotted_name, kwarg
20
+
21
+ _DESTRUCTIVE_CALLS = {
22
+ "os.remove": "deletes a file",
23
+ "os.unlink": "deletes a file",
24
+ "os.rmdir": "deletes a directory",
25
+ "os.removedirs": "deletes directories",
26
+ "os.rename": "renames/overwrites a file",
27
+ "os.replace": "overwrites a file",
28
+ "os.system": "runs a shell command",
29
+ "os.popen": "runs a shell command",
30
+ "os.kill": "terminates a process",
31
+ "shutil.rmtree": "deletes a directory tree",
32
+ "shutil.move": "moves/overwrites files",
33
+ "shutil.copy": "may overwrite a file",
34
+ "shutil.copyfile": "may overwrite a file",
35
+ "subprocess.run": "runs an external command",
36
+ "subprocess.call": "runs an external command",
37
+ "subprocess.Popen": "runs an external command",
38
+ "subprocess.check_call": "runs an external command",
39
+ "subprocess.check_output": "runs an external command",
40
+ }
41
+ _DESTRUCTIVE_METHODS = {
42
+ "unlink": "deletes a file",
43
+ "rmdir": "deletes a directory",
44
+ "write_text": "overwrites a file",
45
+ "write_bytes": "overwrites a file",
46
+ "to_csv": "writes (and may overwrite) a file",
47
+ "to_excel": "writes (and may overwrite) a file",
48
+ "to_parquet": "writes (and may overwrite) a file",
49
+ "to_json": "writes (and may overwrite) a file",
50
+ "to_pickle": "writes (and may overwrite) a file",
51
+ "to_sql": "writes to a database",
52
+ "savefig": "writes (and may overwrite) an image file",
53
+ }
54
+ _DANGEROUS_BUILTINS = {"eval", "exec", "compile", "__import__"}
55
+ _NETWORK_MODULES = {"socket", "requests", "urllib", "http", "httpx", "ftplib", "smtplib"}
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SafetyFinding:
60
+ line: int
61
+ operation: str
62
+ reason: str
63
+
64
+ def __str__(self) -> str:
65
+ return f"line {self.line}: `{self.operation}` {self.reason}"
66
+
67
+
68
+ def scan(code: str) -> list[SafetyFinding]:
69
+ try:
70
+ tree = ast.parse(code)
71
+ except SyntaxError:
72
+ return []
73
+ out: list[SafetyFinding] = []
74
+ for node in ast.walk(tree):
75
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
76
+ mods = [a.name for a in node.names] if isinstance(node, ast.Import) else [node.module or ""]
77
+ for m in mods:
78
+ if m.split(".")[0] in _NETWORK_MODULES:
79
+ out.append(SafetyFinding(node.lineno, f"import {m}", "enables network access"))
80
+ if not isinstance(node, ast.Call):
81
+ continue
82
+ dotted = dotted_name(node.func) or ""
83
+ name = call_name(node)
84
+ if dotted in _DESTRUCTIVE_CALLS:
85
+ out.append(SafetyFinding(node.lineno, dotted, _DESTRUCTIVE_CALLS[dotted]))
86
+ elif isinstance(node.func, ast.Name) and name in _DANGEROUS_BUILTINS:
87
+ out.append(SafetyFinding(node.lineno, name, "executes dynamically generated code"))
88
+ elif isinstance(node.func, ast.Name) and name == "open":
89
+ mode = const_value(node.args[1]) if len(node.args) > 1 else const_value(kwarg(node, "mode"))
90
+ if isinstance(mode, str) and any(m in mode for m in "wax+"):
91
+ out.append(SafetyFinding(node.lineno, f"open(..., {mode!r})", "writes to a file"))
92
+ elif isinstance(node.func, ast.Attribute) and name in _DESTRUCTIVE_METHODS:
93
+ out.append(SafetyFinding(node.lineno, f".{name}()", _DESTRUCTIVE_METHODS[name]))
94
+ return out
95
+
96
+
97
+ def introduced_by(original: str, fixed: str) -> list[SafetyFinding]:
98
+ """Findings present in ``fixed`` that were not already in ``original``."""
99
+ before = {(f.operation, f.reason) for f in scan(original)}
100
+ return [f for f in scan(fixed) if (f.operation, f.reason) not in before]
learnstat/session.py ADDED
@@ -0,0 +1,307 @@
1
+ """The Session ties profiling, memory, advice, recovery and commands together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ import pandas as pd
11
+
12
+ from .advisor import CodeAdvisor, call_name
13
+ from .llm import LLMAssistant
14
+ from .memory import DatasetMemory, MemoryStore
15
+ from .profile import DatasetProfile, profile_dataframe
16
+ from .recovery import Diagnosis, Executor, Fix, RunResult
17
+ from .suggestions import Suggestion, SuggestionHistory
18
+
19
+ _READERS = {"read_csv", "read_excel", "read_parquet", "read_json", "read_table", "read_feather", "read_stata", "read_spss"}
20
+
21
+
22
+ class Session:
23
+ """One analysis session.
24
+
25
+ Parameters
26
+ ----------
27
+ memory_dir: where dataset profiles (Markdown + JSON) are kept.
28
+ ai: ``"auto"`` (use the configured AI if available), ``True`` (expect it) or ``False`` (never).
29
+ max_attempts: maximum number of automatic corrections per failing statement.
30
+ auto_fix: set False to only diagnose errors, never correct them.
31
+ confirm: callback ``(question) -> bool`` used for side-effecting code and for
32
+ corrections that need approval. Without it those are refused.
33
+ namespace: the dict user code runs in (IPython passes ``user_ns``).
34
+ ai_review: also ask the AI to review every checked snippet (slower, costs tokens).
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ memory_dir: str | os.PathLike = ".learnstat",
40
+ ai: bool | str = "auto",
41
+ max_attempts: int = 3,
42
+ auto_fix: bool = True,
43
+ confirm: Callable[[str], bool] | None = None,
44
+ namespace: dict | None = None,
45
+ ai_review: bool = False,
46
+ out: Callable[[str], None] = print,
47
+ verbose: bool = True,
48
+ ):
49
+ self.memory = MemoryStore(memory_dir)
50
+ self.ns = namespace if namespace is not None else {"__name__": "__main__"}
51
+ self.max_attempts = max_attempts
52
+ self.auto_fix = auto_fix
53
+ self.confirm = confirm
54
+ self.ai_review = ai_review
55
+ self.out = out
56
+ self.verbose = verbose
57
+ self.advisor = CodeAdvisor()
58
+ self.history = SuggestionHistory()
59
+ self.datasets: dict[str, DatasetMemory] = {}
60
+ self._frame_ids: dict[int, str] = {}
61
+ self._ephemeral: dict[int, tuple[tuple, DatasetProfile]] = {}
62
+ self.last_code = ""
63
+ self.last_result: RunResult | None = None
64
+ self.on_accept: Callable[[str], None] | None = None
65
+
66
+ self.assistant: LLMAssistant | None = None
67
+ self.ai_status = "disabled"
68
+ self._ai_mode = ai
69
+ if ai:
70
+ self.connect_ai()
71
+
72
+ def connect_ai(self) -> None:
73
+ """(Re)load the AI from the saved settings - call again after ``learnstat setup`` or ``/ai``."""
74
+ self.assistant, self.ai_status = None, "disabled"
75
+ assistant = LLMAssistant()
76
+ if assistant.available():
77
+ self.assistant = assistant
78
+ self.ai_status = f"enabled ({assistant.cfg.provider_name} / {assistant.model})"
79
+ else:
80
+ self.ai_status = f"not set up - {assistant.last_error}; using offline rules"
81
+ if self._ai_mode is True:
82
+ self.out(f"[learnstat] AI requested but {self.ai_status}")
83
+
84
+ # ------------------------------------------------------------------
85
+ # Data loading - thin wrappers that keep pandas' signatures and return
86
+ # ordinary pandas DataFrames.
87
+
88
+ def track(self, df: pd.DataFrame, name: str | None = None, source: str | None = None,
89
+ force: bool = False) -> pd.DataFrame:
90
+ """Profile ``df`` (or reuse its cached profile) and remember it."""
91
+ name = name or f"dataset{len(self.datasets) + 1}"
92
+ mem = self.memory.load_or_profile(df, name=name, source=source, force=force)
93
+ self.datasets[name] = mem
94
+ self._frame_ids[id(df)] = name
95
+ if self.verbose:
96
+ self.out(self._loaded_message(mem))
97
+ return df
98
+
99
+ def _read(self, reader: str, path, name: str | None, kwargs: dict) -> pd.DataFrame:
100
+ df = getattr(pd, reader)(path, **kwargs)
101
+ stem = Path(str(path)).stem if isinstance(path, (str, os.PathLike)) else None
102
+ return self.track(df, name=name or stem, source=str(path))
103
+
104
+ def read_csv(self, path, name: str | None = None, **kwargs) -> pd.DataFrame:
105
+ return self._read("read_csv", path, name, kwargs)
106
+
107
+ def read_excel(self, path, name: str | None = None, **kwargs) -> pd.DataFrame:
108
+ return self._read("read_excel", path, name, kwargs)
109
+
110
+ def read_parquet(self, path, name: str | None = None, **kwargs) -> pd.DataFrame:
111
+ return self._read("read_parquet", path, name, kwargs)
112
+
113
+ def read_json(self, path, name: str | None = None, **kwargs) -> pd.DataFrame:
114
+ return self._read("read_json", path, name, kwargs)
115
+
116
+ def _loaded_message(self, mem: DatasetMemory) -> str:
117
+ p = mem.profile
118
+ verb = "Loaded profile from memory" if mem.reused else "Profiled"
119
+ lines = [
120
+ f"[learnstat] {verb} '{p.name}': {p.n_rows} rows x {p.n_cols} columns "
121
+ f"({len(p.numeric)} numeric, {len(p.categorical)} categorical).",
122
+ ]
123
+ for issue in p.issues[:4]:
124
+ lines.append(f" - {issue}")
125
+ if len(p.issues) > 4:
126
+ lines.append(f" - ... {len(p.issues) - 4} more issues")
127
+ lines.append(f" Memory: {self.memory.md_path(p.name)} (type /memory to view)")
128
+ return "\n".join(lines)
129
+
130
+ # ------------------------------------------------------------------
131
+ # Context
132
+
133
+ def profile(self, name: str | None = None) -> DatasetProfile | None:
134
+ mem = self.dataset_memory(name)
135
+ return mem.profile if mem else None
136
+
137
+ def dataset_memory(self, name: str | None = None) -> DatasetMemory | None:
138
+ if name:
139
+ return self.datasets.get(name)
140
+ return next(reversed(self.datasets.values()), None)
141
+
142
+ def frames(self) -> dict[str, DatasetProfile]:
143
+ """Map variable names in the namespace to profiles (tracked or ad-hoc)."""
144
+ out: dict[str, DatasetProfile] = {}
145
+ for var, obj in list(self.ns.items()):
146
+ if var.startswith("_") or not isinstance(obj, pd.DataFrame):
147
+ continue
148
+ name = self._frame_ids.get(id(obj))
149
+ if name and name in self.datasets:
150
+ out[var] = self.datasets[name].profile
151
+ continue
152
+ cached = self._ephemeral.get(id(obj))
153
+ if cached and cached[0] == obj.shape:
154
+ out[var] = cached[1]
155
+ elif obj.size <= 5_000_000: # derived frames: profile in memory only, never persisted
156
+ prof = profile_dataframe(obj, name=var)
157
+ self._ephemeral[id(obj)] = (obj.shape, prof)
158
+ out[var] = prof
159
+ return out
160
+
161
+ def profiles_for_code(self, code: str, base_dir: str | os.PathLike = ".") -> dict[str, DatasetProfile]:
162
+ """For static checks: profile files loaded with ``x = pd.read_csv('file')`` in ``code``."""
163
+ out: dict[str, DatasetProfile] = {}
164
+ try:
165
+ tree = ast.parse(code)
166
+ except SyntaxError:
167
+ return out
168
+ for node in ast.walk(tree):
169
+ if not (isinstance(node, ast.Assign) and isinstance(node.value, ast.Call)):
170
+ continue
171
+ call = node.value
172
+ if call_name(call) not in _READERS or not call.args or not isinstance(call.args[0], ast.Constant):
173
+ continue
174
+ target = node.targets[0]
175
+ if not isinstance(target, ast.Name):
176
+ continue
177
+ path = Path(base_dir) / str(call.args[0].value)
178
+ if not path.exists():
179
+ continue
180
+ reader = call_name(call)
181
+ try:
182
+ df = getattr(pd, reader)(path)
183
+ except Exception:
184
+ continue
185
+ mem = self.memory.load_or_profile(df, name=path.stem, source=str(path))
186
+ self.datasets[path.stem] = mem
187
+ out[target.id] = mem.profile
188
+ return out
189
+
190
+ def dataset_for(self, s: Suggestion) -> DatasetMemory | None:
191
+ for mem in self.datasets.values():
192
+ if any(c in mem.profile.columns for c in s.columns):
193
+ return mem
194
+ return self.dataset_memory()
195
+
196
+ def profile_markdown(self) -> str:
197
+ if not self.datasets:
198
+ return "(no dataset loaded)"
199
+ parts = []
200
+ for mem in self.datasets.values():
201
+ path = self.memory.md_path(mem.profile.name)
202
+ parts.append(path.read_text(encoding="utf-8") if path.exists() else mem.profile.name)
203
+ return "\n\n".join(parts)
204
+
205
+ # ------------------------------------------------------------------
206
+ # Advice
207
+
208
+ def check(self, code: str, show: bool = True, extra_frames: dict[str, DatasetProfile] | None = None) -> list[Suggestion]:
209
+ """Return (and by default print) new suggestions for ``code``. Never runs it."""
210
+ frames = {**self.frames(), **(extra_frames or {})}
211
+ profiles = [m.profile for m in self.datasets.values()] + [m.profile for m in self.memory.load_all()
212
+ if m.profile.name not in self.datasets]
213
+ found = [s for s in self.advisor.check(code, frames=frames, profiles=profiles)
214
+ if not self.history.seen(s.rule, s.source_code)]
215
+ if self.ai_review and self.assistant:
216
+ found += self._ai_review(code, found)
217
+ self.history.extend(found)
218
+ self.last_code = code
219
+ if show and found:
220
+ self.out("\n".join(s.render() for s in found))
221
+ return found
222
+
223
+ def _ai_review(self, code: str, already: list[Suggestion]) -> list[Suggestion]:
224
+ items = self.assistant.review(code, self.profile_markdown(), [s.title for s in already]) or []
225
+ out = []
226
+ lines = code.splitlines()
227
+ for it in items:
228
+ line = it.get("line") or None
229
+ src = lines[line - 1].strip() if line and 0 < line <= len(lines) else ""
230
+ if self.history.seen("ai_review:" + it["title"], src):
231
+ continue
232
+ out.append(Suggestion(
233
+ rule="ai_review:" + it["title"], concept="", title=it["title"], problem=it["problem"],
234
+ alternative=it["alternative"], rationale=it["rationale"], code=it.get("code") or None,
235
+ line=line, source_code=src, origin="ai",
236
+ ))
237
+ return out
238
+
239
+ # ------------------------------------------------------------------
240
+ # Execution
241
+
242
+ def executor(self, filename: str = "<learnstat>") -> Executor:
243
+ return Executor(
244
+ self.ns,
245
+ max_attempts=self.max_attempts,
246
+ auto_fix=self.auto_fix,
247
+ confirm=self.confirm,
248
+ ai_fixer=self._ai_fixer if self.assistant else None,
249
+ filename=filename,
250
+ )
251
+
252
+ def run(self, code: str, check: bool = True, filename: str = "<learnstat>") -> RunResult:
253
+ """Check, then execute ``code`` with guarded recovery. Prints the recovery report if anything happened."""
254
+ if check:
255
+ self.check(code)
256
+ result = self.executor(filename).run(code)
257
+ self._after_run(code, result)
258
+ return result
259
+
260
+ def _after_run(self, code: str, result: RunResult) -> None:
261
+ self.last_code = code
262
+ self.last_result = result
263
+ if result.attempts or result.blocked or result.notes:
264
+ self.out(result.report())
265
+
266
+ def _ai_fixer(self, d: Diagnosis, src: str) -> Fix | None:
267
+ names = {k: type(v).__name__ for k, v in self.ns.items() if not k.startswith("_") and not callable(v)}
268
+ context = f"Variables: {names}\n\n{self.profile_markdown()[:12000]}"
269
+ data = self.assistant.propose_fix(src, f"{d.exc_type}: {d.message}", d.category, context)
270
+ if not data or not data.get("can_fix") or not data.get("fixed_code", "").strip():
271
+ if data and data.get("explanation"):
272
+ d.next_step = data["explanation"]
273
+ return None
274
+ return Fix("ai", "AI suggests: " + data["explanation"], data["fixed_code"].strip("\n"))
275
+
276
+ # ------------------------------------------------------------------
277
+ # Commands
278
+
279
+ def command(self, text: str) -> str:
280
+ from .commands import dispatch
281
+
282
+ return dispatch(self, text)
283
+
284
+ def accept(self, sid: int | None = None) -> str:
285
+ return self.command(f"/accept {sid or ''}")
286
+
287
+ def reject(self, sid: int | None = None, reason: str = "") -> str:
288
+ return self.command(f"/reject {sid or ''} {reason}")
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Module-level default session, so ``learnstat.read_csv`` feels like pandas.
293
+
294
+ _default: Session | None = None
295
+
296
+
297
+ def get_session() -> Session:
298
+ global _default
299
+ if _default is None:
300
+ _default = Session()
301
+ return _default
302
+
303
+
304
+ def set_session(session: Session) -> Session:
305
+ global _default
306
+ _default = session
307
+ return session