Skip to content

Commit 5de2338

Browse files
committed
Prepare qitos as pypi package
1 parent 647f5fa commit 5de2338

11 files changed

Lines changed: 783 additions & 1 deletion

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ venv/
103103
ENV/
104104
env.bak/
105105
venv.bak/
106-
106+
!qitos/kit/env/
107+
!qitos/kit/env/**
107108
# Spyder project settings
108109
.spyderproject
109110
.spyproject

qitos/kit/env/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Concrete environment implementations for QitOS."""
2+
3+
from .docker_env import DockerEnv, DockerEnvScheduler
4+
from .host_env import HostEnv
5+
from .repo_env import RepoEnv
6+
from .text_web_env import TextWebEnv, TextWebBrowserOps
7+
8+
__all__ = ["HostEnv", "DockerEnv", "DockerEnvScheduler", "RepoEnv", "TextWebEnv", "TextWebBrowserOps"]
512 Bytes
Binary file not shown.
14.5 KB
Binary file not shown.
14.1 KB
Binary file not shown.
3.49 KB
Binary file not shown.
14.3 KB
Binary file not shown.

qitos/kit/env/docker_env.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""Docker-backed environment and capabilities."""
2+
3+
from __future__ import annotations
4+
5+
import shlex
6+
import subprocess
7+
import threading
8+
from contextlib import contextmanager
9+
from pathlib import Path
10+
from typing import Any, Dict, Iterator, Optional
11+
12+
from qitos.core.env import CommandCapability, FileSystemCapability
13+
from qitos.kit.env.host_env import HostEnv
14+
15+
16+
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess[str]:
17+
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
18+
19+
20+
class DockerCommandCapability(CommandCapability):
21+
def __init__(self, container: str, workdir: str = "/workspace"):
22+
self.container = container
23+
self.workdir = workdir
24+
25+
def run(self, command: str, timeout: int = 30) -> Dict[str, Any]:
26+
if not command or not command.strip():
27+
return {"status": "error", "error": "empty command"}
28+
docker_cmd = [
29+
"docker",
30+
"exec",
31+
"-w",
32+
self.workdir,
33+
self.container,
34+
"sh",
35+
"-lc",
36+
command,
37+
]
38+
try:
39+
r = _run(docker_cmd, timeout=timeout)
40+
return {
41+
"status": "success" if r.returncode == 0 else "partial",
42+
"returncode": r.returncode,
43+
"stdout": r.stdout,
44+
"stderr": r.stderr,
45+
"command": command,
46+
"container": self.container,
47+
}
48+
except Exception as exc:
49+
return {"status": "error", "error": str(exc), "command": command, "container": self.container}
50+
51+
52+
class DockerFSCapability(FileSystemCapability):
53+
def __init__(self, container: str, workdir: str = "/workspace"):
54+
self.container = container
55+
self.workdir = workdir.rstrip("/") or "/workspace"
56+
self.cmd = DockerCommandCapability(container=container, workdir=workdir)
57+
58+
def read_text(self, path: str) -> str:
59+
inner = self._inner_path(path)
60+
result = self.cmd.run(f"cat {shlex.quote(inner)}")
61+
if result.get("returncode", 1) != 0:
62+
raise RuntimeError(str(result.get("stderr", "failed to read file")))
63+
return str(result.get("stdout", ""))
64+
65+
def write_text(self, path: str, content: str) -> None:
66+
inner = self._inner_path(path)
67+
encoded = content.replace("\\", "\\\\").replace("'", "'\"'\"'")
68+
cmd = f"mkdir -p {shlex.quote(str(Path(inner).parent))} && printf '%s' '{encoded}' > {shlex.quote(inner)}"
69+
result = self.cmd.run(cmd)
70+
if result.get("returncode", 1) != 0:
71+
raise RuntimeError(str(result.get("stderr", "failed to write file")))
72+
73+
def list_files(self, path: str = ".", limit: int = 200) -> list[str]:
74+
inner = self._inner_path(path)
75+
cmd = f"find {shlex.quote(inner)} -type f | head -n {int(limit)}"
76+
result = self.cmd.run(cmd)
77+
if result.get("returncode", 1) != 0:
78+
return []
79+
prefix = self.workdir.rstrip("/") + "/"
80+
out: list[str] = []
81+
for line in str(result.get("stdout", "")).splitlines():
82+
line = line.strip()
83+
if not line:
84+
continue
85+
out.append(line[len(prefix) :] if line.startswith(prefix) else line)
86+
return out
87+
88+
def exists(self, path: str) -> bool:
89+
inner = self._inner_path(path)
90+
result = self.cmd.run(f"test -e {shlex.quote(inner)}")
91+
return int(result.get("returncode", 1)) == 0
92+
93+
def _inner_path(self, path: str) -> str:
94+
rel = path.lstrip("/")
95+
return f"{self.workdir}/{rel}" if rel else self.workdir
96+
97+
98+
class DockerEnv(HostEnv):
99+
"""HostEnv-compatible action interpreter executed inside Docker.
100+
101+
Supports two modes:
102+
1. Attach existing container: pass `container`.
103+
2. Auto-create ephemeral container: pass `image` and set `auto_create=True`.
104+
"""
105+
106+
name = "docker_env"
107+
version = "1.1"
108+
109+
def __init__(
110+
self,
111+
container: Optional[str] = None,
112+
workspace_root: str = "/workspace",
113+
*,
114+
image: Optional[str] = None,
115+
host_workspace: Optional[str] = None,
116+
auto_create: bool = False,
117+
remove_on_close: bool = False,
118+
network: Optional[str] = None,
119+
extra_run_args: Optional[list[str]] = None,
120+
create_timeout: int = 60,
121+
):
122+
self.container = str(container).strip() if container else ""
123+
self.container_workspace = workspace_root
124+
self.image = str(image or "").strip()
125+
self.host_workspace = str(host_workspace).strip() if host_workspace else ""
126+
self.auto_create = bool(auto_create)
127+
self.remove_on_close = bool(remove_on_close)
128+
self.network = network
129+
self.extra_run_args = list(extra_run_args or [])
130+
self.create_timeout = int(create_timeout)
131+
self._created_here = False
132+
133+
if not self.container and self.auto_create:
134+
self.container = f"qitos_{Path(self.host_workspace or 'workspace').name}_{threading.get_ident()}"
135+
136+
fs = DockerFSCapability(container=self.container or "", workdir=workspace_root)
137+
cmd = DockerCommandCapability(container=self.container or "", workdir=workspace_root)
138+
super().__init__(workspace_root=workspace_root, fs=fs, cmd=cmd)
139+
140+
def setup(self, task: Any = None, workspace: Optional[str] = None, **kwargs: Any) -> None:
141+
if workspace and not self.host_workspace:
142+
self.host_workspace = str(Path(workspace).resolve())
143+
if self.auto_create:
144+
self._ensure_container()
145+
if not self.container:
146+
raise ValueError("DockerEnv requires `container` or `auto_create=True` with `image`")
147+
148+
self.fs = DockerFSCapability(container=self.container, workdir=self.container_workspace)
149+
self.cmd = DockerCommandCapability(container=self.container, workdir=self.container_workspace)
150+
151+
def reset(self, task: Any = None, workspace: Optional[str] = None, **kwargs: Any):
152+
self.setup(task=task, workspace=workspace, **kwargs)
153+
self.workspace_root = workspace or self.container_workspace
154+
self._last_error = None
155+
return self.observe(state=None)
156+
157+
def health_check(self) -> Dict[str, Any]:
158+
if not self.container:
159+
return {"ok": False, "message": "container is empty"}
160+
161+
inspect = _run(["docker", "inspect", self.container], timeout=20)
162+
if inspect.returncode != 0:
163+
return {
164+
"ok": False,
165+
"message": "docker inspect failed",
166+
"container": self.container,
167+
"stderr": inspect.stderr,
168+
}
169+
170+
probe = self.cmd.run("pwd", timeout=10)
171+
if int(probe.get("returncode", 1)) != 0:
172+
return {
173+
"ok": False,
174+
"message": "docker exec probe failed",
175+
"container": self.container,
176+
"stderr": probe.get("stderr", ""),
177+
}
178+
return {"ok": True, "container": self.container, "workspace_root": self.workspace_root}
179+
180+
def close(self) -> None:
181+
if not self.container:
182+
return
183+
if self.remove_on_close and self._created_here:
184+
_run(["docker", "rm", "-f", self.container], timeout=30)
185+
186+
def _ensure_container(self) -> None:
187+
if not self.container:
188+
raise ValueError("auto_create needs container name")
189+
190+
inspect = _run(["docker", "inspect", self.container], timeout=20)
191+
if inspect.returncode == 0:
192+
start = _run(["docker", "start", self.container], timeout=20)
193+
if start.returncode != 0:
194+
raise RuntimeError(f"Failed to start container {self.container}: {start.stderr}")
195+
return
196+
197+
if not self.image:
198+
raise ValueError("auto_create requires `image`")
199+
200+
run_cmd = ["docker", "run", "-d", "--name", self.container]
201+
if self.network:
202+
run_cmd += ["--network", self.network]
203+
204+
mount_src = ""
205+
if self.host_workspace:
206+
host = str(Path(self.host_workspace).resolve())
207+
mount_src = host
208+
run_cmd += ["-v", f"{host}:{self.container_workspace}"]
209+
210+
if self.extra_run_args:
211+
run_cmd += list(self.extra_run_args)
212+
213+
run_cmd += [self.image, "sh", "-lc", "while true; do sleep 3600; done"]
214+
proc = _run(run_cmd, timeout=self.create_timeout)
215+
if proc.returncode != 0:
216+
raise RuntimeError(f"Failed to create container {self.container}: {proc.stderr}")
217+
self._created_here = True
218+
219+
220+
class DockerEnvScheduler:
221+
"""Simple bounded scheduler for per-task DockerEnv creation.
222+
223+
Useful for benchmark batch runs to control concurrent docker containers.
224+
"""
225+
226+
def __init__(self, max_active: int = 1):
227+
self.max_active = max(1, int(max_active))
228+
self._sem = threading.Semaphore(self.max_active)
229+
230+
@contextmanager
231+
def allocate(
232+
self,
233+
*,
234+
image: str,
235+
host_workspace: str,
236+
workspace_root: str = "/workspace",
237+
network: Optional[str] = None,
238+
extra_run_args: Optional[list[str]] = None,
239+
) -> Iterator[DockerEnv]:
240+
self._sem.acquire()
241+
env = DockerEnv(
242+
workspace_root=workspace_root,
243+
image=image,
244+
host_workspace=host_workspace,
245+
auto_create=True,
246+
remove_on_close=True,
247+
network=network,
248+
extra_run_args=extra_run_args,
249+
)
250+
try:
251+
env.setup(workspace=host_workspace)
252+
yield env
253+
finally:
254+
try:
255+
env.close()
256+
finally:
257+
self._sem.release()
258+
259+
260+
__all__ = [
261+
"DockerCommandCapability",
262+
"DockerFSCapability",
263+
"DockerEnv",
264+
"DockerEnvScheduler",
265+
]

0 commit comments

Comments
 (0)