Implement real Jupyter backend with jupyter-client
- Replaced mock exec() implementation with real Jupyter protocol - Uses jupyter-client (host) to connect to ipykernel (container) via ZMQ - 1:1 mapping: one container per session, one kernel per container - Proper Jupyter message protocol for code execution - Kernel lifecycle management (start, execute, shutdown, restart) - Namespace inspection via introspection code - Idle kernel cleanup - Connection file management - Backed up old implementation as kernel_old.py TODO: - Container image needs ipykernel installed - Need to implement proper port mapping for ZMQ - Need to mount connection file into container - Add better kernel readiness check - Implement restart_kernel properly with KernelManager - Write tests
This commit is contained in:
parent
372af75b90
commit
244a3e5574
6 changed files with 940 additions and 253 deletions
|
|
@ -291,17 +291,59 @@ execute_python(
|
|||
**Purpose:** Stateful, multi-step workflows
|
||||
|
||||
**Implementation:**
|
||||
- Spawn Podman container with IPython kernel
|
||||
- Spawn Podman container with IPython kernel (one per session)
|
||||
- jupyter-client runs in MCP-Forge server (host), not in container
|
||||
- ipykernel runs inside container as kernel process
|
||||
- Keep kernel running for session lifetime
|
||||
- Execute code cells via Jupyter protocol (ZMQ)
|
||||
- Maintain namespace between executions
|
||||
- Support rich output formats
|
||||
|
||||
**Architecture Flow:**
|
||||
```
|
||||
MCP-Forge Server (Host)
|
||||
│
|
||||
├─ jupyter-client (ZMQ client library)
|
||||
│ │
|
||||
│ ├─ Manages connections to kernel containers
|
||||
│ └─ Communicates via ZMQ sockets (shell, iopub, stdin, control, heartbeat)
|
||||
│
|
||||
├─ Session "session-123" ──→ Container A ──→ ipykernel Process A
|
||||
├─ Session "session-456" ──→ Container B ──→ ipykernel Process B
|
||||
└─ Session "session-789" ──→ Container C ──→ ipykernel Process C
|
||||
```
|
||||
|
||||
**Session-to-Kernel Mapping (1:1):**
|
||||
- **One container per session** - complete isolation
|
||||
- **One kernel process per container** - dedicated resources
|
||||
- **Separate Python namespaces** - no variable sharing between sessions
|
||||
- **Independent resource limits** - each session has own CPU/memory quota
|
||||
- **Strong security boundary** - container escape affects only one session
|
||||
|
||||
**File Sharing Between Sessions:**
|
||||
Sessions can share files (not variables) via shared volumes:
|
||||
```python
|
||||
# Session 1: Write data
|
||||
execute_python(
|
||||
code="df.to_parquet('/shared/data.parquet')",
|
||||
session_id="session-123",
|
||||
volumes={"/shared": {"bind": "/mcp-forge/projects/abc", "mode": "rw"}}
|
||||
)
|
||||
|
||||
# Session 2: Read data (different container, different namespace)
|
||||
execute_python(
|
||||
code="df = pd.read_parquet('/shared/data.parquet')",
|
||||
session_id="session-456",
|
||||
volumes={"/shared": {"bind": "/mcp-forge/projects/abc", "mode": "ro"}}
|
||||
)
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
- State persists between calls
|
||||
- Variable persistence
|
||||
- State persists between calls within same session
|
||||
- Variable persistence in session namespace
|
||||
- Interactive workflow support
|
||||
- Higher resource usage
|
||||
- Higher resource usage per session
|
||||
- Full isolation between sessions
|
||||
|
||||
**Use cases:**
|
||||
- Multi-step data analysis
|
||||
|
|
@ -311,9 +353,11 @@ execute_python(
|
|||
|
||||
**Session Management:**
|
||||
- Sessions identified by unique ID
|
||||
- Each session gets dedicated container and kernel
|
||||
- Automatic timeout after inactivity (configurable, default: 1 hour)
|
||||
- Manual cleanup via session deletion
|
||||
- Resource limits per session
|
||||
- Resource limits enforced per container/session
|
||||
- Clean lifecycle: destroy container = destroy session
|
||||
|
||||
### 3. Custom Environment Building
|
||||
|
||||
|
|
|
|||
97
docs/todo.md
97
docs/todo.md
|
|
@ -1079,22 +1079,42 @@ class SimpleBackend:
|
|||
- All tests use mocked ZMQ and containers
|
||||
|
||||
**Implementation requirements:**
|
||||
|
||||
**Architecture:**
|
||||
- `jupyter-client` runs in MCP-Forge server (host) - manages ZMQ connections
|
||||
- `ipykernel` runs inside Podman container - actual kernel process
|
||||
- **1:1 mapping**: One container per session, one kernel per container
|
||||
- **No shared variables** between sessions (separate namespaces)
|
||||
- **Optional shared volumes** for file-based data exchange
|
||||
|
||||
```python
|
||||
from jupyter_client import KernelManager, BlockingKernelClient
|
||||
from typing import Any, Dict, List, Optional
|
||||
import zmq
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
@dataclass
|
||||
class KernelInfo:
|
||||
"""Information about running kernel."""
|
||||
kernel_id: str
|
||||
container_id: str
|
||||
connection_file: Path
|
||||
session_id: str
|
||||
connection_info: Dict[str, Any] # ZMQ ports and keys
|
||||
started_at: datetime
|
||||
last_activity: datetime
|
||||
client: Optional[BlockingKernelClient] = None
|
||||
|
||||
class JupyterKernelManager:
|
||||
"""Manages Jupyter kernel in container."""
|
||||
"""
|
||||
Manages IPython kernels in containers via jupyter-client.
|
||||
|
||||
Architecture:
|
||||
- This class runs on host (MCP-Forge server process)
|
||||
- Creates one container per session with ipykernel running inside
|
||||
- Connects to kernel via ZMQ protocol (jupyter-client)
|
||||
- Communicates with kernel using Jupyter message protocol
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -1103,7 +1123,7 @@ class JupyterKernelManager:
|
|||
resource_limits: ResourceLimits
|
||||
):
|
||||
self.container_manager = container_manager
|
||||
self.image = image
|
||||
self.image = image # Image with ipykernel installed
|
||||
self.resource_limits = resource_limits
|
||||
self.kernels: Dict[str, KernelInfo] = {}
|
||||
|
||||
|
|
@ -1113,17 +1133,18 @@ class JupyterKernelManager:
|
|||
volumes: Optional[Dict[str, dict]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Start IPython kernel in container.
|
||||
Start IPython kernel in dedicated container.
|
||||
|
||||
Process:
|
||||
1. Create container with IPython kernel
|
||||
2. Start container
|
||||
3. Wait for kernel to be ready
|
||||
4. Connect to kernel via ZMQ
|
||||
5. Verify kernel is responsive
|
||||
1. Generate ZMQ connection info (ports, keys)
|
||||
2. Create container with ipykernel
|
||||
3. Start ipykernel process with connection file
|
||||
4. Wait for kernel to be ready
|
||||
5. Connect jupyter-client to kernel via ZMQ
|
||||
6. Verify kernel is responsive
|
||||
|
||||
Returns:
|
||||
kernel_id
|
||||
kernel_id: Unique identifier for this kernel
|
||||
"""
|
||||
pass
|
||||
|
||||
|
|
@ -1134,23 +1155,34 @@ class JupyterKernelManager:
|
|||
timeout: int = 300
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Execute code in kernel.
|
||||
Execute code in kernel via ZMQ.
|
||||
|
||||
Uses ZMQ to send execute request and receive result.
|
||||
Captures stdout, stderr, display data, and result.
|
||||
Uses jupyter-client to:
|
||||
1. Send execute_request message
|
||||
2. Receive stream (stdout/stderr) messages
|
||||
3. Receive execute_result/display_data messages
|
||||
4. Collect and parse all output
|
||||
|
||||
Returns ExecutionResult with stdout, stderr, result
|
||||
"""
|
||||
pass
|
||||
|
||||
def shutdown_kernel(self, kernel_id: str) -> None:
|
||||
"""Shutdown kernel and cleanup container."""
|
||||
"""
|
||||
Shutdown kernel and cleanup container.
|
||||
|
||||
1. Send shutdown_request via ZMQ
|
||||
2. Wait for kernel shutdown
|
||||
3. Stop and remove container
|
||||
"""
|
||||
pass
|
||||
|
||||
def inspect_namespace(self, kernel_id: str) -> List[str]:
|
||||
"""
|
||||
Get list of variables in kernel namespace.
|
||||
|
||||
Executes: dir() to get variable names
|
||||
Filters out private variables and builtins
|
||||
Executes introspection code:
|
||||
[var for var in dir() if not var.startswith('_')]
|
||||
"""
|
||||
pass
|
||||
|
||||
|
|
@ -1160,20 +1192,24 @@ class JupyterKernelManager:
|
|||
variable_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about a variable.
|
||||
Get detailed information about a variable.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"type": str,
|
||||
"size_bytes": int (if applicable),
|
||||
"shape": tuple (if array-like),
|
||||
"repr": str (shortened)
|
||||
}
|
||||
Executes introspection code to get:
|
||||
- type(var).__name__
|
||||
- sys.getsizeof(var) if available
|
||||
- var.shape if hasattr(var, 'shape')
|
||||
- repr(var)[:100]
|
||||
|
||||
Returns dict with type, size, shape, repr
|
||||
"""
|
||||
pass
|
||||
|
||||
def restart_kernel(self, kernel_id: str) -> None:
|
||||
"""Restart kernel (keeps container, resets namespace)."""
|
||||
"""
|
||||
Restart kernel (namespace reset, container kept).
|
||||
|
||||
Sends restart_request via ZMQ.
|
||||
"""
|
||||
pass
|
||||
|
||||
def cleanup_idle_kernels(
|
||||
|
|
@ -1190,14 +1226,17 @@ class JupyterKernelManager:
|
|||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Kernel starts successfully in container
|
||||
- [ ] ZMQ connection established correctly
|
||||
- [ ] Code execution works via ZMQ protocol
|
||||
- [ ] Namespace persists between executions
|
||||
- [ ] jupyter-client dependency in server (host), ipykernel in container image
|
||||
- [ ] Kernel starts successfully in dedicated container (1 per session)
|
||||
- [ ] ZMQ connection established correctly (ports exposed from container)
|
||||
- [ ] Code execution works via Jupyter message protocol
|
||||
- [ ] Namespace persists between executions within same session
|
||||
- [ ] Each session has completely isolated namespace
|
||||
- [ ] Variable introspection works
|
||||
- [ ] Variable info includes type, size, shape
|
||||
- [ ] Kernel shutdown cleans up container
|
||||
- [ ] Idle kernel cleanup works
|
||||
- [ ] Kernel restart clears namespace but keeps container
|
||||
- [ ] Kernel restart works
|
||||
- [ ] Handles kernel crashes gracefully
|
||||
- [ ] All tests use mocked ZMQ and containers
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ authors = [
|
|||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastmcp>=2.14.5",
|
||||
"jupyter-client>=8.8.0",
|
||||
"podman>=5.7.0",
|
||||
"pydantic>=2.12.5",
|
||||
"pyyaml>=6.0.3",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
"""Jupyter kernel management for stateful execution."""
|
||||
"""
|
||||
Real Jupyter kernel management for stateful execution.
|
||||
|
||||
This module implements proper Jupyter kernel management:
|
||||
- jupyter-client runs on host (MCP-Forge server)
|
||||
- ipykernel runs inside Podman containers
|
||||
- Communication via ZMQ protocol
|
||||
- 1:1 mapping: one container per session, one kernel per container
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, List, Any
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import json
|
||||
import sys
|
||||
import io
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from jupyter_client.blocking.client import BlockingKernelClient
|
||||
import zmq
|
||||
|
||||
from mcp_forge.podman.containers import SecureContainerManager, ContainerConfig
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
|
|
@ -24,9 +36,11 @@ class KernelInfo:
|
|||
kernel_id: str
|
||||
container_id: str
|
||||
session_id: str
|
||||
connection_file: Path
|
||||
connection_info: Dict[str, Any] # ZMQ ports and keys
|
||||
started_at: datetime
|
||||
last_activity: datetime
|
||||
namespace: Dict[str, Any] = field(default_factory=dict)
|
||||
client: Optional[BlockingKernelClient] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
|
|
@ -36,31 +50,46 @@ class KernelInfo:
|
|||
"session_id": self.session_id,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"last_activity": self.last_activity.isoformat(),
|
||||
"connection_info": {
|
||||
"shell_port": self.connection_info.get("shell_port"),
|
||||
"iopub_port": self.connection_info.get("iopub_port"),
|
||||
"stdin_port": self.connection_info.get("stdin_port"),
|
||||
"control_port": self.connection_info.get("control_port"),
|
||||
"hb_port": self.connection_info.get("hb_port"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class JupyterKernelManager:
|
||||
"""
|
||||
Manages IPython kernels in containers for stateful execution.
|
||||
Manages IPython kernels in containers via jupyter-client.
|
||||
|
||||
This is a simplified implementation that uses containers to maintain
|
||||
state between executions. Each kernel runs in its own container and
|
||||
maintains a Python namespace that persists across execute calls.
|
||||
Architecture:
|
||||
- This class runs on host (MCP-Forge server process)
|
||||
- Creates one container per session with ipykernel running inside
|
||||
- Connects to kernel via ZMQ protocol (jupyter-client)
|
||||
- Communicates using Jupyter message protocol
|
||||
|
||||
Each session gets:
|
||||
- Dedicated container
|
||||
- Dedicated kernel process
|
||||
- Isolated Python namespace
|
||||
- Independent resource limits
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container_manager: SecureContainerManager,
|
||||
image: str,
|
||||
resource_limits: Optional[ResourceLimits]
|
||||
resource_limits: Optional[ResourceLimits] = None
|
||||
):
|
||||
"""
|
||||
Initialize kernel manager.
|
||||
|
||||
Args:
|
||||
container_manager: Container lifecycle manager
|
||||
image: Docker/Podman image with Python/IPython
|
||||
resource_limits: Default resource limits for kernels (None to disable) (None to disable)
|
||||
image: Docker/Podman image with ipykernel installed
|
||||
resource_limits: Default resource limits for kernels
|
||||
"""
|
||||
self.container_manager = container_manager
|
||||
self.image = image
|
||||
|
|
@ -73,50 +102,89 @@ class JupyterKernelManager:
|
|||
volumes: Optional[Dict[str, dict]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Start a new kernel in a container.
|
||||
Start IPython kernel in dedicated container.
|
||||
|
||||
Creates a long-running container with Python that will accept
|
||||
and execute code, maintaining namespace state between executions.
|
||||
Process:
|
||||
1. Generate ZMQ connection info (ports, keys)
|
||||
2. Create connection file
|
||||
3. Create container with ipykernel command
|
||||
4. Start container
|
||||
5. Wait for kernel to be ready
|
||||
6. Connect jupyter-client to kernel via ZMQ
|
||||
7. Verify kernel is responsive
|
||||
|
||||
Args:
|
||||
session_id: Session ID this kernel belongs to
|
||||
volumes: Optional volume mounts
|
||||
|
||||
Returns:
|
||||
kernel_id: Unique identifier for the kernel
|
||||
kernel_id: Unique identifier for this kernel
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel startup fails
|
||||
"""
|
||||
kernel_id = f"kernel-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
# Create container configuration for long-running kernel
|
||||
# We use a shell that stays running so we can exec into it
|
||||
# Generate connection info
|
||||
connection_info = self._generate_connection_info()
|
||||
|
||||
# Create connection file
|
||||
connection_file = self._create_connection_file(kernel_id, connection_info)
|
||||
|
||||
try:
|
||||
# Create container with ipykernel
|
||||
config = ContainerConfig(
|
||||
image=self.image,
|
||||
command=["sleep", "infinity"], # Keep container running
|
||||
command=[
|
||||
"python", "-m", "ipykernel_launcher",
|
||||
"-f", f"/tmp/kernel-{kernel_id}.json"
|
||||
],
|
||||
resource_limits=self.resource_limits,
|
||||
volumes=volumes or {}
|
||||
volumes=volumes or {},
|
||||
# TODO: Port mappings for ZMQ
|
||||
# TODO: Mount connection file into container
|
||||
)
|
||||
|
||||
# Create and start container
|
||||
container_id = self.container_manager.create_container(
|
||||
config,
|
||||
session_id=session_id,
|
||||
name=f"kernel-{kernel_id}"
|
||||
name=f"jupyter-{kernel_id}"
|
||||
)
|
||||
|
||||
# Start container
|
||||
self.container_manager.start_container(container_id)
|
||||
|
||||
# Wait for kernel to be ready
|
||||
time.sleep(2) # TODO: Better readiness check
|
||||
|
||||
# Connect client
|
||||
client = self._connect_client(connection_info)
|
||||
|
||||
# Verify kernel is responsive
|
||||
if not self._verify_kernel(client):
|
||||
raise KernelError(f"Kernel {kernel_id} not responsive")
|
||||
|
||||
# Register kernel
|
||||
now = datetime.utcnow()
|
||||
kernel_info = KernelInfo(
|
||||
kernel_id=kernel_id,
|
||||
container_id=container_id,
|
||||
session_id=session_id,
|
||||
connection_file=connection_file,
|
||||
connection_info=connection_info,
|
||||
started_at=now,
|
||||
last_activity=now
|
||||
last_activity=now,
|
||||
client=client
|
||||
)
|
||||
self.kernels[kernel_id] = kernel_info
|
||||
|
||||
return kernel_id
|
||||
|
||||
except Exception as e:
|
||||
# Cleanup on failure
|
||||
connection_file.unlink(missing_ok=True)
|
||||
raise KernelError(f"Failed to start kernel: {e}") from e
|
||||
|
||||
def execute_code(
|
||||
self,
|
||||
kernel_id: str,
|
||||
|
|
@ -124,133 +192,122 @@ class JupyterKernelManager:
|
|||
timeout: int = 300
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Execute code in the kernel.
|
||||
Execute code in kernel via ZMQ.
|
||||
|
||||
This is a simplified implementation that:
|
||||
1. Validates kernel exists
|
||||
2. Wraps code to capture output and maintain namespace
|
||||
3. Executes in the kernel's container
|
||||
4. Returns results
|
||||
Uses jupyter-client to:
|
||||
1. Send execute_request message
|
||||
2. Receive stream (stdout/stderr) messages
|
||||
3. Receive execute_result/display_data messages
|
||||
4. Collect and parse all output
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to execute in
|
||||
kernel_id: Kernel to execute in
|
||||
code: Python code to execute
|
||||
timeout: Maximum execution time
|
||||
timeout: Maximum execution time in seconds
|
||||
|
||||
Returns:
|
||||
ExecutionResult with output and status
|
||||
ExecutionResult with stdout, stderr, result
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found or execution fails
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
kernel_info = self._get_kernel(kernel_id)
|
||||
client = kernel_info.client
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
if not client:
|
||||
raise KernelError(f"Kernel {kernel_id} has no connected client")
|
||||
|
||||
# Update activity
|
||||
kernel_info.last_activity = datetime.utcnow()
|
||||
|
||||
# For simplified implementation, we execute code by creating
|
||||
# a Python script that:
|
||||
# 1. Loads namespace from kernel_info
|
||||
# 2. Executes user code
|
||||
# 3. Saves namespace back
|
||||
# 4. Returns result as JSON
|
||||
|
||||
# Execute in container using Python
|
||||
# In real implementation, this would use docker exec or similar
|
||||
# For now, we simulate execution with proper stdout/stderr capture
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Capture stdout and stderr
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
# Execute code
|
||||
_msg_id = client.execute(code, silent=False, store_history=True)
|
||||
|
||||
result_value = None
|
||||
error = None
|
||||
# Collect output
|
||||
stdout_parts = []
|
||||
stderr_parts = []
|
||||
result = None
|
||||
|
||||
# Wait for execution to complete
|
||||
while True:
|
||||
try:
|
||||
# Redirect stdout/stderr
|
||||
sys.stdout = stdout_capture
|
||||
sys.stderr = stderr_capture
|
||||
msg = client.get_iopub_msg(timeout=timeout)
|
||||
msg_type = msg['header']['msg_type']
|
||||
content = msg['content']
|
||||
|
||||
# Execute and update namespace
|
||||
exec_globals = kernel_info.namespace.copy()
|
||||
exec(code, exec_globals)
|
||||
if msg_type == 'stream':
|
||||
if content['name'] == 'stdout':
|
||||
stdout_parts.append(content['text'])
|
||||
elif content['name'] == 'stderr':
|
||||
stderr_parts.append(content['text'])
|
||||
|
||||
# Update kernel namespace
|
||||
kernel_info.namespace.update(exec_globals)
|
||||
elif msg_type == 'execute_result':
|
||||
result = content.get('data', {}).get('text/plain', '')
|
||||
|
||||
# Try to get result from last expression
|
||||
result_value = exec_globals.get('_', None)
|
||||
elif msg_type == 'error':
|
||||
stderr_parts.append('\n'.join(content['traceback']))
|
||||
|
||||
except SyntaxError as e:
|
||||
error = f"SyntaxError: {e.msg}"
|
||||
stderr_capture.write(f"{error}\n")
|
||||
except Exception as e:
|
||||
error = f"{type(e).__name__}: {str(e)}"
|
||||
stderr_capture.write(f"{error}\n")
|
||||
finally:
|
||||
# Restore stdout/stderr
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
elif msg_type == 'status':
|
||||
if content['execution_state'] == 'idle':
|
||||
break
|
||||
|
||||
# Get captured output
|
||||
stdout = stdout_capture.getvalue()
|
||||
stderr = stderr_capture.getvalue()
|
||||
except zmq.error.Again:
|
||||
break
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Update last activity
|
||||
kernel_info.last_activity = datetime.utcnow()
|
||||
|
||||
return ExecutionResult(
|
||||
success=(error is None),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
result=result_value,
|
||||
success=True,
|
||||
stdout=''.join(stdout_parts),
|
||||
stderr=''.join(stderr_parts),
|
||||
result=result,
|
||||
execution_time=execution_time,
|
||||
exit_code=0 if error is None else 1,
|
||||
error=error
|
||||
exit_code=0
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="",
|
||||
stdout='',
|
||||
stderr=str(e),
|
||||
result=None,
|
||||
execution_time=execution_time,
|
||||
exit_code=1,
|
||||
error=f"Execution failed: {str(e)}"
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def shutdown_kernel(self, kernel_id: str) -> None:
|
||||
"""
|
||||
Shutdown kernel and cleanup container.
|
||||
|
||||
1. Send shutdown_request via ZMQ
|
||||
2. Wait for kernel shutdown
|
||||
3. Stop and remove container
|
||||
4. Cleanup connection file
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to shutdown
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
kernel_id: Kernel to shutdown
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
kernel_info = self._get_kernel(kernel_id)
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
try:
|
||||
# Shutdown kernel
|
||||
if kernel_info.client:
|
||||
kernel_info.client.shutdown()
|
||||
kernel_info.client.stop_channels()
|
||||
|
||||
# Stop and remove container
|
||||
try:
|
||||
self.container_manager.stop_container(kernel_info.container_id, timeout=10)
|
||||
self.container_manager.stop_container(kernel_info.container_id)
|
||||
self.container_manager.remove_container(kernel_info.container_id)
|
||||
except Exception as e:
|
||||
# Log but don't fail - best effort cleanup
|
||||
pass
|
||||
|
||||
# Cleanup connection file
|
||||
kernel_info.connection_file.unlink(missing_ok=True)
|
||||
|
||||
finally:
|
||||
# Remove from registry
|
||||
del self.kernels[kernel_id]
|
||||
|
||||
|
|
@ -258,27 +315,25 @@ class JupyterKernelManager:
|
|||
"""
|
||||
Get list of variables in kernel namespace.
|
||||
|
||||
Executes introspection code:
|
||||
[var for var in dir() if not var.startswith('_')]
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to inspect
|
||||
kernel_id: Kernel to inspect
|
||||
|
||||
Returns:
|
||||
List of variable names (excluding private vars)
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
List of variable names
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
code = "[var for var in dir() if not var.startswith('_')]"
|
||||
result = self.execute_code(kernel_id, code, timeout=5)
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
# Filter out private variables and builtins
|
||||
variables = [
|
||||
name for name in kernel_info.namespace.keys()
|
||||
if not name.startswith('_') and name not in ['__builtins__']
|
||||
]
|
||||
|
||||
return variables
|
||||
if result.success and result.result:
|
||||
# Parse result (it's a string representation of a list)
|
||||
try:
|
||||
return eval(result.result) # nosec - controlled code
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
def get_variable_info(
|
||||
self,
|
||||
|
|
@ -286,68 +341,66 @@ class JupyterKernelManager:
|
|||
variable_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about a variable.
|
||||
Get detailed information about a variable.
|
||||
|
||||
Executes introspection code to get:
|
||||
- type(var).__name__
|
||||
- sys.getsizeof(var) if available
|
||||
- var.shape if hasattr(var, 'shape')
|
||||
- repr(var)[:100]
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel
|
||||
kernel_id: Kernel to inspect
|
||||
variable_name: Name of variable to inspect
|
||||
|
||||
Returns:
|
||||
Dictionary with type, size, and repr info
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel or variable not found
|
||||
Dict with type, size, shape, repr
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
if variable_name not in kernel_info.namespace:
|
||||
raise KernelError(f"Variable {variable_name} not found in kernel namespace")
|
||||
|
||||
value = kernel_info.namespace[variable_name]
|
||||
|
||||
info = {
|
||||
"type": type(value).__name__,
|
||||
"repr": repr(value)[:100], # Truncate long reprs
|
||||
}
|
||||
|
||||
# Add size for sized objects
|
||||
if hasattr(value, '__len__'):
|
||||
code = f"""
|
||||
import sys
|
||||
_var = {variable_name}
|
||||
_info = {{
|
||||
'type': type(_var).__name__,
|
||||
'repr': repr(_var)[:100],
|
||||
}}
|
||||
try:
|
||||
info["size"] = len(value)
|
||||
_info['size_bytes'] = sys.getsizeof(_var)
|
||||
except:
|
||||
pass
|
||||
if hasattr(_var, 'shape'):
|
||||
_info['shape'] = _var.shape
|
||||
_info
|
||||
"""
|
||||
result = self.execute_code(kernel_id, code, timeout=5)
|
||||
|
||||
# Add shape for array-like objects
|
||||
if hasattr(value, 'shape'):
|
||||
if result.success and result.result:
|
||||
try:
|
||||
info["shape"] = value.shape
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
return eval(result.result) # nosec - controlled code
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def restart_kernel(self, kernel_id: str) -> None:
|
||||
"""
|
||||
Restart kernel (reset namespace).
|
||||
Restart kernel (namespace reset, container kept).
|
||||
|
||||
Strategy: shutdown current kernel and start new one in same container.
|
||||
Note: In a full implementation, we'd use KernelManager.restart_kernel().
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to restart
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
kernel_id: Kernel to restart
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
kernel_info = self._get_kernel(kernel_id)
|
||||
|
||||
# Clear namespace to reset state
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
kernel_info.namespace.clear()
|
||||
# For now, just record activity - full restart implementation requires
|
||||
# KernelManager integration (not just BlockingKernelClient)
|
||||
# TODO: Implement proper kernel restart via KernelManager
|
||||
kernel_info.last_activity = datetime.utcnow()
|
||||
|
||||
def cleanup_idle_kernels(self, idle_timeout: timedelta) -> int:
|
||||
def cleanup_idle_kernels(
|
||||
self,
|
||||
idle_timeout: timedelta
|
||||
) -> int:
|
||||
"""
|
||||
Cleanup kernels idle longer than timeout.
|
||||
|
||||
|
|
@ -358,40 +411,70 @@ class JupyterKernelManager:
|
|||
Number of kernels cleaned up
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
kernels_to_remove = []
|
||||
cleaned_up = 0
|
||||
|
||||
for kernel_id, kernel_info in self.kernels.items():
|
||||
for kernel_id in list(self.kernels.keys()):
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
idle_time = now - kernel_info.last_activity
|
||||
if idle_time > idle_timeout:
|
||||
kernels_to_remove.append(kernel_id)
|
||||
|
||||
# Shutdown idle kernels
|
||||
for kernel_id in kernels_to_remove:
|
||||
if idle_time > idle_timeout:
|
||||
try:
|
||||
self.shutdown_kernel(kernel_id)
|
||||
cleaned_up += 1
|
||||
except Exception:
|
||||
# Best effort cleanup
|
||||
pass
|
||||
pass # Continue cleanup even if one fails
|
||||
|
||||
return len(kernels_to_remove)
|
||||
return cleaned_up
|
||||
|
||||
def _wrap_code_with_namespace(self, code: str, namespace: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Wrap code to load/save namespace.
|
||||
def _get_kernel(self, kernel_id: str) -> KernelInfo:
|
||||
"""Get kernel info or raise error."""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
return self.kernels[kernel_id]
|
||||
|
||||
This is a helper for the real implementation where code would be
|
||||
executed in a container with namespace persistence.
|
||||
def _generate_connection_info(self) -> Dict[str, Any]:
|
||||
"""Generate ZMQ connection information."""
|
||||
import secrets
|
||||
|
||||
Args:
|
||||
code: User code to wrap
|
||||
namespace: Current namespace state
|
||||
return {
|
||||
"shell_port": 0, # Let ZMQ assign
|
||||
"iopub_port": 0,
|
||||
"stdin_port": 0,
|
||||
"control_port": 0,
|
||||
"hb_port": 0,
|
||||
"ip": "127.0.0.1",
|
||||
"key": secrets.token_hex(32),
|
||||
"transport": "tcp",
|
||||
"signature_scheme": "hmac-sha256",
|
||||
"kernel_name": "python3"
|
||||
}
|
||||
|
||||
Returns:
|
||||
Wrapped code with namespace handling
|
||||
"""
|
||||
# In real implementation, this would serialize namespace,
|
||||
# inject it into container execution, run code, and extract
|
||||
# updated namespace.
|
||||
# For this simplified version, we don't need the wrapping
|
||||
# since we're executing directly in Python.
|
||||
return code
|
||||
def _create_connection_file(
|
||||
self,
|
||||
kernel_id: str,
|
||||
connection_info: Dict[str, Any]
|
||||
) -> Path:
|
||||
"""Create connection file for kernel."""
|
||||
# Create temp file
|
||||
fd, path = tempfile.mkstemp(suffix=f"-kernel-{kernel_id}.json")
|
||||
|
||||
# Write connection info
|
||||
with open(fd, 'w') as f:
|
||||
json.dump(connection_info, f)
|
||||
|
||||
return Path(path)
|
||||
|
||||
def _connect_client(self, connection_info: Dict[str, Any]) -> BlockingKernelClient:
|
||||
"""Connect jupyter-client to kernel."""
|
||||
client = BlockingKernelClient()
|
||||
client.load_connection_info(connection_info)
|
||||
client.start_channels()
|
||||
return client
|
||||
|
||||
def _verify_kernel(self, client: BlockingKernelClient, timeout: int = 10) -> bool:
|
||||
"""Verify kernel is responsive."""
|
||||
try:
|
||||
client.wait_for_ready(timeout=timeout)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
|
|
|||
418
src/mcp_forge/execution/jupyter/kernel_old.py
Normal file
418
src/mcp_forge/execution/jupyter/kernel_old.py
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
"""
|
||||
Jupyter kernel management for stateful execution.
|
||||
|
||||
This module implements a real Jupyter kernel manager that:
|
||||
- Uses jupyter-client (runs on host) to connect to kernels
|
||||
- Runs ipykernel processes inside Podman containers
|
||||
- Communicates via ZMQ protocol
|
||||
- Maintains 1:1 mapping of sessions to containers/kernels
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, List, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from jupyter_client import BlockingKernelClient
|
||||
from jupyter_client.manager import KernelManager
|
||||
import zmq
|
||||
|
||||
from mcp_forge.podman.containers import SecureContainerManager, ContainerConfig
|
||||
from mcp_forge.security.resource_limits import ResourceLimits
|
||||
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||
|
||||
|
||||
class KernelError(Exception):
|
||||
"""Raised when kernel operations fail."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class KernelInfo:
|
||||
"""Information about a running kernel."""
|
||||
kernel_id: str
|
||||
container_id: str
|
||||
session_id: str
|
||||
connection_info: Dict[str, Any] # ZMQ ports and keys
|
||||
started_at: datetime
|
||||
last_activity: datetime
|
||||
client: Optional[BlockingKernelClient] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"kernel_id": self.kernel_id,
|
||||
"container_id": self.container_id,
|
||||
"session_id": self.session_id,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"last_activity": self.last_activity.isoformat(),
|
||||
"connection_info": {
|
||||
"shell_port": self.connection_info.get("shell_port"),
|
||||
"iopub_port": self.connection_info.get("iopub_port"),
|
||||
"stdin_port": self.connection_info.get("stdin_port"),
|
||||
"control_port": self.connection_info.get("control_port"),
|
||||
"hb_port": self.connection_info.get("hb_port"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class JupyterKernelManager:
|
||||
"""
|
||||
Manages IPython kernels in containers for stateful execution.
|
||||
|
||||
This is a simplified implementation that uses containers to maintain
|
||||
state between executions. Each kernel runs in its own container and
|
||||
maintains a Python namespace that persists across execute calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container_manager: SecureContainerManager,
|
||||
image: str,
|
||||
resource_limits: Optional[ResourceLimits]
|
||||
):
|
||||
"""
|
||||
Initialize kernel manager.
|
||||
|
||||
Args:
|
||||
container_manager: Container lifecycle manager
|
||||
image: Docker/Podman image with Python/IPython
|
||||
resource_limits: Default resource limits for kernels (None to disable) (None to disable)
|
||||
"""
|
||||
self.container_manager = container_manager
|
||||
self.image = image
|
||||
self.resource_limits = resource_limits
|
||||
self.kernels: Dict[str, KernelInfo] = {}
|
||||
|
||||
def start_kernel(
|
||||
self,
|
||||
session_id: str,
|
||||
volumes: Optional[Dict[str, dict]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Start a new kernel in a container.
|
||||
|
||||
Creates a long-running container with Python that will accept
|
||||
and execute code, maintaining namespace state between executions.
|
||||
|
||||
Args:
|
||||
session_id: Session ID this kernel belongs to
|
||||
volumes: Optional volume mounts
|
||||
|
||||
Returns:
|
||||
kernel_id: Unique identifier for the kernel
|
||||
"""
|
||||
kernel_id = f"kernel-{uuid.uuid4().hex[:16]}"
|
||||
|
||||
# Create container configuration for long-running kernel
|
||||
# We use a shell that stays running so we can exec into it
|
||||
config = ContainerConfig(
|
||||
image=self.image,
|
||||
command=["sleep", "infinity"], # Keep container running
|
||||
resource_limits=self.resource_limits,
|
||||
volumes=volumes or {}
|
||||
)
|
||||
|
||||
# Create and start container
|
||||
container_id = self.container_manager.create_container(
|
||||
config,
|
||||
session_id=session_id,
|
||||
name=f"kernel-{kernel_id}"
|
||||
)
|
||||
self.container_manager.start_container(container_id)
|
||||
|
||||
# Register kernel
|
||||
now = datetime.utcnow()
|
||||
kernel_info = KernelInfo(
|
||||
kernel_id=kernel_id,
|
||||
container_id=container_id,
|
||||
session_id=session_id,
|
||||
started_at=now,
|
||||
last_activity=now
|
||||
)
|
||||
self.kernels[kernel_id] = kernel_info
|
||||
|
||||
return kernel_id
|
||||
|
||||
def execute_code(
|
||||
self,
|
||||
kernel_id: str,
|
||||
code: str,
|
||||
timeout: int = 300
|
||||
) -> ExecutionResult:
|
||||
"""
|
||||
Execute code in the kernel.
|
||||
|
||||
This is a simplified implementation that:
|
||||
1. Validates kernel exists
|
||||
2. Wraps code to capture output and maintain namespace
|
||||
3. Executes in the kernel's container
|
||||
4. Returns results
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to execute in
|
||||
code: Python code to execute
|
||||
timeout: Maximum execution time
|
||||
|
||||
Returns:
|
||||
ExecutionResult with output and status
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found or execution fails
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
# Update activity
|
||||
kernel_info.last_activity = datetime.utcnow()
|
||||
|
||||
# For simplified implementation, we execute code by creating
|
||||
# a Python script that:
|
||||
# 1. Loads namespace from kernel_info
|
||||
# 2. Executes user code
|
||||
# 3. Saves namespace back
|
||||
# 4. Returns result as JSON
|
||||
|
||||
# Execute in container using Python
|
||||
# In real implementation, this would use docker exec or similar
|
||||
# For now, we simulate execution with proper stdout/stderr capture
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Capture stdout and stderr
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
result_value = None
|
||||
error = None
|
||||
|
||||
try:
|
||||
# Redirect stdout/stderr
|
||||
sys.stdout = stdout_capture
|
||||
sys.stderr = stderr_capture
|
||||
|
||||
# Execute and update namespace
|
||||
exec_globals = kernel_info.namespace.copy()
|
||||
exec(code, exec_globals)
|
||||
|
||||
# Update kernel namespace
|
||||
kernel_info.namespace.update(exec_globals)
|
||||
|
||||
# Try to get result from last expression
|
||||
result_value = exec_globals.get('_', None)
|
||||
|
||||
except SyntaxError as e:
|
||||
error = f"SyntaxError: {e.msg}"
|
||||
stderr_capture.write(f"{error}\n")
|
||||
except Exception as e:
|
||||
error = f"{type(e).__name__}: {str(e)}"
|
||||
stderr_capture.write(f"{error}\n")
|
||||
finally:
|
||||
# Restore stdout/stderr
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# Get captured output
|
||||
stdout = stdout_capture.getvalue()
|
||||
stderr = stderr_capture.getvalue()
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
return ExecutionResult(
|
||||
success=(error is None),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
result=result_value,
|
||||
execution_time=execution_time,
|
||||
exit_code=0 if error is None else 1,
|
||||
error=error
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
return ExecutionResult(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="",
|
||||
result=None,
|
||||
execution_time=execution_time,
|
||||
exit_code=1,
|
||||
error=f"Execution failed: {str(e)}"
|
||||
)
|
||||
|
||||
def shutdown_kernel(self, kernel_id: str) -> None:
|
||||
"""
|
||||
Shutdown kernel and cleanup container.
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to shutdown
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
# Stop and remove container
|
||||
try:
|
||||
self.container_manager.stop_container(kernel_info.container_id, timeout=10)
|
||||
self.container_manager.remove_container(kernel_info.container_id)
|
||||
except Exception as e:
|
||||
# Log but don't fail - best effort cleanup
|
||||
pass
|
||||
|
||||
# Remove from registry
|
||||
del self.kernels[kernel_id]
|
||||
|
||||
def inspect_namespace(self, kernel_id: str) -> List[str]:
|
||||
"""
|
||||
Get list of variables in kernel namespace.
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to inspect
|
||||
|
||||
Returns:
|
||||
List of variable names (excluding private vars)
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
# Filter out private variables and builtins
|
||||
variables = [
|
||||
name for name in kernel_info.namespace.keys()
|
||||
if not name.startswith('_') and name not in ['__builtins__']
|
||||
]
|
||||
|
||||
return variables
|
||||
|
||||
def get_variable_info(
|
||||
self,
|
||||
kernel_id: str,
|
||||
variable_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about a variable.
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel
|
||||
variable_name: Name of variable to inspect
|
||||
|
||||
Returns:
|
||||
Dictionary with type, size, and repr info
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel or variable not found
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
|
||||
if variable_name not in kernel_info.namespace:
|
||||
raise KernelError(f"Variable {variable_name} not found in kernel namespace")
|
||||
|
||||
value = kernel_info.namespace[variable_name]
|
||||
|
||||
info = {
|
||||
"type": type(value).__name__,
|
||||
"repr": repr(value)[:100], # Truncate long reprs
|
||||
}
|
||||
|
||||
# Add size for sized objects
|
||||
if hasattr(value, '__len__'):
|
||||
try:
|
||||
info["size"] = len(value)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add shape for array-like objects
|
||||
if hasattr(value, 'shape'):
|
||||
try:
|
||||
info["shape"] = value.shape
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def restart_kernel(self, kernel_id: str) -> None:
|
||||
"""
|
||||
Restart kernel (reset namespace).
|
||||
|
||||
Args:
|
||||
kernel_id: ID of kernel to restart
|
||||
|
||||
Raises:
|
||||
KernelError: If kernel not found
|
||||
"""
|
||||
if kernel_id not in self.kernels:
|
||||
raise KernelError(f"Kernel {kernel_id} not found")
|
||||
|
||||
# Clear namespace to reset state
|
||||
kernel_info = self.kernels[kernel_id]
|
||||
kernel_info.namespace.clear()
|
||||
kernel_info.last_activity = datetime.utcnow()
|
||||
|
||||
def cleanup_idle_kernels(self, idle_timeout: timedelta) -> int:
|
||||
"""
|
||||
Cleanup kernels idle longer than timeout.
|
||||
|
||||
Args:
|
||||
idle_timeout: Maximum idle time before cleanup
|
||||
|
||||
Returns:
|
||||
Number of kernels cleaned up
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
kernels_to_remove = []
|
||||
|
||||
for kernel_id, kernel_info in self.kernels.items():
|
||||
idle_time = now - kernel_info.last_activity
|
||||
if idle_time > idle_timeout:
|
||||
kernels_to_remove.append(kernel_id)
|
||||
|
||||
# Shutdown idle kernels
|
||||
for kernel_id in kernels_to_remove:
|
||||
try:
|
||||
self.shutdown_kernel(kernel_id)
|
||||
except Exception:
|
||||
# Best effort cleanup
|
||||
pass
|
||||
|
||||
return len(kernels_to_remove)
|
||||
|
||||
def _wrap_code_with_namespace(self, code: str, namespace: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Wrap code to load/save namespace.
|
||||
|
||||
This is a helper for the real implementation where code would be
|
||||
executed in a container with namespace persistence.
|
||||
|
||||
Args:
|
||||
code: User code to wrap
|
||||
namespace: Current namespace state
|
||||
|
||||
Returns:
|
||||
Wrapped code with namespace handling
|
||||
"""
|
||||
# In real implementation, this would serialize namespace,
|
||||
# inject it into container execution, run code, and extract
|
||||
# updated namespace.
|
||||
# For this simplified version, we don't need the wrapping
|
||||
# since we're executing directly in Python.
|
||||
return code
|
||||
102
uv.lock
generated
102
uv.lock
generated
|
|
@ -542,6 +542,35 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-client"
|
||||
version = "8.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jupyter-core" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyzmq" },
|
||||
{ name = "tornado" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-core"
|
||||
version = "5.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.7.0"
|
||||
|
|
@ -643,6 +672,7 @@ version = "0.1.0"
|
|||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
{ name = "jupyter-client" },
|
||||
{ name = "podman" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
|
|
@ -658,6 +688,7 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastmcp", specifier = ">=2.14.5" },
|
||||
{ name = "jupyter-client", specifier = ">=8.8.0" },
|
||||
{ name = "podman", specifier = ">=5.7.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
|
|
@ -1117,6 +1148,49 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyzmq"
|
||||
version = "27.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "implementation_name == 'pypy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.0"
|
||||
|
|
@ -1311,6 +1385,34 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "traitlets"
|
||||
version = "5.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue