Packages
snakepit
0.4.3
0.13.0
0.12.0
0.11.1
0.11.0
0.10.1
0.10.0
0.9.1
0.9.0
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
High-performance pooler and session manager for external language integrations. Supports Python, Node.js, Ruby, and more with gRPC streaming, session management, and production-ready process cleanup.
Current section
Files
Jump to
Current section
Files
priv/python/snakepit_bridge/session_context.py
"""
Enhanced SessionContext with comprehensive variable support and intelligent caching.
"""
from typing import Any, Dict, List, Optional, Union, TypeVar, Generic
from contextlib import contextmanager
from datetime import datetime, timedelta
from threading import Lock
import weakref
import logging
import json
from dataclasses import dataclass, field
from enum import Enum
import asyncio
import inspect
from snakepit_bridge_pb2 import (
Variable, RegisterVariableRequest, RegisterVariableResponse,
GetVariableRequest, GetVariableResponse, SetVariableRequest, SetVariableResponse,
BatchGetVariablesRequest, BatchGetVariablesResponse,
BatchSetVariablesRequest, BatchSetVariablesResponse,
ListVariablesRequest, ListVariablesResponse,
DeleteVariableRequest, DeleteVariableResponse,
OptimizationStatus,
GetExposedElixirToolsRequest, GetExposedElixirToolsResponse,
ExecuteElixirToolRequest, ExecuteElixirToolResponse,
ToolSpec
)
from snakepit_bridge_pb2_grpc import BridgeServiceStub
from google.protobuf.any_pb2 import Any
from .types import (
VariableType,
TypeValidator,
serialize_value,
deserialize_value,
validate_constraints
)
logger = logging.getLogger(__name__)
T = TypeVar('T')
@dataclass
class CachedVariable:
"""Cached variable with TTL tracking."""
variable: Variable
cached_at: datetime
ttl: timedelta = field(default_factory=lambda: timedelta(seconds=5))
@property
def expired(self) -> bool:
return datetime.now() > self.cached_at + self.ttl
def refresh(self, variable: Variable):
self.variable = variable
self.cached_at = datetime.now()
class VariableNotFoundError(KeyError):
"""Raised when a variable is not found."""
pass
class VariableProxy(Generic[T]):
"""
Proxy object for lazy variable access.
Provides attribute-style access to variable values with
automatic synchronization.
"""
def __init__(self, context: 'SessionContext', name: str):
self._context = weakref.ref(context)
self._name = name
self._lock = Lock()
@property
def value(self) -> T:
"""Get the current value."""
ctx = self._context()
if ctx is None:
raise RuntimeError("SessionContext has been destroyed")
return ctx.get_variable(self._name)
@value.setter
def value(self, new_value: T):
"""Update the value."""
ctx = self._context()
if ctx is None:
raise RuntimeError("SessionContext has been destroyed")
ctx.update_variable(self._name, new_value)
def __repr__(self):
try:
return f"<Variable {self._name}={self.value}>"
except:
return f"<Variable {self._name} (not loaded)>"
class SessionContext:
"""
Enhanced session context with comprehensive variable support.
Provides intuitive Python API for variable management with
intelligent caching to minimize gRPC calls.
"""
def __init__(self, stub: BridgeServiceStub, session_id: str, strict_mode: bool = False):
self.stub = stub
self.session_id = session_id
self._cache: Dict[str, CachedVariable] = {}
self._cache_lock = Lock()
self._default_ttl = timedelta(seconds=5)
self._proxies: Dict[str, VariableProxy] = {}
self.strict_mode = strict_mode
# Tool registry remains from Stage 0
self._tools = {}
# Elixir tool proxies
self._elixir_tools = {}
self._load_elixir_tools()
logger.info(f"Created SessionContext for session {session_id} (strict_mode={strict_mode})")
def __enter__(self):
"""Support using SessionContext as a context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Automatically cleanup when exiting context."""
self.cleanup()
return False
# Variable Registration
def register_variable(
self,
name: str,
var_type: Union[str, VariableType],
initial_value: Any,
constraints: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, str]] = None,
ttl: Optional[timedelta] = None
) -> str:
"""
Register a new variable in the session.
Args:
name: Variable name (must be unique in session)
var_type: Type of the variable
initial_value: Initial value
constraints: Type-specific constraints
metadata: Additional metadata
ttl: Cache TTL for this variable
Returns:
Variable ID
Raises:
ValueError: If type validation fails
RuntimeError: If registration fails
"""
# Convert type
if isinstance(var_type, str):
var_type = VariableType[var_type.upper()]
# Validate value against type
validator = TypeValidator.get_validator(var_type)
validated_value = validator.validate(initial_value)
# Validate constraints if provided
if constraints:
validate_constraints(validated_value, var_type, constraints)
# Serialize for gRPC
value_any, binary_data = serialize_value(validated_value, var_type)
# Convert constraints to JSON
import json
constraints_json = json.dumps(constraints) if constraints else ""
request = RegisterVariableRequest(
session_id=self.session_id,
name=name,
type=var_type.value,
initial_value=value_any,
constraints_json=constraints_json,
metadata=metadata or {},
initial_binary_value=binary_data if binary_data else b""
)
response = self.stub.RegisterVariable(request)
if not response.success:
raise RuntimeError(f"Failed to register variable: {response.error_message}")
var_id = response.variable_id
logger.info(f"Registered variable {name} ({var_id}) of type {var_type}")
# Invalidate cache for this name
self._invalidate_cache(name)
return var_id
# Variable Access
def get_variable(self, identifier: str) -> Any:
"""
Get a variable's value by name or ID.
Uses cache when possible to minimize gRPC calls.
Args:
identifier: Variable name or ID
Returns:
The variable's current value
Raises:
VariableNotFoundError: If variable doesn't exist
"""
# Check cache first
with self._cache_lock:
cached = self._cache.get(identifier)
if cached and not cached.expired:
logger.debug(f"Cache hit for variable {identifier}")
return deserialize_value(
cached.variable.value,
VariableType(cached.variable.type)
)
# Cache miss - fetch from server
logger.debug(f"Cache miss for variable {identifier}")
request = GetVariableRequest(
session_id=self.session_id,
variable_identifier=identifier
)
try:
response = self.stub.GetVariable(request)
except Exception as e:
if "not found" in str(e).lower():
raise VariableNotFoundError(f"Variable not found: {identifier}")
raise
variable = response.variable
# Update cache (by both ID and name)
with self._cache_lock:
cached_var = CachedVariable(
variable=variable,
cached_at=datetime.now(),
ttl=self._default_ttl
)
self._cache[variable.id] = cached_var
self._cache[variable.name] = cached_var
# Deserialize and return value
return deserialize_value(
variable.value,
VariableType(variable.type),
variable.binary_value if variable.binary_value else None
)
def update_variable(
self,
identifier: str,
new_value: Any,
metadata: Optional[Dict[str, str]] = None
) -> None:
"""
Update a variable's value.
Performs write-through caching for consistency.
Args:
identifier: Variable name or ID
new_value: New value (will be validated)
metadata: Additional metadata for the update
Raises:
ValueError: If value doesn't match type/constraints
VariableNotFoundError: If variable doesn't exist
"""
# First get the variable to know its type
# This also populates the cache
self.get_variable(identifier)
# Get from cache to access type info
with self._cache_lock:
cached = self._cache.get(identifier)
if not cached:
raise RuntimeError("Variable should be in cache")
var_type = VariableType(cached.variable.type)
# Validate new value
validator = TypeValidator.get_validator(var_type)
validated_value = validator.validate(new_value)
# Serialize for gRPC
value_any, binary_data = serialize_value(validated_value, var_type)
request = SetVariableRequest(
session_id=self.session_id,
variable_identifier=identifier,
value=value_any,
metadata=metadata or {},
binary_value=binary_data if binary_data else b""
)
response = self.stub.SetVariable(request)
if not response.success:
raise RuntimeError(f"Failed to update variable: {response.error_message}")
# Invalidate cache for write-through consistency
self._invalidate_cache(identifier)
logger.info(f"Updated variable {identifier}")
def list_variables(self, pattern: Optional[str] = None) -> List[Dict[str, Any]]:
"""
List all variables or those matching a pattern.
Args:
pattern: Optional wildcard pattern (e.g., "temp_*")
Returns:
List of variable info dictionaries
"""
request = ListVariablesRequest(
session_id=self.session_id,
pattern=pattern or ""
)
response = self.stub.ListVariables(request)
variables = []
for var in response.variables:
var_type = VariableType(var.type)
variables.append({
'id': var.id,
'name': var.name,
'type': var_type.value,
'value': deserialize_value(var.value, var_type),
'version': var.version,
'constraints': json.loads(var.constraints_json) if var.constraints_json else {},
'metadata': dict(var.metadata),
'optimizing': var.optimization_status.optimizing if var.optimization_status else False
})
# Update cache opportunistically
with self._cache_lock:
cached_var = CachedVariable(
variable=var,
cached_at=datetime.now(),
ttl=self._default_ttl
)
self._cache[var.id] = cached_var
self._cache[var.name] = cached_var
return variables
def delete_variable(self, identifier: str) -> None:
"""
Delete a variable from the session.
Args:
identifier: Variable name or ID
Raises:
RuntimeError: If deletion fails
"""
request = DeleteVariableRequest(
session_id=self.session_id,
variable_identifier=identifier
)
response = self.stub.DeleteVariable(request)
if not response.success:
raise RuntimeError(f"Failed to delete variable: {response.error_message}")
# Remove from cache
self._invalidate_cache(identifier)
# Remove proxy if exists
self._proxies.pop(identifier, None)
logger.info(f"Deleted variable {identifier}")
# Batch Operations
def get_variables(self, identifiers: List[str]) -> Dict[str, Any]:
"""
Get multiple variables efficiently.
Uses cache and batches uncached requests.
Args:
identifiers: List of variable names or IDs
Returns:
Dict mapping identifier to value
"""
result = {}
uncached = []
# Check cache first
with self._cache_lock:
for identifier in identifiers:
cached = self._cache.get(identifier)
if cached and not cached.expired:
var_type = VariableType(cached.variable.type)
result[identifier] = deserialize_value(
cached.variable.value,
var_type
)
else:
uncached.append(identifier)
# Batch fetch uncached
if uncached:
request = BatchGetVariablesRequest(
session_id=self.session_id,
variable_identifiers=uncached
)
response = self.stub.GetVariables(request)
# Process found variables
for var_id, var in response.variables.items():
var_type = VariableType(var.type)
value = deserialize_value(var.value, var_type, var.binary_value if var.binary_value else None)
# Update result
result[var_id] = value
result[var.name] = value
# Update cache
with self._cache_lock:
cached_var = CachedVariable(
variable=var,
cached_at=datetime.now(),
ttl=self._default_ttl
)
self._cache[var.id] = cached_var
self._cache[var.name] = cached_var
# Handle missing
for missing in response.missing_variables:
if missing in identifiers:
raise VariableNotFoundError(f"Variable not found: {missing}")
return result
def update_variables(
self,
updates: Dict[str, Any],
atomic: bool = False,
metadata: Optional[Dict[str, str]] = None
) -> Dict[str, Union[bool, str]]:
"""
Update multiple variables efficiently.
Args:
updates: Dict mapping identifier to new value
atomic: If True, all updates must succeed
metadata: Metadata for all updates
Returns:
Dict mapping identifier to success/error
"""
# First, get all variables to know their types
var_info = self.get_variables(list(updates.keys()))
# Prepare updates with proper serialization
serialized_updates = {}
binary_updates = {}
for identifier, new_value in updates.items():
# Get variable type from cache
with self._cache_lock:
cached = self._cache.get(identifier)
if not cached:
continue
var_type = VariableType(cached.variable.type)
# Validate and serialize
validator = TypeValidator.get_validator(var_type)
validated = validator.validate(new_value)
value_any, binary_data = serialize_value(validated, var_type)
serialized_updates[identifier] = value_any
if binary_data:
binary_updates[identifier] = binary_data
request = BatchSetVariablesRequest(
session_id=self.session_id,
updates=serialized_updates,
atomic=atomic,
metadata=metadata or {},
binary_updates=binary_updates
)
response = self.stub.SetVariables(request)
if not response.success:
# Return errors
return {k: v for k, v in response.errors.items()}
# Process results
results = {}
for identifier in updates:
if identifier in response.errors:
results[identifier] = response.errors[identifier]
else:
results[identifier] = True
# Invalidate cache
self._invalidate_cache(identifier)
return results
# Pythonic Access Patterns
def __getitem__(self, name: str) -> Any:
"""Allow dict-style access: value = ctx['temperature']"""
return self.get_variable(name)
def __setitem__(self, name: str, value: Any):
"""
Allow dict-style updates: ctx['temperature'] = 0.8
In strict mode, only allows updating existing variables.
In normal mode, auto-registers new variables.
"""
try:
self.update_variable(name, value)
except VariableNotFoundError:
if self.strict_mode:
raise VariableNotFoundError(
f"Variable '{name}' not found. In strict mode, variables must be explicitly "
f"registered with register_variable() before assignment."
)
# Auto-register if doesn't exist (non-strict mode)
var_type = TypeValidator.infer_type(value)
self.register_variable(name, var_type, value)
def __contains__(self, name: str) -> bool:
"""Check if variable exists: 'temperature' in ctx"""
try:
self.get_variable(name)
return True
except VariableNotFoundError:
return False
@property
def v(self) -> 'VariableNamespace':
"""
Attribute-style access to variables.
Example:
ctx.v.temperature = 0.8
print(ctx.v.temperature)
"""
return VariableNamespace(self)
def variable(self, name: str) -> VariableProxy:
"""
Get a variable proxy for repeated access.
The proxy provides efficient access to a single variable.
"""
if name not in self._proxies:
self._proxies[name] = VariableProxy(self, name)
return self._proxies[name]
@contextmanager
def batch_updates(self):
"""
Context manager for batched updates.
Example:
with ctx.batch_updates() as batch:
batch['var1'] = 10
batch['var2'] = 20
batch['var3'] = 30
"""
batch = BatchUpdater(self)
yield batch
batch.commit()
# Cache Management
def set_cache_ttl(self, ttl: timedelta):
"""Set default cache TTL for variables."""
self._default_ttl = ttl
def clear_cache(self):
"""Clear all cached variables."""
with self._cache_lock:
self._cache.clear()
logger.info("Cleared variable cache")
def _invalidate_cache(self, identifier: str):
"""Invalidate cache entry for a variable."""
with self._cache_lock:
# Try to remove by identifier
self._cache.pop(identifier, None)
# Also check if it's cached by the other key
to_remove = []
for key, cached in self._cache.items():
if cached.variable.id == identifier or cached.variable.name == identifier:
to_remove.append(key)
for key in to_remove:
self._cache.pop(key, None)
def _deserialize_constraints(self, constraints_json: str) -> Dict[str, Any]:
"""Deserialize constraint values."""
if not constraints_json:
return {}
import json
try:
return json.loads(constraints_json)
except:
return {}
def cleanup(self, force: bool = False):
"""
Clean up resources associated with this session context.
This method:
1. Clears local caches and proxies
2. Sends a CleanupSession RPC to the server to release server-side resources
Args:
force: If True, forces cleanup even if there are active references
"""
# Clear local resources first
self.clear_cache()
self._proxies.clear()
# Send cleanup request to server
try:
from snakepit_bridge_pb2 import CleanupSessionRequest
request = CleanupSessionRequest(
session_id=self.session_id,
force=force
)
response = self.stub.CleanupSession(request)
if response.success:
logger.info(f"Successfully cleaned up session {self.session_id} "
f"(cleaned {response.resources_cleaned} resources)")
else:
logger.warning(f"Failed to cleanup session {self.session_id} on server")
except Exception as e:
logger.error(f"Error during session cleanup: {e}")
logger.info(f"Cleaned up session context for {self.session_id}")
# Existing tool methods remain...
def register_tool(self, tool_class):
"""Register a tool (from Stage 0)."""
# Implementation remains from Stage 0
pass
def call_tool(self, tool_name: str, **kwargs):
"""Call a tool (Python or Elixir)."""
# Check if it's an Elixir tool first
if tool_name in self._elixir_tools:
return self.call_elixir_tool(tool_name, **kwargs)
# Otherwise, try Python tools
if tool_name in self._tools:
tool = self._tools[tool_name]
return tool(**kwargs)
raise ValueError(f"Tool '{tool_name}' not found")
def _load_elixir_tools(self):
"""Load available Elixir tools from the server."""
try:
request = GetExposedElixirToolsRequest(session_id=self.session_id)
response = self.stub.GetExposedElixirTools(request)
# Handle async stub returning UnaryUnaryCall - properly invoke it
if hasattr(response, '__await__') or hasattr(response, 'result'):
# This is a UnaryUnaryCall object, get the actual result
try:
response = response.result()
except AttributeError:
# Try calling it directly if it's a callable
if callable(response):
response = response()
else:
logger.warning("GetExposedElixirTools returned UnaryUnaryCall but couldn't extract result")
return
for tool_spec in response.tools:
proxy = self._create_tool_proxy(tool_spec)
self._elixir_tools[tool_spec.name] = proxy
logger.info(f"Loaded {len(self._elixir_tools)} Elixir tools for session {self.session_id}")
except Exception as e:
logger.warning(f"Failed to load Elixir tools: {e}")
def _create_tool_proxy(self, tool_spec: ToolSpec):
"""Create a Python callable proxy for an Elixir tool."""
def proxy(**kwargs):
return self.call_elixir_tool(tool_spec.name, **kwargs)
# Set metadata on the proxy function
proxy.__name__ = tool_spec.name
proxy.__doc__ = tool_spec.description
# Add parameter information if available
if tool_spec.parameters:
param_info = []
for param in tool_spec.parameters:
param_str = f"{param.name}: {param.type}"
if param.required:
param_str += " (required)"
if param.description:
param_str += f" - {param.description}"
param_info.append(param_str)
if proxy.__doc__:
proxy.__doc__ += "\n\nParameters:\n" + "\n".join(param_info)
else:
proxy.__doc__ = "Parameters:\n" + "\n".join(param_info)
return proxy
def call_elixir_tool(self, tool_name: str, **kwargs):
"""
Execute an Elixir tool via gRPC.
Args:
tool_name: Name of the Elixir tool to execute
**kwargs: Parameters to pass to the tool
Returns:
The result from the Elixir tool
Raises:
RuntimeError: If the tool execution fails
"""
# Serialize parameters
parameters = {}
for key, value in kwargs.items():
try:
# Serialize to Any protobuf
any_value = Any()
# For now, use JSON serialization
any_value.type_url = "type.googleapis.com/google.protobuf.StringValue"
any_value.value = json.dumps(value).encode('utf-8')
parameters[key] = any_value
except Exception as e:
raise ValueError(f"Failed to serialize parameter '{key}': {e}")
# Create request
request = ExecuteElixirToolRequest(
session_id=self.session_id,
tool_name=tool_name,
parameters=parameters,
metadata={}
)
# Execute tool
try:
response = self.stub.ExecuteElixirTool(request)
if response.success:
# Deserialize result
if response.result and response.result.value:
# For now, assume JSON deserialization
result = json.loads(response.result.value.decode('utf-8'))
return result
else:
return None
else:
raise RuntimeError(f"Elixir tool execution failed: {response.error_message}")
except Exception as e:
logger.error(f"Error calling Elixir tool '{tool_name}': {e}")
raise
@property
def elixir_tools(self) -> Dict[str, Any]:
"""Get dictionary of available Elixir tools."""
return self._elixir_tools.copy()
class VariableNamespace:
"""
Namespace for attribute-style variable access.
Provides ctx.v.variable_name syntax.
"""
def __init__(self, context: SessionContext):
self._context = weakref.ref(context)
def __getattr__(self, name: str) -> Any:
ctx = self._context()
if ctx is None:
raise RuntimeError("SessionContext has been destroyed")
return ctx.get_variable(name)
def __setattr__(self, name: str, value: Any):
if name.startswith('_'):
super().__setattr__(name, value)
else:
ctx = self._context()
if ctx is None:
raise RuntimeError("SessionContext has been destroyed")
try:
ctx.update_variable(name, value)
except VariableNotFoundError:
# Auto-register
var_type = TypeValidator.infer_type(value)
ctx.register_variable(name, var_type, value)
class BatchUpdater:
"""Collect updates for batch submission."""
def __init__(self, context: SessionContext):
self.context = context
self.updates = {}
def __setitem__(self, name: str, value: Any):
self.updates[name] = value
def commit(self, atomic: bool = False):
"""Commit all updates."""
if self.updates:
return self.context.update_variables(self.updates, atomic=atomic)
return {}