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/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
|