ms-enclave 0.0.0__py3-none-any.whl → 0.0.2__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.

Potentially problematic release.


This version of ms-enclave might be problematic. Click here for more details.

Files changed (43) hide show
  1. ms_enclave/__init__.py +2 -2
  2. ms_enclave/cli/__init__.py +1 -0
  3. ms_enclave/cli/base.py +20 -0
  4. ms_enclave/cli/cli.py +27 -0
  5. ms_enclave/cli/start_server.py +84 -0
  6. ms_enclave/sandbox/__init__.py +27 -0
  7. ms_enclave/sandbox/boxes/__init__.py +16 -0
  8. ms_enclave/sandbox/boxes/base.py +270 -0
  9. ms_enclave/sandbox/boxes/docker_notebook.py +214 -0
  10. ms_enclave/sandbox/boxes/docker_sandbox.py +317 -0
  11. ms_enclave/sandbox/manager/__init__.py +11 -0
  12. ms_enclave/sandbox/manager/base.py +155 -0
  13. ms_enclave/sandbox/manager/http_manager.py +405 -0
  14. ms_enclave/sandbox/manager/local_manager.py +295 -0
  15. ms_enclave/sandbox/model/__init__.py +21 -0
  16. ms_enclave/sandbox/model/base.py +36 -0
  17. ms_enclave/sandbox/model/config.py +97 -0
  18. ms_enclave/sandbox/model/requests.py +57 -0
  19. ms_enclave/sandbox/model/responses.py +57 -0
  20. ms_enclave/sandbox/server/__init__.py +0 -0
  21. ms_enclave/sandbox/server/server.py +195 -0
  22. ms_enclave/sandbox/tools/__init__.py +4 -0
  23. ms_enclave/sandbox/tools/base.py +95 -0
  24. ms_enclave/sandbox/tools/sandbox_tool.py +46 -0
  25. ms_enclave/sandbox/tools/sandbox_tools/__init__.py +4 -0
  26. ms_enclave/sandbox/tools/sandbox_tools/file_operation.py +331 -0
  27. ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py +167 -0
  28. ms_enclave/sandbox/tools/sandbox_tools/python_executor.py +87 -0
  29. ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +63 -0
  30. ms_enclave/sandbox/tools/tool_info.py +141 -0
  31. ms_enclave/utils/__init__.py +1 -0
  32. ms_enclave/utils/json_schema.py +208 -0
  33. ms_enclave/utils/logger.py +170 -0
  34. ms_enclave/version.py +2 -2
  35. ms_enclave-0.0.2.dist-info/METADATA +366 -0
  36. ms_enclave-0.0.2.dist-info/RECORD +40 -0
  37. {ms_enclave-0.0.0.dist-info → ms_enclave-0.0.2.dist-info}/WHEEL +1 -1
  38. ms_enclave-0.0.2.dist-info/entry_points.txt +2 -0
  39. ms_enclave/run_server.py +0 -21
  40. ms_enclave-0.0.0.dist-info/METADATA +0 -329
  41. ms_enclave-0.0.0.dist-info/RECORD +0 -8
  42. {ms_enclave-0.0.0.dist-info → ms_enclave-0.0.2.dist-info}/licenses/LICENSE +0 -0
  43. {ms_enclave-0.0.0.dist-info → ms_enclave-0.0.2.dist-info}/top_level.txt +0 -0
ms_enclave/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- from .version import __version__
1
+ from .version import __release_date__, __version__
2
2
 
3
- __all__ = ['__version__']
3
+ __all__ = ['__version__', '__release_date__']
@@ -0,0 +1 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
ms_enclave/cli/base.py ADDED
@@ -0,0 +1,20 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+
3
+ from abc import ABC, abstractmethod
4
+ from argparse import ArgumentParser
5
+
6
+
7
+ class CLICommand(ABC):
8
+ """
9
+ Base class for command line tool.
10
+
11
+ """
12
+
13
+ @staticmethod
14
+ @abstractmethod
15
+ def define_args(parsers: ArgumentParser):
16
+ raise NotImplementedError()
17
+
18
+ @abstractmethod
19
+ def execute(self):
20
+ raise NotImplementedError()
ms_enclave/cli/cli.py ADDED
@@ -0,0 +1,27 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+
3
+ import argparse
4
+
5
+ from ms_enclave import __version__
6
+ from ms_enclave.cli.start_server import ServerCMD
7
+
8
+
9
+ def run_cmd():
10
+ parser = argparse.ArgumentParser('MS-Enclave Command Line tool', usage='ms-enclave <command> [<args>]')
11
+ parser.add_argument('-v', '--version', action='version', version=f'ms-enclave {__version__}')
12
+ subparsers = parser.add_subparsers(help='MS-Enclave command line helper.')
13
+
14
+ ServerCMD.define_args(subparsers)
15
+
16
+ args = parser.parse_args()
17
+
18
+ if not hasattr(args, 'func'):
19
+ parser.print_help()
20
+ exit(1)
21
+
22
+ cmd = args.func(args)
23
+ cmd.execute()
24
+
25
+
26
+ if __name__ == '__main__':
27
+ run_cmd()
@@ -0,0 +1,84 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+ from argparse import ArgumentParser
3
+ from typing import Optional
4
+
5
+ from ms_enclave.cli.base import CLICommand
6
+ from ms_enclave.sandbox import create_server
7
+ from ms_enclave.utils import get_logger
8
+
9
+ logger = get_logger()
10
+
11
+
12
+ def subparser_func(args):
13
+ """ Function which will be called for a specific sub parser.
14
+ """
15
+ return ServerCMD(args)
16
+
17
+
18
+ class ServerCMD(CLICommand):
19
+ name = 'server'
20
+
21
+ def __init__(self, args):
22
+ self.args = args
23
+
24
+ @staticmethod
25
+ def define_args(parsers: ArgumentParser):
26
+ """Define args for the server command.
27
+ """
28
+ parser = parsers.add_parser(ServerCMD.name, help='Start the MS-Enclave sandbox HTTP server')
29
+ add_argument(parser)
30
+ parser.set_defaults(func=subparser_func)
31
+
32
+ def execute(self):
33
+ """Start the sandbox server using provided CLI arguments."""
34
+ cleanup_interval: int = getattr(self.args, 'cleanup_interval', 300)
35
+ host: str = getattr(self.args, 'host', '0.0.0.0')
36
+ port: int = getattr(self.args, 'port', 8000)
37
+ log_level: str = getattr(self.args, 'log_level', 'info')
38
+ api_key: Optional[str] = getattr(self.args, 'api_key', None)
39
+
40
+ server = create_server(cleanup_interval=cleanup_interval, api_key=api_key)
41
+
42
+ logger.info('Starting Sandbox Server...')
43
+ logger.info('API docs: http://%s:%d/docs', host, port)
44
+ logger.info('Health check: http://%s:%d/health', host, port)
45
+
46
+ try:
47
+ server.run(host=host, port=port, log_level=log_level)
48
+ except KeyboardInterrupt:
49
+ logger.info('Server interrupted by user, shutting down...')
50
+ except Exception as e: # pragma: no cover - runtime dependent
51
+ logger.error('Failed to start server: %s', e)
52
+ raise
53
+
54
+
55
+ def add_argument(parser: ArgumentParser) -> None:
56
+ """Register command line arguments for the server command.
57
+
58
+ Args:
59
+ parser: The argparse parser to add arguments to.
60
+ """
61
+ parser.add_argument(
62
+ '--host', type=str, default='0.0.0.0', help='Host interface to bind the server (default: 0.0.0.0)'
63
+ )
64
+ parser.add_argument('--port', type=int, default=8000, help='Port for the HTTP server (default: 8000)')
65
+ parser.add_argument(
66
+ '--log-level',
67
+ type=str,
68
+ choices=['critical', 'error', 'warning', 'info', 'debug'],
69
+ default='info',
70
+ help='Log level for the server (default: info)'
71
+ )
72
+ parser.add_argument(
73
+ '--cleanup-interval',
74
+ type=int,
75
+ default=300,
76
+ metavar='SECONDS',
77
+ help='Background cleanup interval in seconds (default: 300)'
78
+ )
79
+ parser.add_argument(
80
+ '--api-key',
81
+ type=str,
82
+ default=None,
83
+ help='Optional API key to protect endpoints. If omitted, no authentication is enforced.'
84
+ )
@@ -0,0 +1,27 @@
1
+ # Copyright (c) Alibaba, Inc. and its affiliates.
2
+ """Modern agent sandbox system.
3
+
4
+ A modular, extensible sandbox system for safe code execution with Docker isolation,
5
+ FastAPI-based client/server architecture, and comprehensive tool support.
6
+ """
7
+
8
+ from .boxes import DockerSandbox, Sandbox, SandboxFactory
9
+ from .manager import HttpSandboxManager, LocalSandboxManager
10
+
11
+ # Import main components
12
+ from .model import (
13
+ DockerSandboxConfig,
14
+ ExecuteCodeRequest,
15
+ ExecuteCommandRequest,
16
+ ExecutionStatus,
17
+ HealthCheckResult,
18
+ ReadFileRequest,
19
+ SandboxConfig,
20
+ SandboxInfo,
21
+ SandboxStatus,
22
+ ToolExecutionRequest,
23
+ ToolResult,
24
+ WriteFileRequest,
25
+ )
26
+ from .server.server import SandboxServer, create_server
27
+ from .tools import PythonExecutor, Tool, ToolFactory
@@ -0,0 +1,16 @@
1
+ """Sandbox implementations."""
2
+
3
+ from .base import Sandbox, SandboxFactory, register_sandbox
4
+ from .docker_notebook import DockerNotebookSandbox
5
+ from .docker_sandbox import DockerSandbox
6
+
7
+ __all__ = [
8
+ # Base interfaces
9
+ 'Sandbox',
10
+ 'SandboxFactory',
11
+ 'register_sandbox',
12
+
13
+ # Implementations
14
+ 'DockerSandbox',
15
+ 'DockerNotebookSandbox',
16
+ ]
@@ -0,0 +1,270 @@
1
+ """Base sandbox interface and factory."""
2
+
3
+ import abc
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional, Type, Union
6
+
7
+ import shortuuid as uuid
8
+
9
+ from ms_enclave.utils import get_logger
10
+
11
+ from ..model import (
12
+ CommandResult,
13
+ DockerNotebookConfig,
14
+ DockerSandboxConfig,
15
+ SandboxConfig,
16
+ SandboxInfo,
17
+ SandboxStatus,
18
+ SandboxType,
19
+ ToolResult,
20
+ )
21
+ from ..tools import Tool, ToolFactory
22
+
23
+ logger = get_logger()
24
+
25
+
26
+ class Sandbox(abc.ABC):
27
+ """Abstract base class for all sandbox implementations."""
28
+
29
+ def __init__(self, config: SandboxConfig, sandbox_id: Optional[str] = None):
30
+ """Initialize sandbox.
31
+
32
+ Args:
33
+ config: Sandbox configuration
34
+ sandbox_id: Optional sandbox ID (will be generated if not provided)
35
+ """
36
+ self.id = sandbox_id or uuid.ShortUUID(alphabet='23456789abcdefghijkmnopqrstuvwxyz').random(length=8)
37
+ self.config = config
38
+ self.status = SandboxStatus.INITIALIZING
39
+ self.created_at = datetime.now()
40
+ self.updated_at = datetime.now()
41
+ self.metadata: Dict[str, Any] = {}
42
+ self._tools: Dict[str, Tool] = {}
43
+
44
+ @property
45
+ @abc.abstractmethod
46
+ def sandbox_type(self) -> SandboxType:
47
+ """Return the sandbox type identifier."""
48
+ pass
49
+
50
+ @abc.abstractmethod
51
+ async def start(self) -> None:
52
+ """Start the sandbox environment."""
53
+ pass
54
+
55
+ @abc.abstractmethod
56
+ async def stop(self) -> None:
57
+ """Stop the sandbox environment."""
58
+ pass
59
+
60
+ @abc.abstractmethod
61
+ async def cleanup(self) -> None:
62
+ """Clean up sandbox resources."""
63
+ pass
64
+
65
+ async def initialize_tools(self) -> None:
66
+ """Initialize sandbox tools."""
67
+ for tool_name, config in self.config.tools_config.items():
68
+ try:
69
+ tool = ToolFactory.create_tool(tool_name, **config)
70
+ if tool.enabled:
71
+ # Check if tool is compatible with this sandbox
72
+ if (tool.required_sandbox_type is None or tool.required_sandbox_type == self.sandbox_type):
73
+ self._tools[tool_name] = tool
74
+ else:
75
+ logger.warning(
76
+ f'Tool {tool_name} requires {tool.required_sandbox_type} but sandbox is {self.sandbox_type}'
77
+ )
78
+ except Exception as e:
79
+ logger.error(f'Failed to initialize tool {tool_name}: {e}')
80
+
81
+ def get_available_tools(self) -> Dict[str, Any]:
82
+ """Get list of available tools."""
83
+ return {tool.name: tool.schema for tool in self._tools.values() if tool.enabled}
84
+
85
+ def get_tool(self, tool_name: str) -> Optional[Tool]:
86
+ """Get tool instance by type.
87
+
88
+ Args:
89
+ tool_name: Tool name
90
+
91
+ Returns:
92
+ Tool instance or None if not available
93
+ """
94
+ return self._tools.get(tool_name)
95
+
96
+ def add_tool(self, tool: Tool) -> None:
97
+ """Add a tool to the sandbox.
98
+
99
+ Args:
100
+ tool: Tool instance to add
101
+ """
102
+ if tool.name in self._tools:
103
+ logger.warning(f'Tool {tool.name} is already added to the sandbox')
104
+ return
105
+ if tool.enabled:
106
+ if (tool.required_sandbox_type is None or tool.required_sandbox_type == self.sandbox_type):
107
+ self._tools[tool.name] = tool
108
+ else:
109
+ logger.warning(
110
+ f'Tool {tool.name} requires {tool.required_sandbox_type} but sandbox is {self.sandbox_type}'
111
+ )
112
+ else:
113
+ logger.warning(f'Tool {tool.name} is not enabled and cannot be added')
114
+
115
+ async def execute_tool(self, tool_name: str, parameters: Dict[str, Any]) -> ToolResult:
116
+ """Execute a tool with given parameters.
117
+
118
+ Args:
119
+ tool_name: Tool name
120
+ parameters: Tool parameters
121
+
122
+ Returns:
123
+ Tool execution result
124
+
125
+ Raises:
126
+ ValueError: If tool is not found or not enabled
127
+ TimeoutError: If tool execution exceeds timeout
128
+ Exception: For other execution errors
129
+ """
130
+ tool = self.get_tool(tool_name)
131
+ if not tool:
132
+ raise ValueError(f'Tool {tool_name} is not available')
133
+ if not tool.enabled:
134
+ raise ValueError(f'Tool {tool_name} is not enabled')
135
+
136
+ result = await tool.execute(sandbox_context=self, **parameters)
137
+ return result
138
+
139
+ async def execute_command(
140
+ self, command: Union[str, List[str]], timeout: Optional[int] = None, stream: bool = True
141
+ ) -> CommandResult:
142
+ """Execute a command in the sandbox environment.
143
+
144
+ Args:
145
+ command: Command to execute
146
+ timeout: Optional execution timeout in seconds
147
+ stream: Whether to stream output (if supported)
148
+ """
149
+ raise NotImplementedError('execute_command must be implemented by subclasses')
150
+
151
+ @abc.abstractmethod
152
+ async def get_execution_context(self) -> Any:
153
+ """Get the execution context for tools (e.g., container, process, etc.)."""
154
+ pass
155
+
156
+ def update_status(self, status: SandboxStatus) -> None:
157
+ """Update sandbox status.
158
+
159
+ Args:
160
+ status: New status
161
+ """
162
+ self.status = status
163
+ self.updated_at = datetime.now()
164
+
165
+ def get_info(self) -> SandboxInfo:
166
+ """Get sandbox information.
167
+
168
+ Returns:
169
+ Sandbox information
170
+ """
171
+ return SandboxInfo(
172
+ id=self.id,
173
+ status=self.status,
174
+ type=self.sandbox_type,
175
+ config=self.config.model_dump(exclude_none=True),
176
+ created_at=self.created_at,
177
+ updated_at=self.updated_at,
178
+ metadata=self.metadata,
179
+ available_tools=self.get_available_tools()
180
+ )
181
+
182
+ async def __aenter__(self):
183
+ """Async context manager entry."""
184
+ await self.start()
185
+ return self
186
+
187
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
188
+ """Async context manager exit."""
189
+ await self.stop()
190
+
191
+
192
+ class SandboxFactory:
193
+ """Factory for creating sandbox instances."""
194
+
195
+ _sandboxes: Dict[SandboxType, Type[Sandbox]] = {}
196
+
197
+ @classmethod
198
+ def register_sandbox(cls, sandbox_type: SandboxType, sandbox_class: Type[Sandbox]):
199
+ """Register a sandbox class.
200
+
201
+ Args:
202
+ sandbox_type: Sandbox type identifier
203
+ sandbox_class: Sandbox class
204
+ """
205
+ cls._sandboxes[sandbox_type] = sandbox_class
206
+
207
+ @classmethod
208
+ def create_sandbox(
209
+ cls,
210
+ sandbox_type: SandboxType,
211
+ config: Optional[Union[SandboxConfig, Dict]] = None,
212
+ sandbox_id: Optional[str] = None
213
+ ) -> Sandbox:
214
+ """Create a sandbox instance.
215
+
216
+ Args:
217
+ sandbox_type: Sandbox type
218
+ config: Sandbox configuration
219
+ sandbox_id: Optional sandbox ID
220
+
221
+ Returns:
222
+ Sandbox instance
223
+
224
+ Raises:
225
+ ValueError: If sandbox type is not registered
226
+ """
227
+ if sandbox_type not in cls._sandboxes:
228
+ raise ValueError(f'Sandbox type {sandbox_type} is not registered')
229
+
230
+ # Parse config based on sandbox type
231
+ if not config:
232
+ if sandbox_type == SandboxType.DOCKER:
233
+ config = DockerSandboxConfig()
234
+ elif sandbox_type == SandboxType.DOCKER_NOTEBOOK:
235
+ config = DockerNotebookConfig()
236
+ else:
237
+ config = SandboxConfig()
238
+ elif isinstance(config, dict):
239
+ if sandbox_type == SandboxType.DOCKER:
240
+ config = DockerSandboxConfig(**config)
241
+ elif sandbox_type == SandboxType.DOCKER_NOTEBOOK:
242
+ config = DockerNotebookConfig(**config)
243
+ else:
244
+ config = SandboxConfig(**config)
245
+
246
+ sandbox_class = cls._sandboxes[sandbox_type]
247
+ return sandbox_class(config, sandbox_id)
248
+
249
+ @classmethod
250
+ def get_available_types(cls) -> List[SandboxType]:
251
+ """Get list of available sandbox types.
252
+
253
+ Returns:
254
+ List of available sandbox types
255
+ """
256
+ return list(cls._sandboxes.keys())
257
+
258
+
259
+ def register_sandbox(sandbox_type: SandboxType):
260
+ """Decorator for registering sandboxes.
261
+
262
+ Args:
263
+ sandbox_type: Sandbox type identifier
264
+ """
265
+
266
+ def decorator(sandbox_class: Type[Sandbox]):
267
+ SandboxFactory.register_sandbox(sandbox_type, sandbox_class)
268
+ return sandbox_class
269
+
270
+ return decorator
@@ -0,0 +1,214 @@
1
+ # flake8: noqa E501
2
+ import asyncio
3
+ import json
4
+ import tempfile
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+ from typing import Optional
8
+
9
+ from ms_enclave.utils import get_logger
10
+
11
+ from ..model import DockerNotebookConfig, SandboxStatus, SandboxType
12
+ from .base import register_sandbox
13
+ from .docker_sandbox import DockerSandbox
14
+
15
+ logger = get_logger()
16
+
17
+
18
+ @register_sandbox(SandboxType.DOCKER_NOTEBOOK)
19
+ class DockerNotebookSandbox(DockerSandbox):
20
+ """
21
+ Docker sandbox that executes Python code using Jupyter Kernel Gateway.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ config: DockerNotebookConfig,
27
+ sandbox_id: Optional[str] = None,
28
+ ):
29
+ """
30
+ Initialize the Docker-based Jupyter Kernel Gateway executor.
31
+
32
+ Args:
33
+ config: Docker sandbox configuration
34
+ sandbox_id: Optional sandbox ID
35
+ host: Host to bind to.
36
+ port: Port to bind to.
37
+ """
38
+ super().__init__(config, sandbox_id)
39
+
40
+ self.config: DockerNotebookConfig = config
41
+ self.host = self.config.host
42
+ self.port = self.config.port
43
+ self.kernel_id = None
44
+ self.ws = None
45
+ self.base_url = None
46
+ self.config.ports['8888/tcp'] = self.port
47
+ self.config.network_enabled = True # Ensure network is enabled for Jupyter
48
+
49
+ @property
50
+ def sandbox_type(self) -> SandboxType:
51
+ """Return sandbox type."""
52
+ return SandboxType.DOCKER_NOTEBOOK
53
+
54
+ async def start(self) -> None:
55
+ """Start the Docker container with Jupyter Kernel Gateway."""
56
+ try:
57
+ self.update_status(SandboxStatus.INITIALIZING)
58
+
59
+ # Initialize Docker client first
60
+ import docker
61
+ self.client = docker.from_env()
62
+
63
+ # Build Jupyter image if needed before creating container
64
+ await self._build_jupyter_image()
65
+
66
+ # Now start the base container with the Jupyter image
67
+ await super().start()
68
+
69
+ # Setup Jupyter kernel gateway services
70
+ await self._setup_jupyter()
71
+
72
+ self.update_status(SandboxStatus.RUNNING)
73
+
74
+ except Exception as e:
75
+ self.update_status(SandboxStatus.ERROR)
76
+ self.metadata['error'] = str(e)
77
+ logger.error(f'Failed to start Jupyter Docker sandbox: {e}')
78
+ raise RuntimeError(f'Failed to start Jupyter Docker sandbox: {e}')
79
+
80
+ async def _setup_jupyter(self) -> None:
81
+ """Setup Jupyter Kernel Gateway services in the container."""
82
+ try:
83
+ # Wait for Jupyter Kernel Gateway to be ready
84
+ await self._wait_for_jupyter_ready()
85
+
86
+ # Create kernel and establish websocket connection
87
+ await self._create_kernel()
88
+
89
+ except Exception as e:
90
+ logger.error(f'Failed to setup Jupyter: {e}')
91
+ raise
92
+
93
+ async def _wait_for_jupyter_ready(self) -> None:
94
+ """Wait for Jupyter Kernel Gateway to be ready."""
95
+ import requests
96
+
97
+ self.base_url = f'http://{self.host}:{self.port}'
98
+ max_retries = 10 # Wait up to 30 seconds
99
+ retry_interval = 3 # Check every 3 second
100
+
101
+ for attempt in range(max_retries):
102
+ try:
103
+ # Try to get the API status
104
+ response = requests.get(f'{self.base_url}/api', timeout=5)
105
+ if response.status_code == 200:
106
+ logger.info(f'Jupyter Kernel Gateway is ready at {self.base_url}')
107
+ return
108
+ except requests.exceptions.RequestException:
109
+ # Connection failed, Jupyter not ready yet
110
+ pass
111
+
112
+ if attempt < max_retries - 1:
113
+ logger.info(f'Waiting for Jupyter Kernel Gateway to be ready... (attempt {attempt + 1}/{max_retries})')
114
+ await asyncio.sleep(retry_interval)
115
+
116
+ raise RuntimeError(f'Jupyter Kernel Gateway failed to become ready within {max_retries} seconds')
117
+
118
+ async def _build_jupyter_image(self) -> None:
119
+ """Build or ensure Jupyter image exists."""
120
+ try:
121
+ # Check if image exists
122
+ self.client.images.get(self.config.image)
123
+ logger.info(f'Using existing Docker image: {self.config.image}')
124
+ except Exception:
125
+ logger.info(f'Building Docker image {self.config.image}...')
126
+
127
+ # Create Dockerfile
128
+ dockerfile_content = dedent(
129
+ """\
130
+ FROM python:3.12-slim
131
+
132
+ RUN pip install jupyter_kernel_gateway jupyter_client ipykernel
133
+
134
+ # Install and register the Python kernel
135
+ RUN python -m ipykernel install --sys-prefix --name python3 --display-name "Python 3"
136
+
137
+ EXPOSE 8888
138
+ CMD ["jupyter", "kernelgateway", "--KernelGatewayApp.ip=0.0.0.0", "--KernelGatewayApp.port=8888", "--KernelGatewayApp.allow_origin=*"]
139
+ """
140
+ )
141
+
142
+ with tempfile.TemporaryDirectory() as tmpdir:
143
+ dockerfile_path = Path(tmpdir) / 'Dockerfile'
144
+ dockerfile_path.write_text(dockerfile_content)
145
+
146
+ # Build image with output
147
+ def build_image():
148
+ build_logs = self.client.images.build(
149
+ path=tmpdir, dockerfile='Dockerfile', tag=self.config.image, rm=True
150
+ )
151
+ # Process and log build output
152
+ for log in build_logs[1]: # build_logs[1] contains the build log generator
153
+ if 'stream' in log:
154
+ logger.info(f"[📦 {self.id}] {log['stream'].strip()}")
155
+ elif 'error' in log:
156
+ logger.error(f"[📦 {self.id}] {log['error']}")
157
+ return build_logs[0] # Return the built image
158
+
159
+ await asyncio.get_event_loop().run_in_executor(None, build_image)
160
+
161
+ async def _create_kernel(self) -> None:
162
+ """Create a new kernel and establish websocket connection."""
163
+ import requests
164
+
165
+ # Create new kernel via HTTP
166
+ response = requests.post(f'{self.base_url}/api/kernels')
167
+ if response.status_code != 201:
168
+ error_details = {
169
+ 'status_code': response.status_code,
170
+ 'headers': dict(response.headers),
171
+ 'url': response.url,
172
+ 'body': response.text,
173
+ 'request_method': response.request.method,
174
+ 'request_headers': dict(response.request.headers),
175
+ 'request_body': response.request.body,
176
+ }
177
+ raise RuntimeError(f'Failed to create kernel: {json.dumps(error_details, indent=2)}')
178
+
179
+ self.kernel_id = response.json()['id']
180
+
181
+ # Establish websocket connection
182
+ try:
183
+ from websocket import create_connection
184
+ ws_url = f'ws://{self.host}:{self.port}/api/kernels/{self.kernel_id}/channels'
185
+ self.ws = create_connection(ws_url)
186
+ logger.info(f'Kernel {self.kernel_id} created and connected')
187
+ except ImportError:
188
+ raise RuntimeError('websocket-client package is required. Install with: pip install websocket-client')
189
+
190
+ async def cleanup(self) -> None:
191
+ """Clean up Jupyter resources and Docker container."""
192
+ try:
193
+ # Close websocket connection
194
+ if self.ws:
195
+ try:
196
+ self.ws.close()
197
+ except Exception:
198
+ pass
199
+ self.ws = None
200
+
201
+ # Delete kernel
202
+ if self.kernel_id and self.base_url:
203
+ try:
204
+ import requests
205
+ requests.delete(f'{self.base_url}/api/kernels/{self.kernel_id}')
206
+ except Exception:
207
+ pass
208
+ self.kernel_id = None
209
+
210
+ except Exception as e:
211
+ logger.error(f'Error during Jupyter cleanup: {e}')
212
+
213
+ # Call parent cleanup
214
+ await super().cleanup()