gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
gpu4agents/__init__.py
ADDED
gpu4agents/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent integrations: the local MCP server, the bundled skill and their installer."""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`gpu agent install`: put the skill and the MCP server where agents look for them.
|
|
2
|
+
|
|
3
|
+
One skill copy, in ``.claude/skills/gpu/`` (or ``~/.claude/skills/gpu/``),
|
|
4
|
+
serves both Claude Code and Cursor, which also reads Claude's skill
|
|
5
|
+
directories. The MCP server is registered per client:
|
|
6
|
+
|
|
7
|
+
- claude: ``.mcp.json`` in the project; with --global, ``claude mcp add -s user``
|
|
8
|
+
- cursor: ``.cursor/mcp.json`` in the project, or ``~/.cursor/mcp.json``
|
|
9
|
+
- desktop: Claude Desktop's ``claude_desktop_config.json``, pinned to this
|
|
10
|
+
project, plus a zip of the skill to upload in Settings
|
|
11
|
+
|
|
12
|
+
Existing config files are merged, never replaced: only the ``gpu`` server
|
|
13
|
+
entry is written.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
from ..core.errors import ConfigError
|
|
29
|
+
from ..core.paths import gpu_home
|
|
30
|
+
|
|
31
|
+
SERVER_NAME = "gpu"
|
|
32
|
+
SKILL_NAME = "gpu"
|
|
33
|
+
TARGETS = ("claude", "cursor", "desktop")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def skill_source() -> Path:
|
|
37
|
+
return Path(__file__).parent / "skill" / "SKILL.md"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def skill_text() -> str:
|
|
41
|
+
return skill_source().read_text()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gpu_command(portable: bool) -> list[str]:
|
|
45
|
+
"""How a client starts `gpu`.
|
|
46
|
+
|
|
47
|
+
A project file that may be committed says plain ``gpu`` when it is on
|
|
48
|
+
PATH; anything machine-local uses an absolute path, because GUI apps
|
|
49
|
+
(Claude Desktop, Cursor) do not get the shell's PATH.
|
|
50
|
+
"""
|
|
51
|
+
found = shutil.which("gpu")
|
|
52
|
+
if found and portable:
|
|
53
|
+
return ["gpu"]
|
|
54
|
+
if found:
|
|
55
|
+
return [str(Path(found).resolve())]
|
|
56
|
+
return [sys.executable, "-m", "gpu4agents"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def desktop_config_path() -> Path:
|
|
60
|
+
system = platform.system()
|
|
61
|
+
if system == "Darwin":
|
|
62
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
63
|
+
if system == "Windows":
|
|
64
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
|
|
65
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
66
|
+
return (Path(base) if base else Path.home() / ".config") / "Claude" / "claude_desktop_config.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def merge_server(path: Path, entry: dict[str, Any]) -> str:
|
|
70
|
+
"""Write ``mcpServers.gpu`` into a JSON config. Returns created/updated/unchanged."""
|
|
71
|
+
data: dict[str, Any] = {}
|
|
72
|
+
if path.is_file():
|
|
73
|
+
text = path.read_text().strip()
|
|
74
|
+
if text:
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(text)
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
raise ConfigError(f"{path} is not valid JSON ({exc}); fix it or add the gpu server by hand") from exc
|
|
79
|
+
if not isinstance(data, dict):
|
|
80
|
+
raise ConfigError(f"{path} does not hold a JSON object")
|
|
81
|
+
servers = data.setdefault("mcpServers", {})
|
|
82
|
+
if not isinstance(servers, dict):
|
|
83
|
+
raise ConfigError(f"{path}: mcpServers is not an object")
|
|
84
|
+
if servers.get(SERVER_NAME) == entry:
|
|
85
|
+
return "unchanged"
|
|
86
|
+
action = "updated" if SERVER_NAME in servers else "created"
|
|
87
|
+
servers[SERVER_NAME] = entry
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
90
|
+
return action
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_skill(directory: Path) -> str:
|
|
94
|
+
target = directory / SKILL_NAME / "SKILL.md"
|
|
95
|
+
text = skill_text()
|
|
96
|
+
if target.is_file() and target.read_text() == text:
|
|
97
|
+
return "unchanged"
|
|
98
|
+
action = "updated" if target.exists() else "created"
|
|
99
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
target.write_text(text)
|
|
101
|
+
return action
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_skill_zip(path: Path) -> str:
|
|
105
|
+
"""The skill as Claude Desktop / claude.ai expect an upload: a folder in a zip."""
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
108
|
+
archive.writestr(f"{SKILL_NAME}/SKILL.md", skill_text())
|
|
109
|
+
return "created"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install(
|
|
113
|
+
targets: list[str],
|
|
114
|
+
project_dir: Path,
|
|
115
|
+
global_scope: bool = False,
|
|
116
|
+
run=subprocess.run,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Install for each target. ``run`` is a seam for tests (``claude mcp add``)."""
|
|
119
|
+
unknown = [t for t in targets if t not in TARGETS]
|
|
120
|
+
if unknown:
|
|
121
|
+
raise ConfigError(f"unknown target {', '.join(unknown)} (choose from {', '.join(TARGETS)})")
|
|
122
|
+
project_dir = project_dir.resolve()
|
|
123
|
+
done: list[dict[str, Any]] = []
|
|
124
|
+
notes: list[str] = []
|
|
125
|
+
|
|
126
|
+
if "claude" in targets or "cursor" in targets:
|
|
127
|
+
skills = (Path.home() if global_scope else project_dir) / ".claude" / "skills"
|
|
128
|
+
done.append({"what": "skill (Claude Code, Cursor)", "path": str(skills / SKILL_NAME / "SKILL.md"), "action": write_skill(skills)})
|
|
129
|
+
|
|
130
|
+
if "claude" in targets:
|
|
131
|
+
if global_scope:
|
|
132
|
+
command = gpu_command(portable=False)
|
|
133
|
+
argv = ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", *command, "mcp"]
|
|
134
|
+
if shutil.which("claude"):
|
|
135
|
+
# `add` refuses an existing name; replace it so a reinstall picks up a new path.
|
|
136
|
+
run(["claude", "mcp", "remove", "--scope", "user", SERVER_NAME], capture_output=True, text=True)
|
|
137
|
+
proc = run(argv, capture_output=True, text=True)
|
|
138
|
+
if proc.returncode != 0:
|
|
139
|
+
raise ConfigError(f"`{' '.join(argv)}` failed: {(proc.stderr or proc.stdout).strip()}")
|
|
140
|
+
done.append({"what": "MCP server (Claude Code, user scope)", "path": "claude mcp", "action": "created"})
|
|
141
|
+
else:
|
|
142
|
+
notes.append(f"claude CLI not found; register the server with: {' '.join(argv)}")
|
|
143
|
+
else:
|
|
144
|
+
command = gpu_command(portable=True)
|
|
145
|
+
entry = {"command": command[0], "args": [*command[1:], "mcp"]}
|
|
146
|
+
path = project_dir / ".mcp.json"
|
|
147
|
+
done.append({"what": "MCP server (Claude Code)", "path": str(path), "action": merge_server(path, entry)})
|
|
148
|
+
notes.append("Claude Code asks once to approve the project's .mcp.json servers")
|
|
149
|
+
|
|
150
|
+
if "cursor" in targets:
|
|
151
|
+
command = gpu_command(portable=not global_scope)
|
|
152
|
+
entry = {
|
|
153
|
+
"type": "stdio",
|
|
154
|
+
"command": command[0],
|
|
155
|
+
"args": [*command[1:], "mcp", "--project", "${workspaceFolder}"],
|
|
156
|
+
}
|
|
157
|
+
path = (Path.home() if global_scope else project_dir) / ".cursor" / "mcp.json"
|
|
158
|
+
done.append({"what": "MCP server (Cursor)", "path": str(path), "action": merge_server(path, entry)})
|
|
159
|
+
|
|
160
|
+
if "desktop" in targets:
|
|
161
|
+
command = gpu_command(portable=False)
|
|
162
|
+
entry = {"command": command[0], "args": [*command[1:], "mcp", "--project", str(project_dir)]}
|
|
163
|
+
path = desktop_config_path()
|
|
164
|
+
done.append({"what": "MCP server (Claude Desktop)", "path": str(path), "action": merge_server(path, entry)})
|
|
165
|
+
archive = gpu_home() / "gpu-skill.zip"
|
|
166
|
+
done.append({"what": "skill zip (Claude Desktop)", "path": str(archive), "action": write_skill_zip(archive)})
|
|
167
|
+
notes.append(f"Claude Desktop: restart it, then upload {archive} under Settings > Capabilities > Skills")
|
|
168
|
+
notes.append(
|
|
169
|
+
"Claude Desktop does not get your shell's environment: keep VAST_API_KEY in the "
|
|
170
|
+
"project .env or ~/.gpu/credentials"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return {"installed": done, "notes": notes}
|
gpu4agents/agent/jobs.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""`gpu setup` in the background, for callers that cannot block for 15 minutes.
|
|
2
|
+
|
|
3
|
+
An MCP tool call times out long before a first setup finishes, so the MCP
|
|
4
|
+
server starts setup as a detached job and reports on it when asked again.
|
|
5
|
+
The job outlives the server: a client that disconnects mid-setup leaves a
|
|
6
|
+
machine that either finishes booting (and is then stopped by its watchdog
|
|
7
|
+
when nobody uses it) or is destroyed by setup's own failure cleanup.
|
|
8
|
+
|
|
9
|
+
Files, under the project's ``.gpu/jobs/``:
|
|
10
|
+
|
|
11
|
+
- ``setup.json`` pid, argv and start time
|
|
12
|
+
- ``setup.out`` the command's stdout (one JSON document with --json)
|
|
13
|
+
- ``setup.err`` its stderr (progress is suppressed; errors land here)
|
|
14
|
+
- ``setup.rc`` the exit code, written last, when the command ends
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional, Sequence
|
|
27
|
+
|
|
28
|
+
from ..core.paths import project_state_dir
|
|
29
|
+
|
|
30
|
+
JOB_DIRNAME = "jobs"
|
|
31
|
+
ERROR_TAIL_LINES = 40
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _alive(pid: int) -> bool:
|
|
35
|
+
if os.name == "nt": # signal 0 is CTRL_C_EVENT on Windows; trust the rc file
|
|
36
|
+
return True
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
except ProcessLookupError:
|
|
40
|
+
return False
|
|
41
|
+
except PermissionError:
|
|
42
|
+
return True
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tail(path: Path, lines: int) -> list[str]:
|
|
47
|
+
try:
|
|
48
|
+
text = path.read_text(errors="replace")
|
|
49
|
+
except OSError:
|
|
50
|
+
return []
|
|
51
|
+
return [line for line in text.splitlines() if line.strip()][-lines:]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Job:
|
|
55
|
+
"""One named background command per project (only ``setup`` today)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, project_dir: Path, name: str = "setup") -> None:
|
|
58
|
+
self.dir = project_state_dir(Path(project_dir)) / JOB_DIRNAME
|
|
59
|
+
self.name = name
|
|
60
|
+
self.meta_path = self.dir / f"{name}.json"
|
|
61
|
+
self.out_path = self.dir / f"{name}.out"
|
|
62
|
+
self.err_path = self.dir / f"{name}.err"
|
|
63
|
+
self.rc_path = self.dir / f"{name}.rc"
|
|
64
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
65
|
+
|
|
66
|
+
def meta(self) -> Optional[dict[str, Any]]:
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(self.meta_path.read_text())
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def exit_code(self) -> Optional[int]:
|
|
73
|
+
try:
|
|
74
|
+
return int(self.rc_path.read_text().strip())
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def running(self) -> bool:
|
|
79
|
+
meta = self.meta()
|
|
80
|
+
if meta is None or self.exit_code() is not None:
|
|
81
|
+
return False
|
|
82
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
83
|
+
return False # reaped here: our own child never lingers as a zombie
|
|
84
|
+
return _alive(int(meta["pid"]))
|
|
85
|
+
|
|
86
|
+
def start(self, argv: Sequence[str], cwd: Path) -> dict[str, Any]:
|
|
87
|
+
"""Start ``argv`` detached. The caller checks ``running()`` first."""
|
|
88
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
for path in (self.out_path, self.err_path, self.rc_path):
|
|
90
|
+
path.unlink(missing_ok=True)
|
|
91
|
+
runner = [sys.executable, "-m", "gpu4agents.agent.jobs", str(self.dir), self.name, "--", *argv]
|
|
92
|
+
kwargs: dict[str, Any] = {}
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
|
95
|
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
kwargs["start_new_session"] = True
|
|
99
|
+
self._proc = subprocess.Popen(
|
|
100
|
+
runner,
|
|
101
|
+
cwd=str(cwd),
|
|
102
|
+
stdin=subprocess.DEVNULL,
|
|
103
|
+
stdout=subprocess.DEVNULL,
|
|
104
|
+
stderr=subprocess.DEVNULL,
|
|
105
|
+
**kwargs,
|
|
106
|
+
)
|
|
107
|
+
meta = {"pid": self._proc.pid, "argv": list(argv), "started_at": time.time()}
|
|
108
|
+
self.meta_path.write_text(json.dumps(meta))
|
|
109
|
+
return meta
|
|
110
|
+
|
|
111
|
+
def wait(self, seconds: float, poll: float = 2.0) -> bool:
|
|
112
|
+
"""Wait up to ``seconds`` for the job to end. True when it has."""
|
|
113
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
114
|
+
while self.running():
|
|
115
|
+
if time.monotonic() >= deadline:
|
|
116
|
+
return False
|
|
117
|
+
time.sleep(min(poll, max(0.05, deadline - time.monotonic())))
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def cancel(self, timeout: float = 120.0) -> bool:
|
|
121
|
+
"""Interrupt a running job like Ctrl-C and wait for it to clean up.
|
|
122
|
+
|
|
123
|
+
``gpu setup`` destroys the machine it was booting when interrupted, so
|
|
124
|
+
a cancelled setup leaves nothing billing. True when the job has ended.
|
|
125
|
+
"""
|
|
126
|
+
meta = self.meta()
|
|
127
|
+
if meta is None or not self.running():
|
|
128
|
+
return True
|
|
129
|
+
pid = int(meta["pid"])
|
|
130
|
+
try:
|
|
131
|
+
if os.name == "nt":
|
|
132
|
+
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
|
133
|
+
else:
|
|
134
|
+
os.killpg(pid, signal.SIGINT) # the runner leads the group (start_new_session)
|
|
135
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
136
|
+
return not self.running()
|
|
137
|
+
return self.wait(timeout, poll=1.0)
|
|
138
|
+
|
|
139
|
+
def report(self) -> dict[str, Any]:
|
|
140
|
+
"""``status`` is none, running, succeeded, failed or lost (died without a code)."""
|
|
141
|
+
meta = self.meta()
|
|
142
|
+
if meta is None:
|
|
143
|
+
return {"status": "none"}
|
|
144
|
+
started = float(meta.get("started_at") or 0)
|
|
145
|
+
report: dict[str, Any] = {
|
|
146
|
+
"pid": meta.get("pid"),
|
|
147
|
+
"started_at": started,
|
|
148
|
+
"elapsed_s": round(time.time() - started, 1) if started else None,
|
|
149
|
+
}
|
|
150
|
+
if self.running():
|
|
151
|
+
report["status"] = "running"
|
|
152
|
+
return report
|
|
153
|
+
code = self.exit_code()
|
|
154
|
+
if code is None:
|
|
155
|
+
report["status"] = "lost"
|
|
156
|
+
else:
|
|
157
|
+
report["status"] = "succeeded" if code == 0 else "failed"
|
|
158
|
+
report["exit_code"] = code
|
|
159
|
+
report["finished_at"] = self.rc_path.stat().st_mtime
|
|
160
|
+
report["elapsed_s"] = round(report["finished_at"] - started, 1) if started else None
|
|
161
|
+
try:
|
|
162
|
+
report["result"] = json.loads(self.out_path.read_text())
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
pass
|
|
165
|
+
if report["status"] != "succeeded":
|
|
166
|
+
report["stderr_tail"] = _tail(self.err_path, ERROR_TAIL_LINES)
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _main(argv: list[str]) -> int:
|
|
171
|
+
"""``python -m gpu4agents.agent.jobs DIR NAME -- CMD...``: run CMD, record its exit code."""
|
|
172
|
+
directory, name = Path(argv[0]), argv[1]
|
|
173
|
+
command = argv[3:] if len(argv) > 2 and argv[2] == "--" else argv[2:]
|
|
174
|
+
# A cancel sends SIGINT to the whole group. The runner must outlive it to
|
|
175
|
+
# record the exit code, and must not kill the command while it cleans up.
|
|
176
|
+
# A Python handler (not SIG_IGN) resets to the default in the child on exec.
|
|
177
|
+
signal.signal(signal.SIGINT, lambda signum, frame: None)
|
|
178
|
+
with open(directory / f"{name}.out", "wb") as out, open(directory / f"{name}.err", "wb") as err:
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.Popen(command, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
err.write(f"error: could not start {command[0]}: {exc}\n".encode())
|
|
183
|
+
code = 127
|
|
184
|
+
else:
|
|
185
|
+
code = proc.wait()
|
|
186
|
+
tmp = directory / f"{name}.rc.tmp"
|
|
187
|
+
tmp.write_text(str(code))
|
|
188
|
+
tmp.replace(directory / f"{name}.rc")
|
|
189
|
+
return code
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(_main(sys.argv[1:]))
|