From 244a3e55748f4066dfe52fe90a0b2c3b7197db82 Mon Sep 17 00:00:00 2001 From: Hans Aschauer Date: Sat, 7 Feb 2026 08:06:44 +0100 Subject: [PATCH] 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 --- docs/architecture1.md | 54 +- docs/todo.md | 97 +++- pyproject.toml | 1 + src/mcp_forge/execution/jupyter/kernel.py | 521 ++++++++++-------- src/mcp_forge/execution/jupyter/kernel_old.py | 418 ++++++++++++++ uv.lock | 102 ++++ 6 files changed, 940 insertions(+), 253 deletions(-) create mode 100644 src/mcp_forge/execution/jupyter/kernel_old.py diff --git a/docs/architecture1.md b/docs/architecture1.md index 9a0fbaf..bb8df94 100644 --- a/docs/architecture1.md +++ b/docs/architecture1.md @@ -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 diff --git a/docs/todo.md b/docs/todo.md index 52e9a07..2f42b5e 100644 --- a/docs/todo.md +++ b/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 diff --git a/pyproject.toml b/pyproject.toml index f63446f..0d12258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/mcp_forge/execution/jupyter/kernel.py b/src/mcp_forge/execution/jupyter/kernel.py index f32fd61..eb85845 100644 --- a/src/mcp_forge/execution/jupyter/kernel.py +++ b/src/mcp_forge/execution/jupyter/kernel.py @@ -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,49 +102,88 @@ 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 - config = ContainerConfig( - image=self.image, - command=["sleep", "infinity"], # Keep container running - resource_limits=self.resource_limits, - volumes=volumes or {} - ) + # Generate connection info + connection_info = self._generate_connection_info() - # 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) + # Create connection file + connection_file = self._create_connection_file(kernel_id, connection_info) - # 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 + try: + # Create container with ipykernel + config = ContainerConfig( + image=self.image, + command=[ + "python", "-m", "ipykernel_launcher", + "-f", f"/tmp/kernel-{kernel_id}.json" + ], + resource_limits=self.resource_limits, + volumes=volumes or {}, + # TODO: Port mappings for ZMQ + # TODO: Mount connection file into container + ) + + container_id = self.container_manager.create_container( + config, + session_id=session_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, + 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, @@ -124,161 +192,148 @@ 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 - try: - # Redirect stdout/stderr - sys.stdout = stdout_capture - sys.stderr = stderr_capture + # Wait for execution to complete + while True: + try: + msg = client.get_iopub_msg(timeout=timeout) + msg_type = msg['header']['msg_type'] + content = msg['content'] + + if msg_type == 'stream': + if content['name'] == 'stdout': + stdout_parts.append(content['text']) + elif content['name'] == 'stderr': + stderr_parts.append(content['text']) + + elif msg_type == 'execute_result': + result = content.get('data', {}).get('text/plain', '') + + elif msg_type == 'error': + stderr_parts.append('\n'.join(content['traceback'])) + + elif msg_type == 'status': + if content['execution_state'] == 'idle': + break - # 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() + 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] - - # Stop and remove container try: - self.container_manager.stop_container(kernel_info.container_id, timeout=10) + # Shutdown kernel + if kernel_info.client: + kernel_info.client.shutdown() + kernel_info.client.stop_channels() + + # Stop and remove container + 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 - - # Remove from registry - del self.kernels[kernel_id] + + # Cleanup connection file + kernel_info.connection_file.unlink(missing_ok=True) + + finally: + # Remove from registry + del self.kernels[kernel_id] def inspect_namespace(self, kernel_id: str) -> List[str]: """ 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") + code = f""" +import sys +_var = {variable_name} +_info = {{ + 'type': type(_var).__name__, + 'repr': repr(_var)[:100], +}} +try: + _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) - 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__'): + if result.success and result.result: 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 + 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) + try: + self.shutdown_kernel(kernel_id) + cleaned_up += 1 + except Exception: + pass # Continue cleanup even if one fails - # 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) + 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] + + def _generate_connection_info(self) -> Dict[str, Any]: + """Generate ZMQ connection information.""" + import secrets - This is a helper for the real implementation where code would be - executed in a container with namespace persistence. + 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" + } + + 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") - Args: - code: User code to wrap - namespace: Current namespace state + # Write connection info + with open(fd, 'w') as f: + json.dump(connection_info, f) - 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 + 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 diff --git a/src/mcp_forge/execution/jupyter/kernel_old.py b/src/mcp_forge/execution/jupyter/kernel_old.py new file mode 100644 index 0000000..3540426 --- /dev/null +++ b/src/mcp_forge/execution/jupyter/kernel_old.py @@ -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 diff --git a/uv.lock b/uv.lock index 4643a5f..1ee4537 100644 --- a/uv.lock +++ b/uv.lock @@ -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"