execpad 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.
- execpad/__init__.py +13 -0
- execpad/engines.py +127 -0
- execpad/extensions/globs.py +35 -0
- execpad/run_log.py +44 -0
- execpad/runtime.py +230 -0
- execpad/types.py +47 -0
- execpad-0.1.0.dist-info/METADATA +102 -0
- execpad-0.1.0.dist-info/RECORD +9 -0
- execpad-0.1.0.dist-info/WHEEL +4 -0
execpad/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from execpad.run_log import export_run_log_json, export_run_log_markdown
|
|
2
|
+
from execpad.runtime import Runtime
|
|
3
|
+
from execpad.types import FileChange, RunLogEntry, RunResult, SerializedRuntime
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Runtime",
|
|
7
|
+
"RunResult",
|
|
8
|
+
"RunLogEntry",
|
|
9
|
+
"FileChange",
|
|
10
|
+
"SerializedRuntime",
|
|
11
|
+
"export_run_log_json",
|
|
12
|
+
"export_run_log_markdown",
|
|
13
|
+
]
|
execpad/engines.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from execpad.types import RunResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _truncate(stdout: str, stderr: str, max_bytes: int | None) -> tuple[str, str, bool]:
|
|
13
|
+
if max_bytes is None or max_bytes <= 0:
|
|
14
|
+
return stdout, stderr, False
|
|
15
|
+
if len(stdout) + len(stderr) <= max_bytes:
|
|
16
|
+
return stdout, stderr, False
|
|
17
|
+
budget = max_bytes
|
|
18
|
+
if len(stdout) > budget:
|
|
19
|
+
return stdout[:budget] + "\n...[truncated]", "", True
|
|
20
|
+
budget -= len(stdout)
|
|
21
|
+
err = stderr[:budget] + "\n...[truncated]" if len(stderr) > budget else stderr
|
|
22
|
+
return stdout, err, True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_subprocess(
|
|
26
|
+
argv: list[str],
|
|
27
|
+
cwd: str,
|
|
28
|
+
env: dict[str, str] | None,
|
|
29
|
+
timeout_s: float,
|
|
30
|
+
max_output_bytes: int | None,
|
|
31
|
+
) -> RunResult:
|
|
32
|
+
start = time.perf_counter()
|
|
33
|
+
try:
|
|
34
|
+
p = subprocess.run(
|
|
35
|
+
argv,
|
|
36
|
+
cwd=cwd,
|
|
37
|
+
env={**__import__("os").environ, **(env or {})},
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=timeout_s,
|
|
41
|
+
)
|
|
42
|
+
out, err, trunc = _truncate(p.stdout or "", p.stderr or "", max_output_bytes)
|
|
43
|
+
return RunResult(
|
|
44
|
+
stdout=out,
|
|
45
|
+
stderr=err,
|
|
46
|
+
exit_code=p.returncode,
|
|
47
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
48
|
+
files=[],
|
|
49
|
+
truncated=trunc,
|
|
50
|
+
)
|
|
51
|
+
except subprocess.TimeoutExpired as e:
|
|
52
|
+
out, err, trunc = _truncate(
|
|
53
|
+
(e.stdout or "") if isinstance(e.stdout, str) else "",
|
|
54
|
+
(e.stderr or "") if isinstance(e.stderr, str) else "",
|
|
55
|
+
max_output_bytes,
|
|
56
|
+
)
|
|
57
|
+
return RunResult(
|
|
58
|
+
stdout=out,
|
|
59
|
+
stderr=err or "timeout",
|
|
60
|
+
exit_code=124,
|
|
61
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
62
|
+
files=[],
|
|
63
|
+
truncated=trunc,
|
|
64
|
+
)
|
|
65
|
+
except FileNotFoundError as e:
|
|
66
|
+
return RunResult(
|
|
67
|
+
stdout="",
|
|
68
|
+
stderr=str(e),
|
|
69
|
+
exit_code=127,
|
|
70
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
71
|
+
files=[],
|
|
72
|
+
truncated=False,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_bash(code: str, cwd: str, env: dict[str, str] | None, timeout_s: float, max_out: int | None) -> RunResult:
|
|
77
|
+
bash = shutil.which("bash") or "bash"
|
|
78
|
+
return run_subprocess([bash, "-c", code], cwd, env, timeout_s, max_out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_python(code: str, cwd: str, env: dict[str, str] | None, timeout_s: float, max_out: int | None) -> RunResult:
|
|
82
|
+
py = shutil.which("python3") or shutil.which("python") or "python3"
|
|
83
|
+
return run_subprocess([py, "-c", code], cwd, env, timeout_s, max_out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_javascript(code: str, cwd: str, env: dict[str, str] | None, timeout_s: float, max_out: int | None) -> RunResult:
|
|
87
|
+
node = shutil.which("node") or "node"
|
|
88
|
+
return run_subprocess([node, "-e", code], cwd, env, timeout_s, max_out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_sql(code: str, cwd: str, env: dict[str, str] | None, timeout_s: float, max_out: int | None) -> RunResult:
|
|
92
|
+
import sqlite3
|
|
93
|
+
|
|
94
|
+
_ = cwd, env, timeout_s # SQL runs in-memory; cwd reserved for attach extensions
|
|
95
|
+
start = time.perf_counter()
|
|
96
|
+
try:
|
|
97
|
+
con = sqlite3.connect(":memory:")
|
|
98
|
+
cur = con.cursor()
|
|
99
|
+
parts = [s.strip() for s in code.split(";") if s.strip()]
|
|
100
|
+
out_lines: list[str] = []
|
|
101
|
+
for stmt in parts:
|
|
102
|
+
cur.execute(stmt)
|
|
103
|
+
if cur.description:
|
|
104
|
+
rows = cur.fetchall()
|
|
105
|
+
out_lines.extend(str(r) for r in rows)
|
|
106
|
+
else:
|
|
107
|
+
con.commit()
|
|
108
|
+
con.close()
|
|
109
|
+
out = "\n".join(out_lines) + ("\n" if out_lines else "")
|
|
110
|
+
out, err, trunc = _truncate(out, "", max_out)
|
|
111
|
+
return RunResult(
|
|
112
|
+
stdout=out,
|
|
113
|
+
stderr=err,
|
|
114
|
+
exit_code=0,
|
|
115
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
116
|
+
files=[],
|
|
117
|
+
truncated=trunc,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e: # noqa: BLE001
|
|
120
|
+
return RunResult(
|
|
121
|
+
stdout="",
|
|
122
|
+
stderr=str(e),
|
|
123
|
+
exit_code=1,
|
|
124
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
125
|
+
files=[],
|
|
126
|
+
truncated=False,
|
|
127
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _excluded(rel: str, exclude_globs: list[str]) -> bool:
|
|
8
|
+
parts = rel.replace("\\", "/").split("/")
|
|
9
|
+
if "node_modules" in parts or ".git" in parts:
|
|
10
|
+
return True
|
|
11
|
+
posix = rel.replace("\\", "/")
|
|
12
|
+
return any(fnmatch.fnmatch(posix, g) for g in exclude_globs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_matching_rel_paths(
|
|
16
|
+
root: str | Path,
|
|
17
|
+
include_globs: list[str],
|
|
18
|
+
exclude_globs: list[str] | None = None,
|
|
19
|
+
) -> list[str]:
|
|
20
|
+
exclude_globs = exclude_globs or []
|
|
21
|
+
rootp = Path(root).resolve()
|
|
22
|
+
rels: list[str] = []
|
|
23
|
+
for p in rootp.rglob("*"):
|
|
24
|
+
if not p.is_file():
|
|
25
|
+
continue
|
|
26
|
+
rel = str(p.relative_to(rootp)).replace("\\", "/")
|
|
27
|
+
if _excluded(rel, exclude_globs):
|
|
28
|
+
continue
|
|
29
|
+
posix = rel
|
|
30
|
+
if not include_globs:
|
|
31
|
+
rels.append(rel)
|
|
32
|
+
continue
|
|
33
|
+
if any(fnmatch.fnmatch(posix, g) for g in include_globs):
|
|
34
|
+
rels.append(rel)
|
|
35
|
+
return rels
|
execpad/run_log.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from execpad.types import RunLogEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def export_run_log_json(entries: list[RunLogEntry], *, pretty: bool = True) -> str:
|
|
10
|
+
payload = [
|
|
11
|
+
{
|
|
12
|
+
"id": e.id,
|
|
13
|
+
"at": e.at,
|
|
14
|
+
"language": e.language,
|
|
15
|
+
"code": e.code,
|
|
16
|
+
"cwd": e.cwd,
|
|
17
|
+
"exitCode": e.exit_code,
|
|
18
|
+
"durationMs": e.duration_ms,
|
|
19
|
+
"stdout": e.stdout,
|
|
20
|
+
"stderr": e.stderr,
|
|
21
|
+
"truncated": e.truncated,
|
|
22
|
+
"files": [{"path": f.path, "type": f.type, "size": f.size} for f in e.files],
|
|
23
|
+
}
|
|
24
|
+
for e in entries
|
|
25
|
+
]
|
|
26
|
+
return json.dumps(payload, indent=2 if pretty else None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def export_run_log_markdown(entries: list[RunLogEntry]) -> str:
|
|
30
|
+
lines: list[str] = ["# execpad session log", ""]
|
|
31
|
+
for e in entries:
|
|
32
|
+
ts = datetime.fromtimestamp(e.at / 1000, tz=timezone.utc).isoformat()
|
|
33
|
+
lines.append(f"## {e.id} — {e.language} @ {ts}")
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append(
|
|
36
|
+
f"- **exit:** {e.exit_code} · **duration:** {e.duration_ms}ms · **truncated:** {e.truncated}"
|
|
37
|
+
)
|
|
38
|
+
lines.append(f"- **cwd:** `{e.cwd}`")
|
|
39
|
+
if e.files:
|
|
40
|
+
parts = [f"{f.type}:{f.path}" for f in e.files]
|
|
41
|
+
lines.append(f"- **files:** {', '.join(parts)}")
|
|
42
|
+
lines.extend(["", "### code", "", "```", e.code, "```", "", "### stdout", "", "```", e.stdout or "(empty)", "```"])
|
|
43
|
+
lines.extend(["", "### stderr", "", "```", e.stderr or "(empty)", "```", ""])
|
|
44
|
+
return "\n".join(lines)
|
execpad/runtime.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from execpad.engines import run_bash, run_javascript, run_python, run_sql
|
|
12
|
+
from execpad.extensions.globs import list_matching_rel_paths
|
|
13
|
+
from execpad.types import FileChange, Language, RunLogEntry, RunResult, SerializedRuntime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _cp_tree(src: Path, dest: Path) -> None:
|
|
17
|
+
if dest.exists():
|
|
18
|
+
shutil.rmtree(dest)
|
|
19
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _merge_overlay_to_root(work: Path, root: Path) -> None:
|
|
23
|
+
for p in work.rglob("*"):
|
|
24
|
+
if p.is_file():
|
|
25
|
+
rel = p.relative_to(work)
|
|
26
|
+
out = root / rel
|
|
27
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
shutil.copy2(p, out)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _snapshot_tree(root: Path, rels: list[str]) -> dict[str, tuple[float, int]]:
|
|
32
|
+
m: dict[str, tuple[float, int]] = {}
|
|
33
|
+
for rel in rels:
|
|
34
|
+
p = root / rel
|
|
35
|
+
if p.is_file():
|
|
36
|
+
st = p.stat()
|
|
37
|
+
m[rel] = (st.st_mtime, st.st_size)
|
|
38
|
+
return m
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _diff_tree(root: Path, before: dict[str, tuple[float, int]], rels: list[str]) -> list[FileChange]:
|
|
42
|
+
changes: list[FileChange] = []
|
|
43
|
+
for rel in rels:
|
|
44
|
+
p = root / rel
|
|
45
|
+
if not p.is_file():
|
|
46
|
+
if rel in before:
|
|
47
|
+
changes.append(FileChange(path=rel, type="deleted", size=0))
|
|
48
|
+
continue
|
|
49
|
+
st = p.stat()
|
|
50
|
+
prev = before.get(rel)
|
|
51
|
+
if prev is None:
|
|
52
|
+
changes.append(FileChange(path=rel, type="created", size=st.st_size))
|
|
53
|
+
elif prev[0] != st.st_mtime or prev[1] != st.st_size:
|
|
54
|
+
changes.append(FileChange(path=rel, type="modified", size=st.st_size))
|
|
55
|
+
return changes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Runtime:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
root: str | Path,
|
|
62
|
+
*,
|
|
63
|
+
readonly: bool = False,
|
|
64
|
+
overlay: bool = False,
|
|
65
|
+
limits: dict[str, Any] | None = None,
|
|
66
|
+
files: dict[str, str] | None = None,
|
|
67
|
+
include_globs: list[str] | None = None,
|
|
68
|
+
exclude_globs: list[str] | None = None,
|
|
69
|
+
run_log: bool = True,
|
|
70
|
+
run_log_max_entries: int = 200,
|
|
71
|
+
on_run: Callable[[RunLogEntry], None] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.root = Path(root).resolve()
|
|
74
|
+
self._readonly = readonly
|
|
75
|
+
self._overlay = overlay
|
|
76
|
+
self._limits = limits or {}
|
|
77
|
+
self._include = include_globs
|
|
78
|
+
self._exclude = exclude_globs or ["**/node_modules/**", "**/.git/**"]
|
|
79
|
+
self._owns_workdir = overlay
|
|
80
|
+
self._run_log_enabled = run_log
|
|
81
|
+
self._run_log_max = max(1, int(run_log_max_entries))
|
|
82
|
+
self._on_run = on_run
|
|
83
|
+
self._run_log: list[RunLogEntry] = []
|
|
84
|
+
self._log_seq = 0
|
|
85
|
+
if overlay:
|
|
86
|
+
self._workdir = Path(tempfile.mkdtemp(prefix="execpad-"))
|
|
87
|
+
_cp_tree(self.root, self._workdir)
|
|
88
|
+
else:
|
|
89
|
+
self._workdir = self.root
|
|
90
|
+
if files:
|
|
91
|
+
for rel, content in files.items():
|
|
92
|
+
p = self._workdir / rel
|
|
93
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
p.write_text(content, encoding="utf8")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def effective_root(self) -> Path:
|
|
98
|
+
return self._workdir
|
|
99
|
+
|
|
100
|
+
def _timeout_s(self) -> float:
|
|
101
|
+
return float(self._limits.get("timeout_ms", 30_000)) / 1000.0
|
|
102
|
+
|
|
103
|
+
def _max_out(self) -> int | None:
|
|
104
|
+
return self._limits.get("max_output_bytes")
|
|
105
|
+
|
|
106
|
+
def _glob_scope(self) -> list[str]:
|
|
107
|
+
inc = self._include or ["**/*"]
|
|
108
|
+
return list_matching_rel_paths(self._workdir, inc, self._exclude)
|
|
109
|
+
|
|
110
|
+
def run(
|
|
111
|
+
self,
|
|
112
|
+
language: Language,
|
|
113
|
+
code: str,
|
|
114
|
+
*,
|
|
115
|
+
cwd: str | None = None,
|
|
116
|
+
env: dict[str, str] | None = None,
|
|
117
|
+
) -> RunResult:
|
|
118
|
+
wd = (self._workdir / (cwd or ".")).resolve()
|
|
119
|
+
if not str(wd).startswith(str(self._workdir.resolve())):
|
|
120
|
+
raise ValueError("cwd escapes workspace")
|
|
121
|
+
timeout_s = self._timeout_s()
|
|
122
|
+
max_out = self._max_out()
|
|
123
|
+
before = _snapshot_tree(self._workdir, self._glob_scope())
|
|
124
|
+
if language == "bash":
|
|
125
|
+
r = run_bash(code, str(wd), env, timeout_s, max_out)
|
|
126
|
+
elif language == "python":
|
|
127
|
+
r = run_python(code, str(wd), env, timeout_s, max_out)
|
|
128
|
+
elif language == "javascript":
|
|
129
|
+
r = run_javascript(code, str(wd), env, timeout_s, max_out)
|
|
130
|
+
elif language == "sql":
|
|
131
|
+
r = run_sql(code, str(wd), env, timeout_s, max_out)
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unknown language: {language}")
|
|
134
|
+
after_rels = list_matching_rel_paths(self._workdir, ["**/*"], self._exclude)
|
|
135
|
+
r.files = _diff_tree(self._workdir, before, after_rels)
|
|
136
|
+
if self._run_log_enabled:
|
|
137
|
+
self._log_seq += 1
|
|
138
|
+
entry = RunLogEntry(
|
|
139
|
+
id=f"run-{self._log_seq}",
|
|
140
|
+
at=time.time() * 1000,
|
|
141
|
+
language=language,
|
|
142
|
+
code=code,
|
|
143
|
+
cwd=str(wd),
|
|
144
|
+
exit_code=r.exit_code,
|
|
145
|
+
duration_ms=r.duration_ms,
|
|
146
|
+
stdout=r.stdout,
|
|
147
|
+
stderr=r.stderr,
|
|
148
|
+
truncated=r.truncated,
|
|
149
|
+
files=list(r.files),
|
|
150
|
+
)
|
|
151
|
+
self._run_log.append(entry)
|
|
152
|
+
while len(self._run_log) > self._run_log_max:
|
|
153
|
+
self._run_log.pop(0)
|
|
154
|
+
if self._on_run:
|
|
155
|
+
self._on_run(entry)
|
|
156
|
+
return r
|
|
157
|
+
|
|
158
|
+
def get_run_log(self) -> list[RunLogEntry]:
|
|
159
|
+
return list(self._run_log)
|
|
160
|
+
|
|
161
|
+
def clear_run_log(self) -> None:
|
|
162
|
+
self._run_log.clear()
|
|
163
|
+
|
|
164
|
+
def apply(self) -> None:
|
|
165
|
+
if not self._overlay:
|
|
166
|
+
raise RuntimeError("apply() only valid in overlay mode")
|
|
167
|
+
_merge_overlay_to_root(self._workdir, self.root)
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
if self._owns_workdir and self._workdir.exists():
|
|
171
|
+
shutil.rmtree(self._workdir, ignore_errors=True)
|
|
172
|
+
|
|
173
|
+
def serialize(self) -> SerializedRuntime:
|
|
174
|
+
writes: dict[str, str] = {}
|
|
175
|
+
if self._overlay:
|
|
176
|
+
for p in self._workdir.rglob("*"):
|
|
177
|
+
if p.is_file():
|
|
178
|
+
rel = str(p.relative_to(self._workdir)).replace("\\", "/")
|
|
179
|
+
writes[rel] = p.read_text(encoding="utf8", errors="replace")
|
|
180
|
+
return SerializedRuntime(
|
|
181
|
+
version=1,
|
|
182
|
+
root=str(self.root),
|
|
183
|
+
readonly=self._readonly,
|
|
184
|
+
overlay=self._overlay,
|
|
185
|
+
overlay_writes=writes,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def deserialize(data: SerializedRuntime) -> Runtime:
|
|
190
|
+
if data.version != 1:
|
|
191
|
+
raise ValueError("Unsupported version")
|
|
192
|
+
return Runtime(
|
|
193
|
+
data.root,
|
|
194
|
+
overlay=data.overlay,
|
|
195
|
+
readonly=data.readonly,
|
|
196
|
+
files=data.overlay_writes if data.overlay and data.overlay_writes else None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def fs_write(self, rel: str, content: str) -> None:
|
|
200
|
+
if self._readonly:
|
|
201
|
+
raise OSError("read-only")
|
|
202
|
+
p = self._workdir / rel
|
|
203
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
p.write_text(content, encoding="utf8")
|
|
205
|
+
|
|
206
|
+
def fs_read(self, rel: str) -> str:
|
|
207
|
+
return (self._workdir / rel).read_text(encoding="utf8")
|
|
208
|
+
|
|
209
|
+
def as_openai_tool(self) -> dict[str, Any]:
|
|
210
|
+
return {
|
|
211
|
+
"type": "function",
|
|
212
|
+
"function": {
|
|
213
|
+
"name": "execute_code",
|
|
214
|
+
"description": "Execute code in workspace (bash, python, javascript, sql).",
|
|
215
|
+
"parameters": {
|
|
216
|
+
"type": "object",
|
|
217
|
+
"properties": {
|
|
218
|
+
"language": {
|
|
219
|
+
"type": "string",
|
|
220
|
+
"enum": ["bash", "python", "javascript", "sql"],
|
|
221
|
+
},
|
|
222
|
+
"code": {"type": "string"},
|
|
223
|
+
},
|
|
224
|
+
"required": ["language", "code"],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def execute_tool_call(self, args: dict[str, Any]) -> RunResult:
|
|
230
|
+
return self.run(args["language"], args["code"])
|
execpad/types.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
Language = Literal["bash", "python", "javascript", "sql"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FileChange:
|
|
11
|
+
path: str
|
|
12
|
+
type: Literal["created", "modified", "deleted"]
|
|
13
|
+
size: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RunResult:
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
exit_code: int
|
|
21
|
+
duration_ms: float
|
|
22
|
+
files: list[FileChange] = field(default_factory=list)
|
|
23
|
+
truncated: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RunLogEntry:
|
|
28
|
+
id: str
|
|
29
|
+
at: float
|
|
30
|
+
language: Language
|
|
31
|
+
code: str
|
|
32
|
+
cwd: str
|
|
33
|
+
exit_code: int
|
|
34
|
+
duration_ms: float
|
|
35
|
+
stdout: str
|
|
36
|
+
stderr: str
|
|
37
|
+
truncated: bool
|
|
38
|
+
files: list[FileChange]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SerializedRuntime:
|
|
43
|
+
version: int
|
|
44
|
+
root: str
|
|
45
|
+
readonly: bool
|
|
46
|
+
overlay: bool
|
|
47
|
+
overlay_writes: dict[str, str]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: execpad
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-language code execution against a real directory for AI agents
|
|
5
|
+
Author: slaps.dev
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: agents,ai,bash,ci,code-execution,nodejs,openai,python,sandbox,sqlite,subprocess,tool-calling
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# execpad
|
|
14
|
+
|
|
15
|
+
Execute **bash**, **Python**, **JavaScript** (Node), and **SQL** against a **real project directory**—with optional overlay, read-only mode, file-change tracking, and a session run log. Built for **AI agents**, CI, and local tooling.
|
|
16
|
+
|
|
17
|
+
Same design in **TypeScript** (npm) and **Python** (PyPI / local install).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
### JavaScript / TypeScript (npm)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install execpad
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Requires **Node.js ≥ 18**. For SQL on Node, the **`sqlite3`** binary must be on your `PATH`.
|
|
30
|
+
|
|
31
|
+
### Python
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install execpad
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From a clone (editable):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ./python
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires **Python ≥ 3.10**. For SQL in Python, the runtime uses **stdlib `sqlite3`** (no `sqlite3` CLI required).
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
### TypeScript
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { Runtime } from "execpad";
|
|
53
|
+
|
|
54
|
+
const rt = new Runtime("./my-project");
|
|
55
|
+
const r = await rt.run("python", 'print(open("README.md").read()[:80])');
|
|
56
|
+
console.log(r.stdout);
|
|
57
|
+
console.log(r.files); // file changes under the workspace
|
|
58
|
+
rt.close();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Python
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from execpad import Runtime
|
|
65
|
+
|
|
66
|
+
rt = Runtime("./my-project")
|
|
67
|
+
r = rt.run("python", "print(1 + 1)")
|
|
68
|
+
print(r.stdout)
|
|
69
|
+
rt.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
| Capability | Summary |
|
|
77
|
+
|------------|---------|
|
|
78
|
+
| **Languages** | `bash`, `python`, `javascript`, `sql` |
|
|
79
|
+
| **Workspace modes** | Normal, **read-only** (`readonly: true`), or **overlay** (temp copy + `apply()` back to disk) |
|
|
80
|
+
| **File tracking** | `includeGlobs` / `excludeGlobs` (minimatch) scope which paths appear in `RunResult.files` |
|
|
81
|
+
| **Limits** | `timeoutMs`, `maxOutputBytes` per run or via `limits` on the runtime |
|
|
82
|
+
| **Session log** | `getRunLog()`, `clearRunLog()`, `exportRunLogJSON` / `exportRunLogMarkdown`, optional `onRun` |
|
|
83
|
+
| **OpenAI tools** | `asOpenAITool()` + `executeToolCall({ language, code })` (TS); `as_openai_tool` / `execute_tool_call` (Python) |
|
|
84
|
+
| **Persistence** | `serialize()` / `Runtime.deserialize()` for overlay state |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Documentation
|
|
89
|
+
|
|
90
|
+
| Doc | Contents |
|
|
91
|
+
|-----|----------|
|
|
92
|
+
| [Getting started](docs/getting-started.md) | Install, first runs, overlay, read-only |
|
|
93
|
+
| [Configuration](docs/configuration.md) | `RuntimeOptions`, `RunOptions`, globs, limits, run log |
|
|
94
|
+
| [API reference](docs/api-reference.md) | `Runtime`, types, filesystem adapters, exports |
|
|
95
|
+
| [Security](docs/security.md) | Threat model, workspace boundaries, subprocess behavior |
|
|
96
|
+
| [Python notes](docs/python.md) | Node vs Python differences |
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
Apache-2.0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
execpad/__init__.py,sha256=4R48vDjQr9kKnv4lhOlh5AxezQMnLfuzCfK3-4sL6pw,356
|
|
2
|
+
execpad/engines.py,sha256=g-98GzN-cOMsygCVtRbCGvQso4AW9TiEkobQj7o57H4,4189
|
|
3
|
+
execpad/run_log.py,sha256=FSEDM1D5vaedR49ipp-Y8693PgeD1NCFwkluWOIzNTU,1617
|
|
4
|
+
execpad/runtime.py,sha256=6M64Si-hPx7jt5ng7ccKVQS1xCZpp2WYszsgxPQIjII,8061
|
|
5
|
+
execpad/types.py,sha256=AkwwJgrs-hYOR3KccHE4aL-cL9bf-lrrk351sMOAYHw,845
|
|
6
|
+
execpad/extensions/globs.py,sha256=llBOKing9Y6lSGG7Keo8bPQAeKepUUqhJI51nxZuHzo,1008
|
|
7
|
+
execpad-0.1.0.dist-info/METADATA,sha256=0j83N3Jom48LS7W-tvksuSq5OV6J7hqzHYXzhtdXetY,2882
|
|
8
|
+
execpad-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
execpad-0.1.0.dist-info/RECORD,,
|