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.
@@ -0,0 +1,3 @@
1
+ """Coala Runtime - MCP server for executing Python and R scripts in containers."""
2
+
3
+ __version__ = "0.1.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