coala-runtime 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.
- coala_runtime/__init__.py +3 -0
- coala_runtime/__main__.py +56 -0
- coala_runtime/runtime/__init__.py +1 -0
- coala_runtime/runtime/container_manager.py +207 -0
- coala_runtime/runtime/docker_images.py +123 -0
- coala_runtime/runtime/executor_base.py +612 -0
- coala_runtime/runtime/file_handler.py +84 -0
- coala_runtime/server.py +692 -0
- coala_runtime/tools/__init__.py +1 -0
- coala_runtime/tools/python_executor.py +207 -0
- coala_runtime/tools/r_executor.py +185 -0
- coala_runtime/utils/__init__.py +1 -0
- coala_runtime/utils/output_parser.py +124 -0
- coala_runtime-0.1.0.dist-info/METADATA +164 -0
- coala_runtime-0.1.0.dist-info/RECORD +17 -0
- coala_runtime-0.1.0.dist-info/WHEEL +4 -0
- coala_runtime-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Entry point for running the MCP server."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from coala_runtime.server import mcp
|
|
6
|
+
from coala_runtime.runtime.docker_images import ensure_images
|
|
7
|
+
|
|
8
|
+
_HELP = """\
|
|
9
|
+
usage: coala-runtime [--build]
|
|
10
|
+
|
|
11
|
+
Run the Coala Runtime MCP server over stdio (tools: coala_python_executor, coala_r_executor).
|
|
12
|
+
|
|
13
|
+
--build Build executor Docker images from the repo (requires clone + docker/)
|
|
14
|
+
|
|
15
|
+
Configure an MCP client (e.g. Cursor ~/.cursor/mcp.json), replace cwd with your repo path:
|
|
16
|
+
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"coala-runtime": {
|
|
19
|
+
"command": "coala-runtime",
|
|
20
|
+
"args": [],
|
|
21
|
+
"cwd": "/path/to/coala-runtime"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
With uv (no global install):
|
|
26
|
+
|
|
27
|
+
"command": "uv",
|
|
28
|
+
"args": ["run", "--directory", "/path/to/coala-runtime", "coala-runtime"]
|
|
29
|
+
|
|
30
|
+
More options: MCP_CONFIG.md in the coala-runtime repository.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Shown on every start (stderr only; stdout is reserved for MCP JSON-RPC).
|
|
34
|
+
_MCP_START_HINT = """\
|
|
35
|
+
Coala Runtime MCP (stdio). Client config: use "command": "coala-runtime", "args": [], "cwd": "<repo>".
|
|
36
|
+
Alternatives: python -m coala_runtime, or uv run --directory <repo> coala-runtime. Details: MCP_CONFIG.md.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
"""Run the MCP server (used by the coala-runtime console script)."""
|
|
42
|
+
if "-h" in sys.argv or "--help" in sys.argv:
|
|
43
|
+
print(_HELP, end="", file=sys.stdout)
|
|
44
|
+
raise SystemExit(0)
|
|
45
|
+
|
|
46
|
+
build = "--build" in sys.argv
|
|
47
|
+
if build:
|
|
48
|
+
sys.argv = [a for a in sys.argv if a != "--build"]
|
|
49
|
+
|
|
50
|
+
print(_MCP_START_HINT, file=sys.stderr, flush=True)
|
|
51
|
+
ensure_images(build=build)
|
|
52
|
+
mcp.run()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runtime components for containerized script execution."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Container manager for Docker operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
import docker
|
|
8
|
+
from docker.errors import DockerException, ImageNotFound
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContainerManager:
|
|
14
|
+
"""Manages Docker container lifecycle and operations."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, docker_client: Optional[docker.DockerClient] = None):
|
|
17
|
+
"""Initialize container manager.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
docker_client: Optional Docker client. If None, creates a new client.
|
|
21
|
+
"""
|
|
22
|
+
self.client = docker_client or docker.from_env()
|
|
23
|
+
self.containers: Dict[str, docker.models.containers.Container] = {}
|
|
24
|
+
|
|
25
|
+
async def ensure_image(self, image: str) -> None:
|
|
26
|
+
"""Pull ``image`` from a registry if it is not already present locally.
|
|
27
|
+
|
|
28
|
+
``docker.containers.create`` fails with 404 when the image only exists on a
|
|
29
|
+
remote registry (e.g. quay.io/biocontainers/...). This matches ``docker run``
|
|
30
|
+
behavior, which pulls missing images by default.
|
|
31
|
+
"""
|
|
32
|
+
loop = asyncio.get_event_loop()
|
|
33
|
+
|
|
34
|
+
def _ensure() -> None:
|
|
35
|
+
try:
|
|
36
|
+
self.client.images.get(image)
|
|
37
|
+
logger.debug("Docker image present locally: %s", image)
|
|
38
|
+
except ImageNotFound:
|
|
39
|
+
logger.info("Docker image not found locally, pulling: %s", image)
|
|
40
|
+
self.client.images.pull(image)
|
|
41
|
+
logger.info("Pulled Docker image: %s", image)
|
|
42
|
+
|
|
43
|
+
await loop.run_in_executor(None, _ensure)
|
|
44
|
+
|
|
45
|
+
async def create_container(
|
|
46
|
+
self,
|
|
47
|
+
image: str,
|
|
48
|
+
command: Optional[str] = None,
|
|
49
|
+
volumes: Optional[Dict[str, Dict[str, str]]] = None,
|
|
50
|
+
working_dir: str = "/workspace",
|
|
51
|
+
environment: Optional[Dict[str, str]] = None,
|
|
52
|
+
name: Optional[str] = None,
|
|
53
|
+
) -> docker.models.containers.Container:
|
|
54
|
+
"""Create a Docker container.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
image: Docker image name
|
|
58
|
+
command: Command to run in container
|
|
59
|
+
volumes: Volume bind mounts (host_path: {bind: container_path, mode: mode})
|
|
60
|
+
working_dir: Working directory in container
|
|
61
|
+
environment: Environment variables
|
|
62
|
+
name: Optional container name
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Created container object
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
await self.ensure_image(image)
|
|
69
|
+
# Run in executor to avoid blocking
|
|
70
|
+
loop = asyncio.get_event_loop()
|
|
71
|
+
container = await loop.run_in_executor(
|
|
72
|
+
None,
|
|
73
|
+
lambda: self.client.containers.create(
|
|
74
|
+
image=image,
|
|
75
|
+
command=command if command else None,
|
|
76
|
+
volumes=volumes or {},
|
|
77
|
+
working_dir=working_dir,
|
|
78
|
+
environment=environment or {},
|
|
79
|
+
name=name,
|
|
80
|
+
detach=True,
|
|
81
|
+
stdin_open=True,
|
|
82
|
+
tty=True,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
container_id = container.id
|
|
86
|
+
self.containers[container_id] = container
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Created container {container_id[:12]} from image {image} with command: {command}"
|
|
89
|
+
)
|
|
90
|
+
return container
|
|
91
|
+
except DockerException as e:
|
|
92
|
+
logger.error(f"Failed to create container: {e}")
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
async def start_container(self, container: docker.models.containers.Container) -> None:
|
|
96
|
+
"""Start a container.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
container: Container to start
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
loop = asyncio.get_event_loop()
|
|
103
|
+
await loop.run_in_executor(None, container.start)
|
|
104
|
+
# Refresh container to get updated status
|
|
105
|
+
container.reload()
|
|
106
|
+
logger.info(f"Started container {container.id[:12]} (status: {container.status})")
|
|
107
|
+
except DockerException as e:
|
|
108
|
+
logger.error(f"Failed to start container: {e}")
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
async def exec_command(
|
|
112
|
+
self,
|
|
113
|
+
container: docker.models.containers.Container,
|
|
114
|
+
command: Union[str, Sequence[str]],
|
|
115
|
+
workdir: Optional[str] = None,
|
|
116
|
+
environment: Optional[Dict[str, str]] = None,
|
|
117
|
+
) -> tuple[int, bytes, bytes]:
|
|
118
|
+
"""Execute a command in a running container.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
container: Container to execute command in
|
|
122
|
+
command: Shell string or argv list (list avoids shell quoting issues)
|
|
123
|
+
workdir: Working directory for command
|
|
124
|
+
environment: Environment variables
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Tuple of (exit_code, stdout, stderr)
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
# Refresh container status to ensure it's running
|
|
131
|
+
container.reload()
|
|
132
|
+
if container.status != "running":
|
|
133
|
+
raise DockerException(
|
|
134
|
+
f"Container {container.id[:12]} is not running (status: {container.status})"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
loop = asyncio.get_event_loop()
|
|
138
|
+
exec_result = await loop.run_in_executor(
|
|
139
|
+
None,
|
|
140
|
+
lambda: container.exec_run(
|
|
141
|
+
command,
|
|
142
|
+
workdir=workdir,
|
|
143
|
+
environment=environment or {},
|
|
144
|
+
stdout=True,
|
|
145
|
+
stderr=True,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
exit_code = exec_result.exit_code
|
|
149
|
+
output = exec_result.output
|
|
150
|
+
# Split stdout and stderr (Docker API returns combined output)
|
|
151
|
+
# For simplicity, we'll return the combined output as stdout
|
|
152
|
+
# and empty stderr (actual stderr is in container logs)
|
|
153
|
+
return exit_code, output, b""
|
|
154
|
+
except DockerException as e:
|
|
155
|
+
logger.error(f"Failed to execute command in container: {e}")
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
async def get_logs(
|
|
159
|
+
self, container: docker.models.containers.Container, tail: int = 1000
|
|
160
|
+
) -> str:
|
|
161
|
+
"""Get container logs.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
container: Container to get logs from
|
|
165
|
+
tail: Number of lines to retrieve
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Container logs as string
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
loop = asyncio.get_event_loop()
|
|
172
|
+
logs_bytes = await loop.run_in_executor(
|
|
173
|
+
None, lambda: container.logs(tail=tail, stdout=True, stderr=True)
|
|
174
|
+
)
|
|
175
|
+
return logs_bytes.decode("utf-8", errors="replace")
|
|
176
|
+
except DockerException as e:
|
|
177
|
+
logger.error(f"Failed to get container logs: {e}")
|
|
178
|
+
return f"Error retrieving logs: {e}"
|
|
179
|
+
|
|
180
|
+
async def remove_container(
|
|
181
|
+
self, container: docker.models.containers.Container, force: bool = True
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Remove a container.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
container: Container to remove
|
|
187
|
+
force: Force removal even if running
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
container_id = container.id
|
|
191
|
+
loop = asyncio.get_event_loop()
|
|
192
|
+
await loop.run_in_executor(None, lambda: container.remove(force=force))
|
|
193
|
+
if container_id in self.containers:
|
|
194
|
+
del self.containers[container_id]
|
|
195
|
+
logger.info(f"Removed container {container_id[:12]}")
|
|
196
|
+
except DockerException as e:
|
|
197
|
+
logger.warning(f"Failed to remove container: {e}")
|
|
198
|
+
|
|
199
|
+
async def cleanup_all(self) -> None:
|
|
200
|
+
"""Remove all managed containers."""
|
|
201
|
+
for container in list(self.containers.values()):
|
|
202
|
+
await self.remove_container(container)
|
|
203
|
+
|
|
204
|
+
def __del__(self):
|
|
205
|
+
"""Cleanup on deletion."""
|
|
206
|
+
# Note: This is a fallback, but async cleanup should be done explicitly
|
|
207
|
+
pass
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Ensure required Docker images exist; pull from registry or build locally."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import docker
|
|
8
|
+
from docker.errors import DockerException, ImageNotFound
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Default images used by executors; pulled if missing
|
|
13
|
+
PULL_IMAGES = [
|
|
14
|
+
"hubentu/coala-runtime-python:latest",
|
|
15
|
+
"hubentu/coala-runtime-r:latest",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _project_root() -> Path | None:
|
|
20
|
+
"""Return project root (directory containing docker/ and Dockerfiles), or None."""
|
|
21
|
+
# Prefer cwd so "uv run --directory /path/to/coala-runtime" or running from
|
|
22
|
+
# project dir works when the package is installed (e.g. conda).
|
|
23
|
+
cwd = Path.cwd()
|
|
24
|
+
if cwd.joinpath("docker", "Dockerfile.python").exists():
|
|
25
|
+
return cwd
|
|
26
|
+
# Walk up from this file (works when running from source tree).
|
|
27
|
+
path = Path(__file__).resolve().parent
|
|
28
|
+
for _ in range(5):
|
|
29
|
+
if path.joinpath("docker", "Dockerfile.python").exists():
|
|
30
|
+
return path
|
|
31
|
+
parent = path.parent
|
|
32
|
+
if parent == path:
|
|
33
|
+
break
|
|
34
|
+
path = parent
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _image_exists(client, image_tag: str) -> bool:
|
|
39
|
+
"""Return True if the image exists locally."""
|
|
40
|
+
try:
|
|
41
|
+
client.images.get(image_tag)
|
|
42
|
+
return True
|
|
43
|
+
except ImageNotFound:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pull_image(client, image: str) -> None:
|
|
48
|
+
"""Pull image from registry."""
|
|
49
|
+
logger.info("Pulling %s...", image)
|
|
50
|
+
client.images.pull(image)
|
|
51
|
+
logger.info("Pulled %s", image)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_image(client, project_root: Path, dockerfile_rel: str, tag: str) -> None:
|
|
55
|
+
"""Build a single image. dockerfile_rel is relative to project_root."""
|
|
56
|
+
logger.info("Building Docker image %s from %s...", tag, dockerfile_rel)
|
|
57
|
+
build_path = str(project_root)
|
|
58
|
+
try:
|
|
59
|
+
image, gen = client.images.build(
|
|
60
|
+
path=build_path,
|
|
61
|
+
dockerfile=dockerfile_rel,
|
|
62
|
+
tag=tag,
|
|
63
|
+
)
|
|
64
|
+
for chunk in gen:
|
|
65
|
+
if "stream" in chunk:
|
|
66
|
+
logger.debug(chunk["stream"].rstrip())
|
|
67
|
+
except ImageNotFound:
|
|
68
|
+
# docker-py may raise after build when resolving image by ID; image can
|
|
69
|
+
# still be present by tag (e.g. with buildx or some Docker versions).
|
|
70
|
+
try:
|
|
71
|
+
client.images.get(tag)
|
|
72
|
+
except ImageNotFound:
|
|
73
|
+
raise
|
|
74
|
+
logger.info("Built image %s", tag)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_build_script(project_root: Path) -> None:
|
|
78
|
+
"""Run docker/build.sh to build both images locally."""
|
|
79
|
+
script = project_root / "docker" / "build.sh"
|
|
80
|
+
if not script.exists():
|
|
81
|
+
raise FileNotFoundError(f"Build script not found: {script}")
|
|
82
|
+
logger.info("Running %s...", script)
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["bash", str(script)],
|
|
85
|
+
cwd=str(project_root),
|
|
86
|
+
check=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ensure_images(*, build: bool = False) -> None:
|
|
91
|
+
"""Ensure required executor images exist.
|
|
92
|
+
|
|
93
|
+
By default, pulls hubentu/coala-runtime-python:latest and hubentu/coala-runtime-r:latest
|
|
94
|
+
from Docker Hub if not present. With build=True, runs docker/build.sh to build both
|
|
95
|
+
images locally (tagged as coala-runtime-python:latest and coala-runtime-r:latest).
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
client = docker.from_env()
|
|
99
|
+
except DockerException as e:
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Could not connect to Docker; skipping image check/build: %s", e
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if build:
|
|
106
|
+
project_root = _project_root()
|
|
107
|
+
if project_root is None or not project_root.joinpath("docker", "Dockerfile.python").exists():
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Could not find project root (run from coala-runtime repo or set cwd); skipping Docker image build"
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
_run_build_script(project_root)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
for image in PULL_IMAGES:
|
|
116
|
+
if _image_exists(client, image):
|
|
117
|
+
logger.debug("Docker image %s already present", image)
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
_pull_image(client, image)
|
|
121
|
+
except DockerException as e:
|
|
122
|
+
logger.error("Failed to pull %s: %s", image, e)
|
|
123
|
+
raise
|