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:
Hans Aschauer 2026-02-07 08:06:44 +01:00
parent 372af75b90
commit 244a3e5574
6 changed files with 940 additions and 253 deletions

View file

@ -291,17 +291,59 @@ execute_python(
**Purpose:** Stateful, multi-step workflows **Purpose:** Stateful, multi-step workflows
**Implementation:** **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 - Keep kernel running for session lifetime
- Execute code cells via Jupyter protocol (ZMQ) - Execute code cells via Jupyter protocol (ZMQ)
- Maintain namespace between executions - Maintain namespace between executions
- Support rich output formats - 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:** **Characteristics:**
- State persists between calls - State persists between calls within same session
- Variable persistence - Variable persistence in session namespace
- Interactive workflow support - Interactive workflow support
- Higher resource usage - Higher resource usage per session
- Full isolation between sessions
**Use cases:** **Use cases:**
- Multi-step data analysis - Multi-step data analysis
@ -311,9 +353,11 @@ execute_python(
**Session Management:** **Session Management:**
- Sessions identified by unique ID - Sessions identified by unique ID
- Each session gets dedicated container and kernel
- Automatic timeout after inactivity (configurable, default: 1 hour) - Automatic timeout after inactivity (configurable, default: 1 hour)
- Manual cleanup via session deletion - 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 ### 3. Custom Environment Building

View file

@ -1079,22 +1079,42 @@ class SimpleBackend:
- All tests use mocked ZMQ and containers - All tests use mocked ZMQ and containers
**Implementation requirements:** **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 ```python
from jupyter_client import KernelManager, BlockingKernelClient from jupyter_client import KernelManager, BlockingKernelClient
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import zmq import zmq
import json
import tempfile
@dataclass @dataclass
class KernelInfo: class KernelInfo:
"""Information about running kernel.""" """Information about running kernel."""
kernel_id: str kernel_id: str
container_id: str container_id: str
connection_file: Path session_id: str
connection_info: Dict[str, Any] # ZMQ ports and keys
started_at: datetime started_at: datetime
last_activity: datetime last_activity: datetime
client: Optional[BlockingKernelClient] = None
class JupyterKernelManager: 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__( def __init__(
self, self,
@ -1103,7 +1123,7 @@ class JupyterKernelManager:
resource_limits: ResourceLimits resource_limits: ResourceLimits
): ):
self.container_manager = container_manager self.container_manager = container_manager
self.image = image self.image = image # Image with ipykernel installed
self.resource_limits = resource_limits self.resource_limits = resource_limits
self.kernels: Dict[str, KernelInfo] = {} self.kernels: Dict[str, KernelInfo] = {}
@ -1113,17 +1133,18 @@ class JupyterKernelManager:
volumes: Optional[Dict[str, dict]] = None volumes: Optional[Dict[str, dict]] = None
) -> str: ) -> str:
""" """
Start IPython kernel in container. Start IPython kernel in dedicated container.
Process: Process:
1. Create container with IPython kernel 1. Generate ZMQ connection info (ports, keys)
2. Start container 2. Create container with ipykernel
3. Wait for kernel to be ready 3. Start ipykernel process with connection file
4. Connect to kernel via ZMQ 4. Wait for kernel to be ready
5. Verify kernel is responsive 5. Connect jupyter-client to kernel via ZMQ
6. Verify kernel is responsive
Returns: Returns:
kernel_id kernel_id: Unique identifier for this kernel
""" """
pass pass
@ -1134,23 +1155,34 @@ class JupyterKernelManager:
timeout: int = 300 timeout: int = 300
) -> ExecutionResult: ) -> ExecutionResult:
""" """
Execute code in kernel. Execute code in kernel via ZMQ.
Uses ZMQ to send execute request and receive result. Uses jupyter-client to:
Captures stdout, stderr, display data, and result. 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 pass
def shutdown_kernel(self, kernel_id: str) -> None: 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 pass
def inspect_namespace(self, kernel_id: str) -> List[str]: def inspect_namespace(self, kernel_id: str) -> List[str]:
""" """
Get list of variables in kernel namespace. Get list of variables in kernel namespace.
Executes: dir() to get variable names Executes introspection code:
Filters out private variables and builtins [var for var in dir() if not var.startswith('_')]
""" """
pass pass
@ -1160,20 +1192,24 @@ class JupyterKernelManager:
variable_name: str variable_name: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get information about a variable. Get detailed information about a variable.
Returns: Executes introspection code to get:
{ - type(var).__name__
"type": str, - sys.getsizeof(var) if available
"size_bytes": int (if applicable), - var.shape if hasattr(var, 'shape')
"shape": tuple (if array-like), - repr(var)[:100]
"repr": str (shortened)
} Returns dict with type, size, shape, repr
""" """
pass pass
def restart_kernel(self, kernel_id: str) -> None: 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 pass
def cleanup_idle_kernels( def cleanup_idle_kernels(
@ -1190,14 +1226,17 @@ class JupyterKernelManager:
``` ```
**Acceptance criteria:** **Acceptance criteria:**
- [ ] Kernel starts successfully in container - [ ] jupyter-client dependency in server (host), ipykernel in container image
- [ ] ZMQ connection established correctly - [ ] Kernel starts successfully in dedicated container (1 per session)
- [ ] Code execution works via ZMQ protocol - [ ] ZMQ connection established correctly (ports exposed from container)
- [ ] Namespace persists between executions - [ ] Code execution works via Jupyter message protocol
- [ ] Namespace persists between executions within same session
- [ ] Each session has completely isolated namespace
- [ ] Variable introspection works - [ ] Variable introspection works
- [ ] Variable info includes type, size, shape - [ ] Variable info includes type, size, shape
- [ ] Kernel shutdown cleans up container - [ ] Kernel shutdown cleans up container
- [ ] Idle kernel cleanup works - [ ] Idle kernel cleanup works
- [ ] Kernel restart clears namespace but keeps container
- [ ] Kernel restart works - [ ] Kernel restart works
- [ ] Handles kernel crashes gracefully - [ ] Handles kernel crashes gracefully
- [ ] All tests use mocked ZMQ and containers - [ ] All tests use mocked ZMQ and containers

View file

@ -9,6 +9,7 @@ authors = [
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastmcp>=2.14.5", "fastmcp>=2.14.5",
"jupyter-client>=8.8.0",
"podman>=5.7.0", "podman>=5.7.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",

View file

@ -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 typing import Dict, Optional, List, Any
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid import uuid
import json import json
import sys import tempfile
import io 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.podman.containers import SecureContainerManager, ContainerConfig
from mcp_forge.security.resource_limits import ResourceLimits from mcp_forge.security.resource_limits import ResourceLimits
@ -24,9 +36,11 @@ class KernelInfo:
kernel_id: str kernel_id: str
container_id: str container_id: str
session_id: str session_id: str
connection_file: Path
connection_info: Dict[str, Any] # ZMQ ports and keys
started_at: datetime started_at: datetime
last_activity: datetime last_activity: datetime
namespace: Dict[str, Any] = field(default_factory=dict) client: Optional[BlockingKernelClient] = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
@ -36,31 +50,46 @@ class KernelInfo:
"session_id": self.session_id, "session_id": self.session_id,
"started_at": self.started_at.isoformat(), "started_at": self.started_at.isoformat(),
"last_activity": self.last_activity.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: 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 Architecture:
state between executions. Each kernel runs in its own container and - This class runs on host (MCP-Forge server process)
maintains a Python namespace that persists across execute calls. - 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__( def __init__(
self, self,
container_manager: SecureContainerManager, container_manager: SecureContainerManager,
image: str, image: str,
resource_limits: Optional[ResourceLimits] resource_limits: Optional[ResourceLimits] = None
): ):
""" """
Initialize kernel manager. Initialize kernel manager.
Args: Args:
container_manager: Container lifecycle manager container_manager: Container lifecycle manager
image: Docker/Podman image with Python/IPython image: Docker/Podman image with ipykernel installed
resource_limits: Default resource limits for kernels (None to disable) (None to disable) resource_limits: Default resource limits for kernels
""" """
self.container_manager = container_manager self.container_manager = container_manager
self.image = image self.image = image
@ -73,50 +102,89 @@ class JupyterKernelManager:
volumes: Optional[Dict[str, dict]] = None volumes: Optional[Dict[str, dict]] = None
) -> str: ) -> str:
""" """
Start a new kernel in a container. Start IPython kernel in dedicated container.
Creates a long-running container with Python that will accept Process:
and execute code, maintaining namespace state between executions. 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: Args:
session_id: Session ID this kernel belongs to session_id: Session ID this kernel belongs to
volumes: Optional volume mounts volumes: Optional volume mounts
Returns: 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]}" kernel_id = f"kernel-{uuid.uuid4().hex[:16]}"
# Create container configuration for long-running kernel # Generate connection info
# We use a shell that stays running so we can exec into it 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( config = ContainerConfig(
image=self.image, 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, 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( container_id = self.container_manager.create_container(
config, config,
session_id=session_id, session_id=session_id,
name=f"kernel-{kernel_id}" name=f"jupyter-{kernel_id}"
) )
# Start container
self.container_manager.start_container(container_id) 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 # Register kernel
now = datetime.utcnow() now = datetime.utcnow()
kernel_info = KernelInfo( kernel_info = KernelInfo(
kernel_id=kernel_id, kernel_id=kernel_id,
container_id=container_id, container_id=container_id,
session_id=session_id, session_id=session_id,
connection_file=connection_file,
connection_info=connection_info,
started_at=now, started_at=now,
last_activity=now last_activity=now,
client=client
) )
self.kernels[kernel_id] = kernel_info self.kernels[kernel_id] = kernel_info
return kernel_id 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( def execute_code(
self, self,
kernel_id: str, kernel_id: str,
@ -124,133 +192,122 @@ class JupyterKernelManager:
timeout: int = 300 timeout: int = 300
) -> ExecutionResult: ) -> ExecutionResult:
""" """
Execute code in the kernel. Execute code in kernel via ZMQ.
This is a simplified implementation that: Uses jupyter-client to:
1. Validates kernel exists 1. Send execute_request message
2. Wraps code to capture output and maintain namespace 2. Receive stream (stdout/stderr) messages
3. Executes in the kernel's container 3. Receive execute_result/display_data messages
4. Returns results 4. Collect and parse all output
Args: Args:
kernel_id: ID of kernel to execute in kernel_id: Kernel to execute in
code: Python code to execute code: Python code to execute
timeout: Maximum execution time timeout: Maximum execution time in seconds
Returns: Returns:
ExecutionResult with output and status ExecutionResult with stdout, stderr, result
Raises: Raises:
KernelError: If kernel not found or execution fails KernelError: If kernel not found or execution fails
""" """
if kernel_id not in self.kernels: kernel_info = self._get_kernel(kernel_id)
raise KernelError(f"Kernel {kernel_id} not found") 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() start_time = time.time()
try: try:
# Capture stdout and stderr # Execute code
stdout_capture = io.StringIO() _msg_id = client.execute(code, silent=False, store_history=True)
stderr_capture = io.StringIO()
old_stdout = sys.stdout
old_stderr = sys.stderr
result_value = None # Collect output
error = None stdout_parts = []
stderr_parts = []
result = None
# Wait for execution to complete
while True:
try: try:
# Redirect stdout/stderr msg = client.get_iopub_msg(timeout=timeout)
sys.stdout = stdout_capture msg_type = msg['header']['msg_type']
sys.stderr = stderr_capture content = msg['content']
# Execute and update namespace if msg_type == 'stream':
exec_globals = kernel_info.namespace.copy() if content['name'] == 'stdout':
exec(code, exec_globals) stdout_parts.append(content['text'])
elif content['name'] == 'stderr':
stderr_parts.append(content['text'])
# Update kernel namespace elif msg_type == 'execute_result':
kernel_info.namespace.update(exec_globals) result = content.get('data', {}).get('text/plain', '')
# Try to get result from last expression elif msg_type == 'error':
result_value = exec_globals.get('_', None) stderr_parts.append('\n'.join(content['traceback']))
except SyntaxError as e: elif msg_type == 'status':
error = f"SyntaxError: {e.msg}" if content['execution_state'] == 'idle':
stderr_capture.write(f"{error}\n") break
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 except zmq.error.Again:
stdout = stdout_capture.getvalue() break
stderr = stderr_capture.getvalue()
execution_time = time.time() - start_time execution_time = time.time() - start_time
# Update last activity
kernel_info.last_activity = datetime.utcnow()
return ExecutionResult( return ExecutionResult(
success=(error is None), success=True,
stdout=stdout, stdout=''.join(stdout_parts),
stderr=stderr, stderr=''.join(stderr_parts),
result=result_value, result=result,
execution_time=execution_time, execution_time=execution_time,
exit_code=0 if error is None else 1, exit_code=0
error=error
) )
except Exception as e: except Exception as e:
execution_time = time.time() - start_time execution_time = time.time() - start_time
return ExecutionResult( return ExecutionResult(
success=False, success=False,
stdout="", stdout='',
stderr="", stderr=str(e),
result=None, result=None,
execution_time=execution_time, execution_time=execution_time,
exit_code=1, exit_code=1,
error=f"Execution failed: {str(e)}" error=str(e)
) )
def shutdown_kernel(self, kernel_id: str) -> None: 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
4. Cleanup connection file
Args: Args:
kernel_id: ID of kernel to shutdown kernel_id: Kernel to shutdown
Raises:
KernelError: If kernel not found
""" """
if kernel_id not in self.kernels: kernel_info = self._get_kernel(kernel_id)
raise KernelError(f"Kernel {kernel_id} not found")
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 # Stop and remove container
try: self.container_manager.stop_container(kernel_info.container_id)
self.container_manager.stop_container(kernel_info.container_id, timeout=10)
self.container_manager.remove_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 # Remove from registry
del self.kernels[kernel_id] del self.kernels[kernel_id]
@ -258,27 +315,25 @@ class JupyterKernelManager:
""" """
Get list of variables in kernel namespace. Get list of variables in kernel namespace.
Executes introspection code:
[var for var in dir() if not var.startswith('_')]
Args: Args:
kernel_id: ID of kernel to inspect kernel_id: Kernel to inspect
Returns: Returns:
List of variable names (excluding private vars) List of variable names
Raises:
KernelError: If kernel not found
""" """
if kernel_id not in self.kernels: code = "[var for var in dir() if not var.startswith('_')]"
raise KernelError(f"Kernel {kernel_id} not found") result = self.execute_code(kernel_id, code, timeout=5)
kernel_info = self.kernels[kernel_id] if result.success and result.result:
# Parse result (it's a string representation of a list)
# Filter out private variables and builtins try:
variables = [ return eval(result.result) # nosec - controlled code
name for name in kernel_info.namespace.keys() except Exception:
if not name.startswith('_') and name not in ['__builtins__'] return []
] return []
return variables
def get_variable_info( def get_variable_info(
self, self,
@ -286,68 +341,66 @@ class JupyterKernelManager:
variable_name: str variable_name: str
) -> Dict[str, Any]: ) -> 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: Args:
kernel_id: ID of kernel kernel_id: Kernel to inspect
variable_name: Name of variable to inspect variable_name: Name of variable to inspect
Returns: Returns:
Dictionary with type, size, and repr info Dict with type, size, shape, repr
Raises:
KernelError: If kernel or variable not found
""" """
if kernel_id not in self.kernels: code = f"""
raise KernelError(f"Kernel {kernel_id} not found") import sys
_var = {variable_name}
kernel_info = self.kernels[kernel_id] _info = {{
'type': type(_var).__name__,
if variable_name not in kernel_info.namespace: 'repr': repr(_var)[:100],
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: try:
info["size"] = len(value) _info['size_bytes'] = sys.getsizeof(_var)
except: except:
pass 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 result.success and result.result:
if hasattr(value, 'shape'):
try: try:
info["shape"] = value.shape return eval(result.result) # nosec - controlled code
except: except Exception:
pass return {}
return {}
return info
def restart_kernel(self, kernel_id: str) -> None: 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: Args:
kernel_id: ID of kernel to restart kernel_id: Kernel to restart
Raises:
KernelError: If kernel not found
""" """
if kernel_id not in self.kernels: kernel_info = self._get_kernel(kernel_id)
raise KernelError(f"Kernel {kernel_id} not found")
# Clear namespace to reset state # For now, just record activity - full restart implementation requires
kernel_info = self.kernels[kernel_id] # KernelManager integration (not just BlockingKernelClient)
kernel_info.namespace.clear() # TODO: Implement proper kernel restart via KernelManager
kernel_info.last_activity = datetime.utcnow() 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. Cleanup kernels idle longer than timeout.
@ -358,40 +411,70 @@ class JupyterKernelManager:
Number of kernels cleaned up Number of kernels cleaned up
""" """
now = datetime.utcnow() 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 idle_time = now - kernel_info.last_activity
if idle_time > idle_timeout:
kernels_to_remove.append(kernel_id)
# Shutdown idle kernels if idle_time > idle_timeout:
for kernel_id in kernels_to_remove:
try: try:
self.shutdown_kernel(kernel_id) self.shutdown_kernel(kernel_id)
cleaned_up += 1
except Exception: except Exception:
# Best effort cleanup pass # Continue cleanup even if one fails
pass
return len(kernels_to_remove) return cleaned_up
def _wrap_code_with_namespace(self, code: str, namespace: Dict[str, Any]) -> str: def _get_kernel(self, kernel_id: str) -> KernelInfo:
""" """Get kernel info or raise error."""
Wrap code to load/save namespace. 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 def _generate_connection_info(self) -> Dict[str, Any]:
executed in a container with namespace persistence. """Generate ZMQ connection information."""
import secrets
Args: return {
code: User code to wrap "shell_port": 0, # Let ZMQ assign
namespace: Current namespace state "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: def _create_connection_file(
Wrapped code with namespace handling self,
""" kernel_id: str,
# In real implementation, this would serialize namespace, connection_info: Dict[str, Any]
# inject it into container execution, run code, and extract ) -> Path:
# updated namespace. """Create connection file for kernel."""
# For this simplified version, we don't need the wrapping # Create temp file
# since we're executing directly in Python. fd, path = tempfile.mkstemp(suffix=f"-kernel-{kernel_id}.json")
return code
# 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

View 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
View file

@ -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" }, { 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]] [[package]]
name = "keyring" name = "keyring"
version = "25.7.0" version = "25.7.0"
@ -643,6 +672,7 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "jupyter-client" },
{ name = "podman" }, { name = "podman" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyyaml" }, { name = "pyyaml" },
@ -658,6 +688,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=2.14.5" }, { name = "fastmcp", specifier = ">=2.14.5" },
{ name = "jupyter-client", specifier = ">=8.8.0" },
{ name = "podman", specifier = ">=5.7.0" }, { name = "podman", specifier = ">=5.7.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { 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" }, { 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]] [[package]]
name = "redis" name = "redis"
version = "7.1.0" 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" }, { 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]] [[package]]
name = "typer" name = "typer"
version = "0.21.1" version = "0.21.1"