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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any