euron-code 0.1.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.
- euron_code/__init__.py +3 -0
- euron_code/__main__.py +4 -0
- euron_code/agent.py +595 -0
- euron_code/api.py +364 -0
- euron_code/auth.py +115 -0
- euron_code/cli.py +351 -0
- euron_code/config.py +207 -0
- euron_code/context.py +131 -0
- euron_code/credentials.py +197 -0
- euron_code/doctor.py +88 -0
- euron_code/keys.py +92 -0
- euron_code/memory.py +97 -0
- euron_code/permissions.py +245 -0
- euron_code/prompts.py +98 -0
- euron_code/repl.py +425 -0
- euron_code/sessions.py +186 -0
- euron_code/tools/__init__.py +33 -0
- euron_code/tools/base.py +167 -0
- euron_code/tools/db.py +368 -0
- euron_code/tools/fs.py +412 -0
- euron_code/tools/ignore.py +75 -0
- euron_code/tools/search.py +204 -0
- euron_code/tools/shell.py +214 -0
- euron_code/tools/task.py +41 -0
- euron_code/tools/todo.py +64 -0
- euron_code/tools/web.py +112 -0
- euron_code/ui.py +269 -0
- euron_code/usage_view.py +108 -0
- euron_code-0.1.0.dist-info/METADATA +278 -0
- euron_code-0.1.0.dist-info/RECORD +33 -0
- euron_code-0.1.0.dist-info/WHEEL +4 -0
- euron_code-0.1.0.dist-info/entry_points.txt +2 -0
- euron_code-0.1.0.dist-info/licenses/LICENSE +23 -0
euron_code/tools/base.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Tool plumbing shared by every tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from euron_code.permissions import Request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolError(Exception):
|
|
14
|
+
"""Raised by a tool to return an error message to the model."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolResult:
|
|
19
|
+
content: str
|
|
20
|
+
is_error: bool = False
|
|
21
|
+
preview: Optional[str] = None # short text for the user; defaults to content
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FileRecord:
|
|
26
|
+
mtime: float
|
|
27
|
+
size: int
|
|
28
|
+
ranges: List[tuple] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReadTracker:
|
|
32
|
+
"""Remembers which files the model has read (for edit safety and read dedupe)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.files: Dict[str, FileRecord] = {}
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _key(path: Path) -> str:
|
|
39
|
+
k = str(path.resolve())
|
|
40
|
+
return k.lower() if os.name == "nt" else k
|
|
41
|
+
|
|
42
|
+
def mark_read(self, path: Path, rng: Optional[tuple] = None) -> None:
|
|
43
|
+
st = path.stat()
|
|
44
|
+
rec = self.files.get(self._key(path))
|
|
45
|
+
if rec is None or rec.mtime != st.st_mtime or rec.size != st.st_size:
|
|
46
|
+
rec = FileRecord(st.st_mtime, st.st_size)
|
|
47
|
+
self.files[self._key(path)] = rec
|
|
48
|
+
if rng is not None and rng not in rec.ranges:
|
|
49
|
+
rec.ranges.append(rng)
|
|
50
|
+
|
|
51
|
+
def mark_written(self, path: Path) -> None:
|
|
52
|
+
st = path.stat()
|
|
53
|
+
self.files[self._key(path)] = FileRecord(st.st_mtime, st.st_size, [])
|
|
54
|
+
|
|
55
|
+
def was_read(self, path: Path) -> bool:
|
|
56
|
+
return self._key(path) in self.files
|
|
57
|
+
|
|
58
|
+
def unchanged_since_read(self, path: Path) -> bool:
|
|
59
|
+
rec = self.files.get(self._key(path))
|
|
60
|
+
if rec is None:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
st = path.stat()
|
|
64
|
+
except OSError:
|
|
65
|
+
return False
|
|
66
|
+
return rec.mtime == st.st_mtime and rec.size == st.st_size
|
|
67
|
+
|
|
68
|
+
def already_has(self, path: Path, rng: tuple) -> bool:
|
|
69
|
+
rec = self.files.get(self._key(path))
|
|
70
|
+
return bool(rec) and self.unchanged_since_read(path) and rng in rec.ranges
|
|
71
|
+
|
|
72
|
+
def clear(self) -> None:
|
|
73
|
+
"""Called after compaction: file contents are no longer in context."""
|
|
74
|
+
for rec in self.files.values():
|
|
75
|
+
rec.ranges.clear()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ToolContext:
|
|
80
|
+
root: Path
|
|
81
|
+
settings: Dict[str, Any]
|
|
82
|
+
ui: Any
|
|
83
|
+
tracker: ReadTracker = field(default_factory=ReadTracker)
|
|
84
|
+
shell: Any = None # ShellSession
|
|
85
|
+
todos: List[Dict[str, Any]] = field(default_factory=list)
|
|
86
|
+
agent: Any = None # the running Agent (for the task tool)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def max_output_chars(self) -> int:
|
|
90
|
+
return int(self.settings.get("toolOutputMaxChars") or 12000)
|
|
91
|
+
|
|
92
|
+
def resolve(self, p: str) -> Path:
|
|
93
|
+
if not isinstance(p, str) or not p.strip():
|
|
94
|
+
raise ToolError("A non-empty path is required.")
|
|
95
|
+
raw = os.path.expanduser(p.strip())
|
|
96
|
+
path = Path(raw)
|
|
97
|
+
if not path.is_absolute():
|
|
98
|
+
path = self.root / path
|
|
99
|
+
return Path(os.path.normpath(str(path)))
|
|
100
|
+
|
|
101
|
+
def rel(self, path: Path) -> str:
|
|
102
|
+
try:
|
|
103
|
+
return path.resolve().relative_to(self.root.resolve()).as_posix()
|
|
104
|
+
except ValueError:
|
|
105
|
+
return str(path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def truncate(text: str, max_chars: int, note: str = "") -> str:
|
|
109
|
+
"""Keep the head and tail of long output, with a note saying what was cut."""
|
|
110
|
+
if len(text) <= max_chars:
|
|
111
|
+
return text
|
|
112
|
+
head = int(max_chars * 0.6)
|
|
113
|
+
tail = max_chars - head
|
|
114
|
+
cut = text[head: len(text) - tail]
|
|
115
|
+
cut_lines = cut.count("\n")
|
|
116
|
+
msg = (f"\n\n[... output truncated: {len(cut)} characters / ~{cut_lines} lines omitted"
|
|
117
|
+
f"{'; ' + note if note else ''} ...]\n\n")
|
|
118
|
+
return text[:head] + msg + text[len(text) - tail:]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Tool:
|
|
122
|
+
name: str = ""
|
|
123
|
+
description: str = ""
|
|
124
|
+
parameters: Dict[str, Any] = {}
|
|
125
|
+
read_only: bool = False
|
|
126
|
+
|
|
127
|
+
def schema(self) -> Dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
"type": "function",
|
|
130
|
+
"function": {"name": self.name, "description": self.description, "parameters": self.parameters},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def permission(self, args: Dict[str, Any], ctx: ToolContext) -> Request:
|
|
134
|
+
return Request(category=None, tool=self.name)
|
|
135
|
+
|
|
136
|
+
def describe(self, args: Dict[str, Any], ctx: ToolContext) -> str:
|
|
137
|
+
"""One-line summary shown to the user."""
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
def preview(self, args: Dict[str, Any], ctx: ToolContext) -> Optional[str]:
|
|
141
|
+
"""Extra detail shown in the permission prompt (e.g. a diff). May raise ToolError."""
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def run(self, args: Dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def require(args: Dict[str, Any], key: str, typ: type = str) -> Any:
|
|
149
|
+
if key not in args or args[key] is None:
|
|
150
|
+
raise ToolError(f"Missing required parameter `{key}`.")
|
|
151
|
+
v = args[key]
|
|
152
|
+
if typ is str and not isinstance(v, str):
|
|
153
|
+
raise ToolError(f"Parameter `{key}` must be a string.")
|
|
154
|
+
return v
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def opt_int(args: Dict[str, Any], key: str, default: Optional[int]) -> Optional[int]:
|
|
158
|
+
v = args.get(key)
|
|
159
|
+
if v is None or v == "":
|
|
160
|
+
return default
|
|
161
|
+
try:
|
|
162
|
+
return int(v)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
raise ToolError(f"Parameter `{key}` must be an integer.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
DiffFn = Callable[[str, str, str], str]
|
euron_code/tools/db.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""db_query: explore SQLite files and Postgres / MySQL databases the user configured.
|
|
2
|
+
|
|
3
|
+
Read-only by default, enforced twice:
|
|
4
|
+
1. a SQL guard rejects anything that is not a plain read (SELECT / WITH / EXPLAIN / SHOW / ...);
|
|
5
|
+
2. the connection itself is opened read-only (SQLite `mode=ro`, Postgres read-only session,
|
|
6
|
+
MySQL `TRANSACTION READ ONLY`).
|
|
7
|
+
Writes need `db.allowWrites: true` in settings AND a per-statement permission prompt.
|
|
8
|
+
|
|
9
|
+
Connections live in settings, never in the conversation:
|
|
10
|
+
"db": {"connections": {"app": "postgresql://user:${PGPASSWORD}@localhost/app",
|
|
11
|
+
"local": "sqlite:///data/app.db"}}
|
|
12
|
+
`${VAR}` is expanded from the environment so passwords need not be stored in the file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
22
|
+
from urllib.parse import unquote, urlparse
|
|
23
|
+
|
|
24
|
+
from euron_code.permissions import Request
|
|
25
|
+
from euron_code.tools.base import Tool, ToolContext, ToolError, ToolResult, opt_int, truncate
|
|
26
|
+
|
|
27
|
+
READ_FIRST = {"select", "with", "explain", "show", "describe", "desc", "pragma", "values", "table"}
|
|
28
|
+
DENY_TOKENS = {
|
|
29
|
+
"insert", "update", "delete", "drop", "create", "alter", "truncate", "grant", "revoke", "merge",
|
|
30
|
+
"upsert", "attach", "detach", "vacuum", "reindex", "copy", "call", "exec", "execute", "lock",
|
|
31
|
+
"into", "rename", "load_extension", "set_config", "pg_terminate_backend", "pg_cancel_backend",
|
|
32
|
+
"pg_reload_conf", "lo_import", "lo_export", "pg_read_file", "pg_read_binary_file", "pg_ls_dir",
|
|
33
|
+
"dblink", "dblink_exec", "nextval", "setval", "refresh", "analyze", "optimize", "handler",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strip_sql(sql: str) -> str:
|
|
38
|
+
"""Remove comments, string literals and quoted identifiers so keyword checks see only code."""
|
|
39
|
+
out = []
|
|
40
|
+
i, n = 0, len(sql)
|
|
41
|
+
while i < n:
|
|
42
|
+
c = sql[i]
|
|
43
|
+
if sql.startswith("--", i) or c == "#":
|
|
44
|
+
j = sql.find("\n", i)
|
|
45
|
+
i = n if j == -1 else j
|
|
46
|
+
out.append(" ")
|
|
47
|
+
elif sql.startswith("/*", i):
|
|
48
|
+
j = sql.find("*/", i + 2)
|
|
49
|
+
i = n if j == -1 else j + 2
|
|
50
|
+
out.append(" ")
|
|
51
|
+
elif c in ("'", '"', "`"):
|
|
52
|
+
j = i + 1
|
|
53
|
+
while j < n:
|
|
54
|
+
if sql[j] == c:
|
|
55
|
+
if j + 1 < n and sql[j + 1] == c:
|
|
56
|
+
j += 2
|
|
57
|
+
continue
|
|
58
|
+
break
|
|
59
|
+
if sql[j] == "\\" and c == "'":
|
|
60
|
+
j += 2
|
|
61
|
+
continue
|
|
62
|
+
j += 1
|
|
63
|
+
i = j + 1
|
|
64
|
+
out.append(" 'x' " if c == "'" else " ident ")
|
|
65
|
+
elif c == "$":
|
|
66
|
+
m = re.match(r"\$([A-Za-z_]\w*)?\$", sql[i:])
|
|
67
|
+
if m:
|
|
68
|
+
tag = m.group(0)
|
|
69
|
+
j = sql.find(tag, i + len(tag))
|
|
70
|
+
i = n if j == -1 else j + len(tag)
|
|
71
|
+
out.append(" 'x' ")
|
|
72
|
+
else:
|
|
73
|
+
out.append(c)
|
|
74
|
+
i += 1
|
|
75
|
+
else:
|
|
76
|
+
out.append(c)
|
|
77
|
+
i += 1
|
|
78
|
+
return "".join(out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_read_only(sql: str) -> Tuple[bool, str]:
|
|
82
|
+
"""Return (ok, reason). ok=True only for statements that cannot write."""
|
|
83
|
+
code = _strip_sql(sql)
|
|
84
|
+
stmts = [s.strip() for s in code.split(";") if s.strip()]
|
|
85
|
+
if not stmts:
|
|
86
|
+
return False, "empty statement"
|
|
87
|
+
for s in stmts:
|
|
88
|
+
toks = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", s.lower())
|
|
89
|
+
if not toks:
|
|
90
|
+
return False, "no SQL keyword found"
|
|
91
|
+
first = toks[0]
|
|
92
|
+
if first not in READ_FIRST:
|
|
93
|
+
return False, f"`{first.upper()}` statements are not read-only"
|
|
94
|
+
bad = sorted(set(toks) & DENY_TOKENS)
|
|
95
|
+
if first == "explain":
|
|
96
|
+
# EXPLAIN ANALYZE runs the statement; allow only when the rest is a plain read
|
|
97
|
+
rest = [t for t in toks[1:] if t not in ("analyze", "verbose", "query", "plan", "format",
|
|
98
|
+
"json", "text", "costs", "buffers", "true", "false")]
|
|
99
|
+
if rest and rest[0] not in ("select", "with", "values", "table"):
|
|
100
|
+
return False, "EXPLAIN of a non-SELECT statement"
|
|
101
|
+
bad = [b for b in bad if b != "analyze"]
|
|
102
|
+
if bad:
|
|
103
|
+
return False, f"contains {', '.join(b.upper() for b in bad)}"
|
|
104
|
+
if first == "pragma" and "=" in s:
|
|
105
|
+
return False, "PRAGMA assignments change the database"
|
|
106
|
+
return True, ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expand_env(s: str) -> str:
|
|
110
|
+
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), ""), s)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mask_dsn(dsn: str) -> str:
|
|
114
|
+
return re.sub(r"(://[^:/@]+:)[^@]*@", r"\1****@", dsn)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fmt(v: Any) -> str:
|
|
118
|
+
if v is None:
|
|
119
|
+
return "NULL"
|
|
120
|
+
if isinstance(v, (bytes, bytearray, memoryview)):
|
|
121
|
+
return f"<{len(bytes(v))} bytes>"
|
|
122
|
+
s = str(v).replace("\n", "\\n")
|
|
123
|
+
return s if len(s) <= 200 else s[:200] + "..."
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def format_table(cols: Sequence[str], rows: Sequence[Sequence[Any]], total: Optional[int], limit: int) -> str:
|
|
127
|
+
if not cols:
|
|
128
|
+
return "(statement returned no columns)"
|
|
129
|
+
cells = [[_fmt(v) for v in r] for r in rows]
|
|
130
|
+
widths = [min(60, max([len(str(c))] + [len(r[i]) for r in cells])) for i, c in enumerate(cols)]
|
|
131
|
+
def line(vals):
|
|
132
|
+
return " | ".join(str(v)[:60].ljust(widths[i]) for i, v in enumerate(vals)).rstrip()
|
|
133
|
+
out = [line(cols), "-+-".join("-" * w for w in widths)]
|
|
134
|
+
out += [line(r) for r in cells]
|
|
135
|
+
n = len(rows)
|
|
136
|
+
more = total is not None and total > n
|
|
137
|
+
out.append(f"({n} row{'s' if n != 1 else ''}{'; more rows exist, showing the first ' + str(limit) if more else ''})")
|
|
138
|
+
return "\n".join(out)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Connection:
|
|
142
|
+
def __init__(self, kind: str, target: str, label: str):
|
|
143
|
+
self.kind = kind # sqlite | postgres | mysql
|
|
144
|
+
self.target = target
|
|
145
|
+
self.label = label
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_connection(name: Optional[str], ctx: ToolContext) -> Connection:
|
|
149
|
+
conns = (ctx.settings.get("db") or {}).get("connections") or {}
|
|
150
|
+
if not name:
|
|
151
|
+
if len(conns) == 1:
|
|
152
|
+
name = next(iter(conns))
|
|
153
|
+
else:
|
|
154
|
+
raise ToolError("Give `database`: a configured connection name "
|
|
155
|
+
f"({', '.join(conns) or 'none configured'}) or a path to a .db/.sqlite file.")
|
|
156
|
+
raw = conns.get(name)
|
|
157
|
+
if raw is None:
|
|
158
|
+
p = ctx.resolve(name)
|
|
159
|
+
if p.suffix.lower() in (".db", ".sqlite", ".sqlite3", ".db3") or p.is_file():
|
|
160
|
+
if not p.is_file():
|
|
161
|
+
raise ToolError(f"SQLite file not found: {ctx.rel(p)}")
|
|
162
|
+
return Connection("sqlite", str(p), ctx.rel(p))
|
|
163
|
+
raise ToolError(f"Unknown database {name!r}. Configured: {', '.join(conns) or 'none'}. "
|
|
164
|
+
"The user can add one with `euron config set db.connections.<name> <dsn>`.")
|
|
165
|
+
dsn = expand_env(str(raw))
|
|
166
|
+
low = dsn.lower()
|
|
167
|
+
if low.startswith("sqlite:"):
|
|
168
|
+
# sqlite:///rel/path.db, sqlite:////abs/path.db, sqlite:///C:/abs/path.db, sqlite:path.db
|
|
169
|
+
if low.startswith("sqlite:///"):
|
|
170
|
+
path = dsn[len("sqlite:///"):]
|
|
171
|
+
elif low.startswith("sqlite://"):
|
|
172
|
+
path = dsn[len("sqlite://"):]
|
|
173
|
+
else:
|
|
174
|
+
path = dsn[len("sqlite:"):]
|
|
175
|
+
p = ctx.resolve(path)
|
|
176
|
+
return Connection("sqlite", str(p), name)
|
|
177
|
+
if low.startswith(("postgres://", "postgresql://")):
|
|
178
|
+
return Connection("postgres", dsn, name)
|
|
179
|
+
if low.startswith(("mysql://", "mariadb://")):
|
|
180
|
+
return Connection("mysql", dsn, name)
|
|
181
|
+
p = ctx.resolve(dsn)
|
|
182
|
+
return Connection("sqlite", str(p), name)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_sqlite(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
|
|
186
|
+
p = Path(conn.target)
|
|
187
|
+
if not p.is_file():
|
|
188
|
+
raise ToolError(f"SQLite file not found: {conn.label}")
|
|
189
|
+
if write:
|
|
190
|
+
db = sqlite3.connect(str(p), timeout=10)
|
|
191
|
+
else:
|
|
192
|
+
db = sqlite3.connect(p.resolve().as_uri() + "?mode=ro", uri=True, timeout=10)
|
|
193
|
+
try:
|
|
194
|
+
cur = db.cursor()
|
|
195
|
+
if write:
|
|
196
|
+
cur.executescript(sql) if not params and sql.count(";") > 1 else cur.execute(sql, params)
|
|
197
|
+
db.commit()
|
|
198
|
+
return [], [], None, cur.rowcount
|
|
199
|
+
cur.execute(sql, params)
|
|
200
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
201
|
+
rows = cur.fetchmany(limit + 1)
|
|
202
|
+
return cols, rows[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
|
|
203
|
+
except sqlite3.Error as e:
|
|
204
|
+
raise ToolError(f"SQLite error: {e}")
|
|
205
|
+
finally:
|
|
206
|
+
db.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _run_postgres(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
|
|
210
|
+
try:
|
|
211
|
+
import psycopg # type: ignore
|
|
212
|
+
v3 = True
|
|
213
|
+
except ImportError:
|
|
214
|
+
try:
|
|
215
|
+
import psycopg2 as psycopg # type: ignore
|
|
216
|
+
v3 = False
|
|
217
|
+
except ImportError:
|
|
218
|
+
raise ToolError("Postgres support needs a driver: pip install \"euron-code[postgres]\"")
|
|
219
|
+
try:
|
|
220
|
+
db = psycopg.connect(conn.target, connect_timeout=10)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise ToolError(f"Cannot connect to {conn.label}: {mask_dsn(str(e))}")
|
|
223
|
+
try:
|
|
224
|
+
if v3:
|
|
225
|
+
db.read_only = not write
|
|
226
|
+
else:
|
|
227
|
+
db.set_session(readonly=not write)
|
|
228
|
+
cur = db.cursor()
|
|
229
|
+
cur.execute("SET statement_timeout = 30000")
|
|
230
|
+
cur.execute(sql, params or None)
|
|
231
|
+
if write:
|
|
232
|
+
db.commit()
|
|
233
|
+
return [], [], None, cur.rowcount
|
|
234
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
235
|
+
rows = cur.fetchmany(limit + 1) if cur.description else []
|
|
236
|
+
db.rollback()
|
|
237
|
+
return cols, rows[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
|
|
238
|
+
except Exception as e:
|
|
239
|
+
raise ToolError(f"Postgres error: {e}")
|
|
240
|
+
finally:
|
|
241
|
+
db.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_mysql(conn: Connection, sql: str, params: Sequence[Any], write: bool, limit: int):
|
|
245
|
+
try:
|
|
246
|
+
import pymysql # type: ignore
|
|
247
|
+
except ImportError:
|
|
248
|
+
raise ToolError("MySQL support needs a driver: pip install \"euron-code[mysql]\"")
|
|
249
|
+
u = urlparse(conn.target)
|
|
250
|
+
try:
|
|
251
|
+
db = pymysql.connect(host=u.hostname or "localhost", port=u.port or 3306,
|
|
252
|
+
user=unquote(u.username or ""), password=unquote(u.password or ""),
|
|
253
|
+
database=(u.path or "/").lstrip("/") or None, connect_timeout=10,
|
|
254
|
+
read_timeout=30)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise ToolError(f"Cannot connect to {conn.label}: {mask_dsn(str(e))}")
|
|
257
|
+
try:
|
|
258
|
+
cur = db.cursor()
|
|
259
|
+
if not write:
|
|
260
|
+
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
|
261
|
+
cur.execute("START TRANSACTION READ ONLY")
|
|
262
|
+
cur.execute(sql, params or None)
|
|
263
|
+
if write:
|
|
264
|
+
db.commit()
|
|
265
|
+
return [], [], None, cur.rowcount
|
|
266
|
+
cols = [d[0] for d in cur.description] if cur.description else []
|
|
267
|
+
rows = cur.fetchmany(limit + 1) if cur.description else []
|
|
268
|
+
db.rollback()
|
|
269
|
+
return cols, list(rows)[:limit], (limit + 1 if len(rows) > limit else len(rows)), None
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise ToolError(f"MySQL error: {e}")
|
|
272
|
+
finally:
|
|
273
|
+
db.close()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
RUNNERS = {"sqlite": _run_sqlite, "postgres": _run_postgres, "mysql": _run_mysql}
|
|
277
|
+
|
|
278
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def helper_sql(kind: str, action: str, table: Optional[str]) -> Tuple[str, List[Any]]:
|
|
282
|
+
if action == "list_tables":
|
|
283
|
+
if kind == "sqlite":
|
|
284
|
+
return ("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') "
|
|
285
|
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name"), []
|
|
286
|
+
if kind == "postgres":
|
|
287
|
+
return ("SELECT table_schema, table_name, table_type FROM information_schema.tables "
|
|
288
|
+
"WHERE table_schema NOT IN ('pg_catalog','information_schema') "
|
|
289
|
+
"ORDER BY table_schema, table_name"), []
|
|
290
|
+
return ("SELECT table_name, table_type FROM information_schema.tables "
|
|
291
|
+
"WHERE table_schema = DATABASE() ORDER BY table_name"), []
|
|
292
|
+
if not table or not _IDENT.match(table):
|
|
293
|
+
raise ToolError("`table` must be a plain table name (optionally schema.table).")
|
|
294
|
+
schema, _, name = table.rpartition(".")
|
|
295
|
+
if kind == "sqlite":
|
|
296
|
+
return f"SELECT cid, name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{name}')", []
|
|
297
|
+
ph = "%s"
|
|
298
|
+
if kind == "postgres":
|
|
299
|
+
sql = ("SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns "
|
|
300
|
+
f"WHERE table_name = {ph}" + (f" AND table_schema = {ph}" if schema else "") + " ORDER BY ordinal_position")
|
|
301
|
+
return sql, [name] + ([schema] if schema else [])
|
|
302
|
+
sql = ("SELECT column_name, column_type, is_nullable, column_key, column_default FROM information_schema.columns "
|
|
303
|
+
f"WHERE table_name = {ph} AND table_schema = " + (ph if schema else "DATABASE()") + " ORDER BY ordinal_position")
|
|
304
|
+
return sql, [name] + ([schema] if schema else [])
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class DbQuery(Tool):
|
|
308
|
+
name = "db_query"
|
|
309
|
+
description = (
|
|
310
|
+
"Explore a database: a SQLite file in the project, or a Postgres/MySQL connection the user "
|
|
311
|
+
"configured by name. action `list_tables`, `describe` (needs `table`), or `query` (needs `sql`). "
|
|
312
|
+
"Read-only: only SELECT/WITH/EXPLAIN/SHOW/PRAGMA unless the user enabled writes. Results are "
|
|
313
|
+
"capped (default 200 rows); add LIMIT to large queries."
|
|
314
|
+
)
|
|
315
|
+
parameters = {
|
|
316
|
+
"type": "object",
|
|
317
|
+
"properties": {
|
|
318
|
+
"action": {"type": "string", "enum": ["query", "list_tables", "describe"]},
|
|
319
|
+
"database": {"type": "string", "description": "Connection name from settings, or a path to a SQLite file."},
|
|
320
|
+
"sql": {"type": "string"},
|
|
321
|
+
"table": {"type": "string"},
|
|
322
|
+
},
|
|
323
|
+
"required": ["action"],
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def _plan(self, args, ctx):
|
|
327
|
+
action = args.get("action") or ("query" if args.get("sql") else "list_tables")
|
|
328
|
+
conn = resolve_connection(args.get("database"), ctx)
|
|
329
|
+
if action == "query":
|
|
330
|
+
sql = args.get("sql")
|
|
331
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
332
|
+
raise ToolError("action `query` needs `sql`.")
|
|
333
|
+
ok, reason = check_read_only(sql)
|
|
334
|
+
write = not ok
|
|
335
|
+
if write and not (ctx.settings.get("db") or {}).get("allowWrites"):
|
|
336
|
+
raise ToolError(
|
|
337
|
+
f"Rejected: this database tool is read-only ({reason}). The user can allow writes with "
|
|
338
|
+
"`euron config set db.allowWrites true`; each write still asks for permission."
|
|
339
|
+
)
|
|
340
|
+
return action, conn, sql, [], write
|
|
341
|
+
if action not in ("list_tables", "describe"):
|
|
342
|
+
raise ToolError("action must be query, list_tables or describe.")
|
|
343
|
+
sql, params = helper_sql(conn.kind, action, args.get("table"))
|
|
344
|
+
return action, conn, sql, params, False
|
|
345
|
+
|
|
346
|
+
def permission(self, args, ctx):
|
|
347
|
+
_, conn, sql, _, write = self._plan(args, ctx)
|
|
348
|
+
paths = [Path(conn.target)] if conn.kind == "sqlite" else []
|
|
349
|
+
return Request("DbWrite" if write else "Db", self.name, paths=paths, command=sql)
|
|
350
|
+
|
|
351
|
+
def describe(self, args, ctx):
|
|
352
|
+
a = args.get("action") or "query"
|
|
353
|
+
what = args.get("sql") or args.get("table") or ""
|
|
354
|
+
return f"{a} {args.get('database') or ''} {what}".strip()[:160]
|
|
355
|
+
|
|
356
|
+
def preview(self, args, ctx):
|
|
357
|
+
_, conn, sql, _, write = self._plan(args, ctx)
|
|
358
|
+
return f"{'WRITE to' if write else 'Read'} {conn.kind} database {conn.label}:\n{sql}"
|
|
359
|
+
|
|
360
|
+
def run(self, args, ctx):
|
|
361
|
+
action, conn, sql, params, write = self._plan(args, ctx)
|
|
362
|
+
limit = max(1, min(opt_int(args, "limit", None) or int((ctx.settings.get("db") or {}).get("maxRows") or 200), 1000))
|
|
363
|
+
cols, rows, total, affected = RUNNERS[conn.kind](conn, sql, params, write, limit)
|
|
364
|
+
if write:
|
|
365
|
+
return ToolResult(f"Statement executed on {conn.label}. Rows affected: {affected}.")
|
|
366
|
+
body = format_table(cols, rows, total, limit)
|
|
367
|
+
return ToolResult(truncate(body, ctx.max_output_chars, "add LIMIT or select fewer columns"),
|
|
368
|
+
preview=f"{len(rows)} rows")
|