diff --git a/src/tetra_rp/client.py b/src/tetra_rp/client.py index a61c4636..dd086a7c 100644 --- a/src/tetra_rp/client.py +++ b/src/tetra_rp/client.py @@ -1,17 +1,19 @@ +import inspect import logging from functools import wraps -from typing import List -from .core.resources import ServerlessResource, ResourceManager -from .stubs import stub_resource +from typing import List, Optional +from .core.resources import ResourceManager, ServerlessResource +from .execute_class import create_remote_class +from .stubs import stub_resource log = logging.getLogger(__name__) def remote( resource_config: ServerlessResource, - dependencies: List[str] = None, - system_dependencies: List[str] = None, + dependencies: Optional[List[str]] = None, + system_dependencies: Optional[List[str]] = None, **extra, ): """ @@ -24,8 +26,6 @@ def remote( to be provisioned or used. dependencies (List[str], optional): A list of pip package names to be installed in the remote environment before executing the function. Defaults to None. - mount_volume (NetworkVolume, optional): Configuration for creating and mounting a network volume. - Should contain 'size', 'datacenter_id', and 'name' keys. Defaults to None. extra (dict, optional): Additional parameters for the execution of the resource. Defaults to an empty dict. Returns: @@ -45,17 +45,26 @@ async def my_function(data): ``` """ - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - resource_manager = ResourceManager() - remote_resource = await resource_manager.get_or_deploy_resource( - resource_config + def decorator(func_or_class): + if inspect.isclass(func_or_class): + # Handle class decoration + return create_remote_class( + func_or_class, resource_config, dependencies, system_dependencies, extra ) + else: + # Handle function decoration (unchanged) + @wraps(func_or_class) + async def wrapper(*args, **kwargs): + resource_manager = ResourceManager() + remote_resource = await resource_manager.get_or_deploy_resource( + resource_config + ) - stub = stub_resource(remote_resource, **extra) - return await stub(func, dependencies, system_dependencies, *args, **kwargs) + stub = stub_resource(remote_resource, **extra) + return await stub( + func_or_class, dependencies, system_dependencies, *args, **kwargs + ) - return wrapper + return wrapper return decorator diff --git a/src/tetra_rp/execute_class.py b/src/tetra_rp/execute_class.py new file mode 100644 index 00000000..9830c1b3 --- /dev/null +++ b/src/tetra_rp/execute_class.py @@ -0,0 +1,178 @@ +import base64 +import inspect +import logging +import textwrap +import uuid +from typing import List, Type, Optional + +import cloudpickle + +from .core.resources import ResourceManager, ServerlessResource +from .protos.remote_execution import FunctionRequest +from .stubs import stub_resource + +log = logging.getLogger(__name__) + + +def extract_class_code_simple(cls: Type) -> str: + """Extract clean class code without decorators and proper indentation""" + try: + # Get source code + source = inspect.getsource(cls) + + # Split into lines + lines = source.split("\n") + + # Find the class definition line (starts with 'class' and contains ':') + class_start_idx = -1 + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("class ") and ":" in stripped: + class_start_idx = i + break + + if class_start_idx == -1: + raise ValueError("Could not find class definition") + + # Take lines from class definition onwards (ignore everything before) + class_lines = lines[class_start_idx:] + + # Remove empty lines at the end + while class_lines and not class_lines[-1].strip(): + class_lines.pop() + + # Join back and dedent to remove any leading indentation + class_code = "\n".join(class_lines) + class_code = textwrap.dedent(class_code) + + # Validate the code by trying to compile it + compile(class_code, "", "exec") + + log.debug(f"Successfully extracted class code for {cls.__name__}") + return class_code + + except Exception as e: + log.warning(f"Could not extract class code for {cls.__name__}: {e}") + log.warning("Falling back to basic class structure") + + # Enhanced fallback: try to preserve method signatures + fallback_methods = [] + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + try: + sig = inspect.signature(method) + fallback_methods.append(f" def {name}{sig}:") + fallback_methods.append(" pass") + fallback_methods.append("") + except (TypeError, ValueError, OSError) as e: + log.warning(f"Could not extract method signature for {name}: {e}") + fallback_methods.append(f" def {name}(self, *args, **kwargs):") + fallback_methods.append(" pass") + fallback_methods.append("") + + fallback_code = f"""class {cls.__name__}: + def __init__(self, *args, **kwargs): + pass + +{chr(10).join(fallback_methods)}""" + + return fallback_code + + +def create_remote_class( + cls: Type, + resource_config: ServerlessResource, + dependencies: Optional[List[str]], + system_dependencies: Optional[List[str]], + extra: dict, +): + """ + Create a remote class wrapper. + """ + # Validate inputs + if not inspect.isclass(cls): + raise TypeError(f"Expected a class, got {type(cls).__name__}") + if not hasattr(cls, "__name__"): + raise ValueError("Class must have a __name__ attribute") + + class RemoteClassWrapper: + def __init__(self, *args, **kwargs): + self._class_type = cls + self._resource_config = resource_config + self._dependencies = dependencies or [] + self._system_dependencies = system_dependencies or [] + self._extra = extra + self._constructor_args = args + self._constructor_kwargs = kwargs + self._instance_id = f"{cls.__name__}_{uuid.uuid4().hex[:8]}" + self._initialized = False + + self._clean_class_code = extract_class_code_simple(cls) + + log.debug(f"Created remote class wrapper for {cls.__name__}") + + async def _ensure_initialized(self): + """Ensure the remote instance is created.""" + if self._initialized: + return + + # Get remote resource + resource_manager = ResourceManager() + remote_resource = await resource_manager.get_or_deploy_resource( + self._resource_config + ) + self._stub = stub_resource(remote_resource, **self._extra) + + # Create the remote instance by calling a method (which will trigger instance creation) + # We'll do this on first method call + self._initialized = True + + def __getattr__(self, name): + """Dynamically create method proxies for all class methods.""" + if name.startswith("_"): + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'" + ) + + async def method_proxy(*args, **kwargs): + await self._ensure_initialized() + + # Create class method request + + # class_code = inspect.getsource(self._class_type) + class_code = self._clean_class_code + + request = FunctionRequest( + execution_type="class", + class_name=self._class_type.__name__, + class_code=class_code, + method_name=name, + args=[ + base64.b64encode(cloudpickle.dumps(arg)).decode("utf-8") + for arg in args + ], + kwargs={ + k: base64.b64encode(cloudpickle.dumps(v)).decode("utf-8") + for k, v in kwargs.items() + }, + constructor_args=[ + base64.b64encode(cloudpickle.dumps(arg)).decode("utf-8") + for arg in self._constructor_args + ], + constructor_kwargs={ + k: base64.b64encode(cloudpickle.dumps(v)).decode("utf-8") + for k, v in self._constructor_kwargs.items() + }, + dependencies=self._dependencies, + system_dependencies=self._system_dependencies, + instance_id=self._instance_id, + create_new_instance=not hasattr( + self, "_stub" + ), # Create new only on first call + ) + + # Execute via stub + return await self._stub.execute_class_method(request) # type: ignore + + return method_proxy + + return RemoteClassWrapper diff --git a/src/tetra_rp/protos/remote_execution.proto b/src/tetra_rp/protos/remote_execution.proto index f269059f..39341dec 100644 --- a/src/tetra_rp/protos/remote_execution.proto +++ b/src/tetra_rp/protos/remote_execution.proto @@ -4,22 +4,36 @@ package tetra; // The remote execution service definition service RemoteExecutor { - // Execute a function remotely + // Execute a function or class method remotely rpc ExecuteFunction (FunctionRequest) returns (FunctionResponse) {} } -// The request message containing function details and arguments +// The request message containing function/class details and arguments message FunctionRequest { - string function_name = 1; // Name of the function to execute - string function_code = 2; // Source code of the function - repeated string args = 3; // Base64-encoded cloudpickle-serialized positional arguments - map kwargs = 4; // Base64-encoded cloudpickle-serialized keyword arguments - repeated string dependencies = 5; // Optional list of pip packages to install before execution + optional string function_name = 1; // Name of the function to execute + optional string function_code = 2; // Source code of the function to execute + repeated string args = 3; // Base64-encoded cloudpickle-serialized positional arguments + map kwargs = 4; // Base64-encoded cloudpickle-serialized keyword arguments + repeated string dependencies = 5; // Optional list of pip packages to install before execution + repeated string system_dependencies = 6; // Optional list of system dependencies to install before execution + + string execution_type = 7; // Type of execution: 'function' or 'class' + optional string class_name = 8; // Name of the class to instantiate (for class execution) + optional string class_code = 9; // Source code of the class to instantiate (for class execution) + repeated string constructor_args = 10; // Base64-encoded cloudpickle-serialized constructor arguments + map constructor_kwargs = 11; // Base64-encoded cloudpickle-serialized constructor keyword arguments + string method_name = 12; // Name of the method to call on the class instance (default: "__call__") + optional string instance_id = 13; // Unique identifier for the class instance (for persistence) + bool create_new_instance = 14; // Whether to create a new instance or reuse existing one } // The response message containing the execution result or error message FunctionResponse { - bool success = 1; // Whether execution was successful - string result = 2; // Base64-encoded cloudpickle-serialized result (if success) - string error = 3; // Error message (if not success) + bool success = 1; // Whether execution was successful + optional string result = 2; // Base64-encoded cloudpickle-serialized result (if success) + optional string error = 3; // Error message (if not success) + optional string stdout = 4; // Captured standard output from the function execution + + optional string instance_id = 5; // ID of the class instance that was used/created + map instance_info = 6; // Metadata about the class instance (creation time, call count, etc.) } \ No newline at end of file diff --git a/src/tetra_rp/protos/remote_execution.py b/src/tetra_rp/protos/remote_execution.py index ab988e2f..6fc80dd4 100644 --- a/src/tetra_rp/protos/remote_execution.py +++ b/src/tetra_rp/protos/remote_execution.py @@ -1,15 +1,18 @@ # TODO: generate using betterproto - from abc import ABC, abstractmethod -from typing import List, Dict, Optional -from pydantic import BaseModel, Field +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field, model_validator class FunctionRequest(BaseModel): - function_name: str = Field( + # MADE OPTIONAL - can be None for class-only execution + function_name: Optional[str] = Field( + default=None, description="Name of the function to execute", ) - function_code: str = Field( + function_code: Optional[str] = Field( + default=None, description="Source code of the function to execute", ) args: List = Field( @@ -29,8 +32,67 @@ class FunctionRequest(BaseModel): description="Optional list of system dependencies to install before executing the function", ) + # NEW FIELDS FOR CLASS SUPPORT + execution_type: str = Field( + default="function", description="Type of execution: 'function' or 'class'" + ) + class_name: Optional[str] = Field( + default=None, + description="Name of the class to instantiate (for class execution)", + ) + class_code: Optional[str] = Field( + default=None, + description="Source code of the class to instantiate (for class execution)", + ) + constructor_args: Optional[List] = Field( + default_factory=list, + description="List of base64-encoded cloudpickle-serialized constructor arguments", + ) + constructor_kwargs: Optional[Dict] = Field( + default_factory=dict, + description="Dictionary of base64-encoded cloudpickle-serialized constructor keyword arguments", + ) + method_name: str = Field( + default="__call__", + description="Name of the method to call on the class instance", + ) + instance_id: Optional[str] = Field( + default=None, + description="Unique identifier for the class instance (for persistence)", + ) + create_new_instance: bool = Field( + default=True, + description="Whether to create a new instance or reuse existing one", + ) + + @model_validator(mode="after") + def validate_execution_requirements(self) -> "FunctionRequest": + """Validate that required fields are provided based on execution_type""" + if self.execution_type == "function": + if self.function_name is None: + raise ValueError( + 'function_name is required when execution_type is "function"' + ) + if self.function_code is None: + raise ValueError( + 'function_code is required when execution_type is "function"' + ) + + elif self.execution_type == "class": + if self.class_name is None: + raise ValueError( + 'class_name is required when execution_type is "class"' + ) + if self.class_code is None: + raise ValueError( + 'class_code is required when execution_type is "class"' + ) + + return self + class FunctionResponse(BaseModel): + # EXISTING FIELDS (unchanged) success: bool = Field( description="Indicates if the function execution was successful", ) @@ -47,6 +109,15 @@ class FunctionResponse(BaseModel): description="Captured standard output from the function execution", ) + # NEW FIELDS FOR CLASS SUPPORT + instance_id: Optional[str] = Field( + default=None, description="ID of the class instance that was used/created" + ) + instance_info: Optional[Dict] = Field( + default=None, + description="Metadata about the class instance (creation time, call count, etc.)", + ) + class RemoteExecutorStub(ABC): """Abstract base class for remote execution.""" diff --git a/src/tetra_rp/stubs/registry.py b/src/tetra_rp/stubs/registry.py index a3b25cef..778ea3d7 100644 --- a/src/tetra_rp/stubs/registry.py +++ b/src/tetra_rp/stubs/registry.py @@ -1,13 +1,13 @@ import logging from functools import singledispatch -from .live_serverless import LiveServerlessStub -from .serverless import ServerlessEndpointStub + from ..core.resources import ( CpuServerlessEndpoint, LiveServerless, ServerlessEndpoint, ) - +from .live_serverless import LiveServerlessStub +from .serverless import ServerlessEndpointStub log = logging.getLogger(__name__) @@ -22,20 +22,29 @@ async def fallback(*args, **kwargs): @stub_resource.register(LiveServerless) def _(resource, **extra): + stub = LiveServerlessStub(resource) + + # Function execution async def stubbed_resource( func, dependencies, system_dependencies, *args, **kwargs ) -> dict: if args == (None,): - # cleanup: when the function is called with no args args = [] - stub = LiveServerlessStub(resource) request = stub.prepare_request( func, dependencies, system_dependencies, *args, **kwargs ) response = await stub.ExecuteFunction(request) return stub.handle_response(response) + # Class method execution + async def execute_class_method(request): + response = await stub.ExecuteFunction(request) + return stub.handle_response(response) + + # Attach the method to the function + stubbed_resource.execute_class_method = execute_class_method + return stubbed_resource diff --git a/tests/integration/test_class_execution_integration.py b/tests/integration/test_class_execution_integration.py new file mode 100644 index 00000000..545e8923 --- /dev/null +++ b/tests/integration/test_class_execution_integration.py @@ -0,0 +1,754 @@ +""" +Integration tests for tetra_rp remote class execution functionality. + +These tests verify end-to-end functionality of remote class execution including: +- Remote class decorator integration +- Multiple method calls on the same instance +- Complex constructor arguments +- Error handling in remote class execution +""" + +import asyncio +import base64 +from unittest.mock import AsyncMock, patch + +import cloudpickle +import pytest +from tetra_rp.client import remote +from tetra_rp.core.resources import ServerlessResource +from tetra_rp.execute_class import create_remote_class + + +class TestRemoteClassDecoratorIntegration: + """Test remote class decorator integration.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_resource_config = ServerlessResource( + name="integration-test-resource", + image="python:3.9-slim", + cpu=2, + memory=1024, + ) + self.dependencies = ["numpy>=1.21.0", "pandas>=1.3.0"] + self.system_dependencies = ["curl", "git"] + + @pytest.mark.asyncio + async def test_remote_decorator_on_class(self): + """Test @remote decorator integration with class.""" + + @remote( + resource_config=self.mock_resource_config, + dependencies=self.dependencies, + system_dependencies=self.system_dependencies, + timeout=60, + ) + class RemoteCalculator: + def __init__(self, initial_value=0): + self.value = initial_value + self.history = [] + + def add(self, x): + self.value += x + self.history.append(f"add({x})") + return self.value + + def multiply(self, x): + self.value *= x + self.history.append(f"multiply({x})") + return self.value + + def get_history(self): + return self.history.copy() + + # Verify decorator returns a class (RemoteClassWrapper) + assert hasattr(RemoteCalculator, "__call__") + + # Create instance + calc = RemoteCalculator(10) + + # Verify wrapper properties + assert calc._class_type.__name__ == "RemoteCalculator" + assert calc._constructor_args == (10,) + assert calc._dependencies == self.dependencies + assert calc._system_dependencies == self.system_dependencies + assert not calc._initialized + + # Verify class code extraction + assert "class RemoteCalculator:" in calc._clean_class_code + assert "def add(self, x):" in calc._clean_class_code + assert "def multiply(self, x):" in calc._clean_class_code + + # Mock the stub for method execution + mock_stub = AsyncMock() + mock_stub.execute_class_method.return_value = 15 + + async def mock_ensure_initialized(): + if calc._initialized: + return + calc._stub = mock_stub + calc._initialized = True + + with patch.object( + calc, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + result = await calc.add(5) + assert result == 15 + + # Verify correct request construction + call_args = mock_stub.execute_class_method.call_args[0][0] + assert call_args.class_name == "RemoteCalculator" + assert call_args.method_name == "add" + assert call_args.dependencies == self.dependencies + assert call_args.system_dependencies == self.system_dependencies + + @pytest.mark.asyncio + async def test_remote_decorator_with_complex_class_structure(self): + """Test remote decorator with a complex class including properties and class methods.""" + + @remote( + resource_config=self.mock_resource_config, + dependencies=["scikit-learn"], + ) + class DataProcessor: + CLASS_CONSTANT = "DATA_PROCESSOR_V1" + + def __init__(self, name, config=None, *args, **kwargs): + self.name = name + self.config = config or {} + self.extra_args = args + self.extra_kwargs = kwargs + self._processed_count = 0 + + @classmethod + def create_default(cls): + return cls("default_processor", {"mode": "standard"}) + + @staticmethod + def validate_data(data): + return isinstance(data, (list, tuple)) and len(data) > 0 + + @property + def status(self): + return f"{self.name}: processed {self._processed_count} items" + + def process(self, data): + if not self.validate_data(data): + raise ValueError("Invalid data format") + self._processed_count += len(data) + return f"Processed {len(data)} items" + + # Create instance with complex arguments + processor = DataProcessor( + "test_processor", + {"batch_size": 32, "verbose": True}, + "extra_arg1", + "extra_arg2", + extra_param="extra_value", + debug=True, + ) + + # Verify complex initialization + assert processor._constructor_args == ( + "test_processor", + {"batch_size": 32, "verbose": True}, + "extra_arg1", + "extra_arg2", + ) + assert processor._constructor_kwargs == { + "extra_param": "extra_value", + "debug": True, + } + + # Verify class code preserves complex structure + class_code = processor._clean_class_code + assert 'CLASS_CONSTANT = "DATA_PROCESSOR_V1"' in class_code + assert "def create_default(cls):" in class_code + assert "def validate_data(data):" in class_code + assert "def status(self):" in class_code + + +class TestMultipleMethodCallsOnSameInstance: + """Test multiple method calls on the same remote class instance.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_resource_config = ServerlessResource( + name="multi-call-test", image="python:3.9", cpu=1, memory=512 + ) + + @pytest.mark.asyncio + async def test_multiple_method_calls_maintain_state(self): + """Test that multiple method calls on same instance maintain state.""" + + class StatefulCounter: + def __init__(self, start=0): + self.count = start + self.operation_log = [] + + def increment(self, by=1): + self.count += by + self.operation_log.append(f"increment({by})") + return self.count + + def decrement(self, by=1): + self.count -= by + self.operation_log.append(f"decrement({by})") + return self.count + + def get_state(self): + return { + "count": self.count, + "operations": len(self.operation_log), + "last_op": self.operation_log[-1] if self.operation_log else None, + } + + RemoteCounter = create_remote_class( + StatefulCounter, self.mock_resource_config, [], [], {} + ) + + counter = RemoteCounter(5) + + # Mock stub responses for multiple calls + mock_stub = AsyncMock() + + # Simulate maintaining state across calls + call_responses = [ + 10, + 8, + {"count": 8, "operations": 2, "last_op": "decrement(2)"}, + ] + mock_stub.execute_class_method.side_effect = call_responses + + async def mock_ensure_initialized(): + if counter._initialized: + return + counter._stub = mock_stub + counter._initialized = True + + with patch.object( + counter, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # First call + result1 = await counter.increment(5) + assert result1 == 10 + + # Second call + result2 = await counter.decrement(2) + assert result2 == 8 + + # Third call + result3 = await counter.get_state() + assert result3["count"] == 8 + assert result3["operations"] == 2 + + # Verify all calls used the same instance_id + calls = mock_stub.execute_class_method.call_args_list + assert len(calls) == 3 + + instance_ids = [call[0][0].instance_id for call in calls] + assert all(id == instance_ids[0] for id in instance_ids), ( + "All calls should use same instance_id" + ) + + # Verify create_new_instance is False after first call + assert ( + calls[0][0][0].create_new_instance is False + ) # First call after initialization + assert calls[1][0][0].create_new_instance is False + assert calls[2][0][0].create_new_instance is False + + @pytest.mark.asyncio + async def test_parallel_method_calls_same_instance(self): + """Test parallel method calls on the same instance.""" + + class AsyncWorker: + def __init__(self): + self.tasks_completed = 0 + + async def work_task(self, task_id, duration=0.1): + # Simulate async work + await asyncio.sleep(duration) + self.tasks_completed += 1 + return f"Task {task_id} completed" + + def get_completed_count(self): + return self.tasks_completed + + RemoteWorker = create_remote_class( + AsyncWorker, self.mock_resource_config, [], [], {} + ) + + worker = RemoteWorker() + + # Mock responses for parallel calls + mock_stub = AsyncMock() + mock_stub.execute_class_method.side_effect = [ + "Task 1 completed", + "Task 2 completed", + "Task 3 completed", + 3, # Final count + ] + + async def mock_ensure_initialized(): + if worker._initialized: + return + worker._stub = mock_stub + worker._initialized = True + + with patch.object( + worker, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Execute parallel tasks + tasks = [worker.work_task(1), worker.work_task(2), worker.work_task(3)] + + results = await asyncio.gather(*tasks) + final_count = await worker.get_completed_count() + + assert results == [ + "Task 1 completed", + "Task 2 completed", + "Task 3 completed", + ] + assert final_count == 3 + + # Verify all calls used same instance + calls = mock_stub.execute_class_method.call_args_list + instance_ids = [call[0][0].instance_id for call in calls] + assert all(id == instance_ids[0] for id in instance_ids) + + +class TestComplexConstructorArguments: + """Test remote class execution with complex constructor arguments.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_resource_config = ServerlessResource( + name="complex-args-test", image="python:3.9", cpu=1, memory=512 + ) + + @pytest.mark.asyncio + async def test_complex_object_serialization(self): + """Test complex object serialization in constructor.""" + + class ConfigurableModel: + def __init__(self, model_config, data_sources, metadata=None, **options): + self.config = model_config + self.sources = data_sources + self.metadata = metadata or {} + self.options = options + self.initialized = True + + def get_config_summary(self): + return { + "config_type": type(self.config).__name__, + "sources_count": len(self.sources), + "has_metadata": bool(self.metadata), + "options_count": len(self.options), + } + + def process_with_config(self, input_data): + return ( + f"Processed {len(input_data)} items with {self.config['algorithm']}" + ) + + # Complex constructor arguments + model_config = { + "algorithm": "random_forest", + "parameters": {"n_estimators": 100, "max_depth": 10}, + "preprocessing": ["normalize", "scale"], + } + + data_sources = [ + {"type": "database", "connection": "postgresql://..."}, + {"type": "file", "path": "/data/training.csv"}, + ] + + metadata = { + "version": "1.0.0", + "created_by": "data_team", + "tags": ["production", "ml_model"], + } + + RemoteModel = create_remote_class( + ConfigurableModel, + self.mock_resource_config, + ["scikit-learn", "pandas"], + [], + {}, + ) + + model = RemoteModel( + model_config, + data_sources, + metadata=metadata, + debug=True, + cache_enabled=False, + timeout=300, + ) + + # Verify complex arguments are properly stored + assert model._constructor_args == (model_config, data_sources) + assert model._constructor_kwargs == { + "metadata": metadata, + "debug": True, + "cache_enabled": False, + "timeout": 300, + } + + # Mock execution and verify serialization + mock_stub = AsyncMock() + mock_stub.execute_class_method.return_value = { + "config_type": "dict", + "sources_count": 2, + "has_metadata": True, + "options_count": 3, + } + + async def mock_ensure_initialized(): + if model._initialized: + return + model._stub = mock_stub + model._initialized = True + + with patch.object( + model, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + await model.get_config_summary() + + # Verify method call + call_args = mock_stub.execute_class_method.call_args[0][0] + + # Verify constructor arguments are properly serialized + assert len(call_args.constructor_args) == 2 + deserialized_config = cloudpickle.loads( + base64.b64decode(call_args.constructor_args[0]) + ) + assert deserialized_config == model_config + + deserialized_sources = cloudpickle.loads( + base64.b64decode(call_args.constructor_args[1]) + ) + assert deserialized_sources == data_sources + + # Verify constructor kwargs + assert len(call_args.constructor_kwargs) == 4 + deserialized_metadata = cloudpickle.loads( + base64.b64decode(call_args.constructor_kwargs["metadata"]) + ) + assert deserialized_metadata == metadata + + @pytest.mark.asyncio + async def test_nested_class_instances_as_arguments(self): + """Test passing instances of other classes as constructor arguments.""" + + class DatabaseConnection: + def __init__(self, host, port, database): + self.host = host + self.port = port + self.database = database + + def get_connection_string(self): + return f"{self.host}:{self.port}/{self.database}" + + class CacheConfig: + def __init__(self, enabled=True, ttl=3600): + self.enabled = enabled + self.ttl = ttl + + class DataService: + def __init__(self, db_connection, cache_config, api_keys=None): + self.db = db_connection + self.cache = cache_config + self.api_keys = api_keys or [] + + def get_service_info(self): + return { + "db_connection": self.db.get_connection_string(), + "cache_enabled": self.cache.enabled, + "cache_ttl": self.cache.ttl, + "api_keys_count": len(self.api_keys), + } + + # Create complex nested objects + db_conn = DatabaseConnection("localhost", 5432, "testdb") + cache_conf = CacheConfig(enabled=True, ttl=7200) + api_keys = ["key1", "key2", "key3"] + + RemoteDataService = create_remote_class( + DataService, self.mock_resource_config, ["psycopg2"], [], {} + ) + + service = RemoteDataService(db_conn, cache_conf, api_keys=api_keys) + + # Mock execution + mock_stub = AsyncMock() + mock_stub.execute_class_method.return_value = { + "db_connection": "localhost:5432/testdb", + "cache_enabled": True, + "cache_ttl": 7200, + "api_keys_count": 3, + } + + async def mock_ensure_initialized(): + if service._initialized: + return + service._stub = mock_stub + service._initialized = True + + with patch.object( + service, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + await service.get_service_info() + + # Verify serialization of complex nested objects + call_args = mock_stub.execute_class_method.call_args[0][0] + + # Test deserialization of db_connection + deserialized_db = cloudpickle.loads( + base64.b64decode(call_args.constructor_args[0]) + ) + assert isinstance(deserialized_db, DatabaseConnection) + assert deserialized_db.host == "localhost" + assert deserialized_db.port == 5432 + + # Test deserialization of cache_config + deserialized_cache = cloudpickle.loads( + base64.b64decode(call_args.constructor_args[1]) + ) + assert isinstance(deserialized_cache, CacheConfig) + assert deserialized_cache.enabled is True + assert deserialized_cache.ttl == 7200 + + +class TestErrorHandlingInRemoteClassExecution: + """Test error handling scenarios in remote class execution.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_resource_config = ServerlessResource( + name="error-test", image="python:3.9", cpu=1, memory=512 + ) + + @pytest.mark.asyncio + async def test_remote_method_execution_error(self): + """Test error handling when remote method execution fails.""" + + class ErrorProneClass: + def __init__(self, should_fail=False): + self.should_fail = should_fail + + def risky_method(self, data): + if self.should_fail: + raise ValueError("Intentional failure for testing") + return f"Processed: {data}" + + def safe_method(self): + return "This always works" + + RemoteErrorProneClass = create_remote_class( + ErrorProneClass, self.mock_resource_config, [], [], {} + ) + + error_instance = RemoteErrorProneClass(should_fail=True) + + # Mock stub that raises an exception + mock_stub = AsyncMock() + mock_stub.execute_class_method.side_effect = Exception( + "Remote execution failed: ValueError: Intentional failure for testing" + ) + + async def mock_ensure_initialized(): + if error_instance._initialized: + return + error_instance._stub = mock_stub + error_instance._initialized = True + + with patch.object( + error_instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Test that the exception is properly propagated + with pytest.raises(Exception, match="Remote execution failed"): + await error_instance.risky_method("test_data") + + @pytest.mark.asyncio + async def test_resource_initialization_error(self): + """Test error handling when resource initialization fails.""" + + class SimpleClass: + def __init__(self): + pass + + def simple_method(self): + return "hello" + + RemoteSimpleClass = create_remote_class( + SimpleClass, self.mock_resource_config, [], [], {} + ) + + instance = RemoteSimpleClass() + + # Mock initialization failure + async def mock_failing_ensure_initialized(): + if instance._initialized: + return + raise ConnectionError("Failed to connect to remote resource") + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_failing_ensure_initialized + ): + # Test that initialization errors are properly propagated + with pytest.raises( + ConnectionError, match="Failed to connect to remote resource" + ): + await instance.simple_method() + + @pytest.mark.asyncio + async def test_serialization_error_handling(self): + """Test error handling for serialization issues.""" + + class UnserializableClass: + def __init__(self, file_handle=None): + self.file_handle = file_handle # File handles can't be pickled + + def process_file(self): + return "Processing file" + + # Create instance with unserializable object + import tempfile + + with tempfile.NamedTemporaryFile() as temp_file: + RemoteUnserializableClass = create_remote_class( + UnserializableClass, self.mock_resource_config, [], [], {} + ) + + # This should not fail during initialization (lazy serialization) + instance = RemoteUnserializableClass(temp_file) + + # Mock ensure_initialized to avoid actual resource calls + mock_stub = AsyncMock() + + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # The error should occur during method call when trying to serialize + # Mock cloudpickle.dumps to raise an error + with patch( + "tetra_rp.execute_class.cloudpickle.dumps", + side_effect=TypeError("Can't pickle file objects"), + ): + with pytest.raises(TypeError, match="Can't pickle file objects"): + await instance.process_file() + + @pytest.mark.asyncio + async def test_timeout_error_handling(self): + """Test timeout error handling in remote execution.""" + + class SlowClass: + def __init__(self): + pass + + def slow_method(self, duration): + # Simulate a slow operation + import time + + time.sleep(duration) + return f"Completed after {duration} seconds" + + RemoteSlowClass = create_remote_class( + SlowClass, + self.mock_resource_config, + [], + [], + {"timeout": 5}, # 5 second timeout + ) + + instance = RemoteSlowClass() + + # Mock stub that simulates timeout + mock_stub = AsyncMock() + mock_stub.execute_class_method.side_effect = asyncio.TimeoutError( + "Operation timed out after 5 seconds" + ) + + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Test timeout error handling + with pytest.raises(asyncio.TimeoutError, match="Operation timed out"): + await instance.slow_method(10) # Request 10 seconds but timeout at 5 + + def test_invalid_class_type_error(self): + """Test error handling for invalid class types.""" + + # Test with non-class object + with pytest.raises(TypeError, match="Expected a class"): + create_remote_class( + "not_a_class", # String instead of class + self.mock_resource_config, + [], + [], + {}, + ) + + # Test with function instead of class + def not_a_class(): + pass + + with pytest.raises(TypeError, match="Expected a class"): + create_remote_class(not_a_class, self.mock_resource_config, [], [], {}) + + # Note: Testing class without __name__ is not practically possible + # since Python classes always have __name__ attribute + + @pytest.mark.asyncio + async def test_dependency_installation_error(self): + """Test error handling when dependency installation fails.""" + + class DependentClass: + def __init__(self): + pass + + def use_dependency(self): + return "Using numpy successfully" + + RemoteDependentClass = create_remote_class( + DependentClass, + self.mock_resource_config, + ["nonexistent-package==999.999.999"], # Invalid package + [], + {}, + ) + + instance = RemoteDependentClass() + + # Mock stub that simulates dependency installation failure + mock_stub = AsyncMock() + mock_stub.execute_class_method.side_effect = Exception( + "Failed to install dependencies: nonexistent-package==999.999.999 not found" + ) + + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Test dependency installation error + with pytest.raises(Exception, match="Failed to install dependencies"): + await instance.use_dependency() diff --git a/tests/unit/test_execute_class.py b/tests/unit/test_execute_class.py new file mode 100644 index 00000000..9e28711b --- /dev/null +++ b/tests/unit/test_execute_class.py @@ -0,0 +1,708 @@ +""" +Unit tests for tetra_rp.execute_class module. +""" + +import asyncio +import base64 +import inspect +from unittest.mock import AsyncMock, Mock, patch + +import cloudpickle +import pytest +from tetra_rp.core.resources import ServerlessResource +from tetra_rp.execute_class import create_remote_class, extract_class_code_simple +from tetra_rp.protos.remote_execution import FunctionRequest + + +class TestExtractClassCodeSimple: + """Test cases for extract_class_code_simple function.""" + + def test_extract_simple_class(self): + """Test extracting code from a simple class.""" + + class SimpleClass: + def __init__(self, value): + self.value = value + + def get_value(self): + return self.value + + result = extract_class_code_simple(SimpleClass) + + assert "class SimpleClass:" in result + assert "def __init__(self, value):" in result + assert "def get_value(self):" in result + assert "self.value = value" in result + assert "return self.value" in result + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_class_with_decorators(self): + """Test extracting code from a class with decorators (should ignore decorators).""" + + def some_decorator(cls): + return cls + + @some_decorator + class DecoratedClass: + def method(self): + pass + + result = extract_class_code_simple(DecoratedClass) + + # Should start with class definition, not decorators + lines = result.strip().split("\n") + assert lines[0].startswith("class DecoratedClass:") + assert "@" not in lines[0] # No decorator in class line + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_indented_class(self): + """Test extracting code from an indented class (nested).""" + # Create a nested class by exec'ing it + code = """ +def create_nested(): + class NestedClass: + def __init__(self): + self.data = "nested" + + def get_data(self): + return self.data + return NestedClass +""" + namespace = {} + exec(code, namespace) + NestedClass = namespace["create_nested"]() + + result = extract_class_code_simple(NestedClass) + + # Should be properly dedented + lines = result.split("\n") + assert lines[0] == "class NestedClass:" + assert not lines[0].startswith(" ") # Should not have leading whitespace + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_class_with_methods_and_properties(self): + """Test extracting code from a class with various method types.""" + + class ComplexClass: + def __init__(self, name): + self.name = name + + def instance_method(self): + return f"Hello {self.name}" + + @classmethod + def class_method(cls): + return "class method" + + @staticmethod + def static_method(): + return "static method" + + @property + def name_property(self): + return self.name.upper() + + result = extract_class_code_simple(ComplexClass) + + assert "class ComplexClass:" in result + assert "def __init__(self, name):" in result + assert "def instance_method(self):" in result + assert "def class_method(cls):" in result + assert "def static_method():" in result + assert "def name_property(self):" in result + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_class_fallback_on_error(self): + """Test fallback behavior when source extraction fails.""" + # Create a mock class that will cause inspect.getsource to fail + mock_class = type( + "MockClass", + (), + { + "__name__": "MockClass", + "method1": lambda self, x, y: None, + "method2": lambda self, *args, **kwargs: None, + }, + ) + + # Mock inspect.getsource to raise an exception + with patch( + "tetra_rp.execute_class.inspect.getsource", + side_effect=OSError("No source available"), + ): + with patch("tetra_rp.execute_class.log.warning") as mock_log_warning: + result = extract_class_code_simple(mock_class) + + # Should use fallback + assert "class MockClass:" in result + assert "def __init__(self, *args, **kwargs):" in result + assert "pass" in result + + # Verify fallback was triggered + mock_log_warning.assert_any_call( + "Could not extract class code for MockClass: No source available" + ) + mock_log_warning.assert_any_call( + "Falling back to basic class structure" + ) + + def test_extract_class_with_complex_signatures(self): + """Test extracting class with complex method signatures.""" + + class ClassWithComplexMethods: + def method_with_defaults(self, a, b=10, c="default"): + return a + b + + def method_with_varargs(self, *args, **kwargs): + return len(args) + len(kwargs) + + def method_with_annotations(self, x: int, y: str = "hello") -> str: + return f"{x}: {y}" + + result = extract_class_code_simple(ClassWithComplexMethods) + + assert 'def method_with_defaults(self, a, b=10, c="default"):' in result + assert "def method_with_varargs(self, *args, **kwargs):" in result + assert ( + 'def method_with_annotations(self, x: int, y: str = "hello") -> str:' + in result + ) + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_class_empty_methods(self): + """Test extracting class with empty methods.""" + + class EmptyMethodsClass: + def empty_method(self): + pass + + def another_empty(self): + """Just a docstring.""" + pass + + result = extract_class_code_simple(EmptyMethodsClass) + + assert "class EmptyMethodsClass:" in result + assert "def empty_method(self):" in result + assert "def another_empty(self):" in result + + # Verify the code compiles + compile(result, "", "exec") + + def test_extract_class_with_trailing_whitespace(self): + """Test that trailing empty lines are removed.""" + + # This test ensures the extract function handles trailing whitespace properly + class SimpleClass: + def method(self): + return "test" + + result = extract_class_code_simple(SimpleClass) + + # Should not end with multiple newlines + assert not result.endswith("\n\n\n") + lines = result.split("\n") + # Last line should not be empty + assert lines[-1].strip() != "" + + +class TestCreateRemoteClass: + """Test cases for create_remote_class function and RemoteClassWrapper.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_resource_config = ServerlessResource( + name="test-resource", image="test-image:latest", cpu=1, memory=512 + ) + self.dependencies = ["numpy", "pandas"] + self.system_dependencies = ["git"] + self.extra = {"timeout": 30} + + def test_create_remote_class_basic(self): + """Test basic remote class creation.""" + + class TestClass: + def __init__(self, value): + self.value = value + + def get_value(self): + return self.value + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + # Should return a class + assert inspect.isclass(RemoteWrapper) + assert RemoteWrapper.__name__ == "RemoteClassWrapper" + + def test_remote_class_wrapper_initialization(self): + """Test RemoteClassWrapper initialization.""" + + class TestClass: + def __init__(self, value, name="default"): + self.value = value + self.name = name + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper(42, name="test") + + assert instance._class_type == TestClass + assert instance._resource_config == self.mock_resource_config + assert instance._dependencies == self.dependencies + assert instance._system_dependencies == self.system_dependencies + assert instance._extra == self.extra + assert instance._constructor_args == (42,) + assert instance._constructor_kwargs == {"name": "test"} + assert instance._instance_id.startswith("TestClass_") + assert not instance._initialized + assert instance._clean_class_code is not None + + def test_remote_class_wrapper_initialization_defaults(self): + """Test RemoteClassWrapper initialization with default values.""" + + class TestClass: + pass + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + None, # dependencies + None, # system_dependencies + self.extra, + ) + + instance = RemoteWrapper() + + assert instance._dependencies == [] + assert instance._system_dependencies == [] + assert instance._constructor_args == () + assert instance._constructor_kwargs == {} + + @pytest.mark.asyncio + async def test_ensure_initialized(self): + """Test _ensure_initialized method.""" + + class TestClass: + pass + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper() + + # Mock the stub + mock_stub = Mock() + + # Mock the entire _ensure_initialized method to avoid ResourceManager issues + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + await instance._ensure_initialized() + + assert instance._initialized + assert instance._stub == mock_stub + + @pytest.mark.asyncio + async def test_ensure_initialized_idempotent(self): + """Test that _ensure_initialized is idempotent.""" + + class TestClass: + pass + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper() + + # Mock the stub + mock_stub = Mock() + + # Mock the entire _ensure_initialized method to test idempotency + call_count = 0 + + async def mock_ensure_initialized(): + nonlocal call_count + if instance._initialized: + return + call_count += 1 + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Call twice + await instance._ensure_initialized() + await instance._ensure_initialized() + + # Should only initialize once + assert call_count == 1 + + def test_getattr_private_attributes(self): + """Test that private attributes raise AttributeError.""" + + class TestClass: + pass + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper() + + with pytest.raises( + AttributeError, + match="'RemoteClassWrapper' object has no attribute '_private'", + ): + instance._private + + @pytest.mark.asyncio + async def test_method_proxy_execution(self): + """Test method proxy execution.""" + + class TestClass: + def __init__(self, value): + self.value = value + + def get_value(self): + return self.value + + def add(self, x, y=10): + return x + y + self.value + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper(5) + + # Mock the initialization and stub + mock_stub = AsyncMock() + expected_result = "test_result" + mock_stub.execute_class_method.return_value = expected_result + + # Mock the _ensure_initialized method to avoid ResourceManager issues + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Call a method + result = await instance.add(20, y=30) + + assert result == expected_result + assert instance._initialized + + # Verify the request was constructed correctly + call_args = mock_stub.execute_class_method.call_args[0][0] + assert isinstance(call_args, FunctionRequest) + assert call_args.execution_type == "class" + assert call_args.class_name == "TestClass" + assert call_args.method_name == "add" + assert call_args.instance_id == instance._instance_id + assert call_args.dependencies == self.dependencies + assert call_args.system_dependencies == self.system_dependencies + + # Verify serialized arguments + assert len(call_args.args) == 1 + assert cloudpickle.loads(base64.b64decode(call_args.args[0])) == 20 + assert len(call_args.kwargs) == 1 + assert cloudpickle.loads(base64.b64decode(call_args.kwargs["y"])) == 30 + + # Verify serialized constructor arguments + assert len(call_args.constructor_args) == 1 + assert ( + cloudpickle.loads(base64.b64decode(call_args.constructor_args[0])) == 5 + ) + + @pytest.mark.asyncio + async def test_method_proxy_create_new_instance_flag(self): + """Test create_new_instance flag behavior.""" + + class TestClass: + def method1(self): + return "result1" + + def method2(self): + return "result2" + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper() + + # Mock the initialization and stub + mock_stub = AsyncMock() + mock_stub.execute_class_method.return_value = "result" + + # Mock the _ensure_initialized method to avoid ResourceManager issues + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + # Test current behavior: create_new_instance is False after _ensure_initialized sets _stub + await instance.method1() + first_call_args = mock_stub.execute_class_method.call_args[0][0] + # After _ensure_initialized, _stub exists, so create_new_instance is False + assert first_call_args.create_new_instance is False + + # Subsequent calls also have create_new_instance as False + await instance.method2() + second_call_args = mock_stub.execute_class_method.call_args[0][0] + assert second_call_args.create_new_instance is False + + @pytest.mark.asyncio + async def test_method_proxy_no_args_no_kwargs(self): + """Test method proxy with no arguments.""" + + class TestClass: + def simple_method(self): + return "simple" + + RemoteWrapper = create_remote_class( + TestClass, self.mock_resource_config, [], [], {} + ) + + instance = RemoteWrapper() + + # Mock the initialization and stub + mock_stub = AsyncMock() + mock_stub.execute_class_method.return_value = "result" + + # Mock the _ensure_initialized method to avoid ResourceManager issues + async def mock_ensure_initialized(): + if instance._initialized: + return + instance._stub = mock_stub + instance._initialized = True + + with patch.object( + instance, "_ensure_initialized", side_effect=mock_ensure_initialized + ): + await instance.simple_method() + + call_args = mock_stub.execute_class_method.call_args[0][0] + assert call_args.args == [] + assert call_args.kwargs == {} + assert call_args.constructor_args == [] + assert call_args.constructor_kwargs == {} + + def test_class_code_extraction_in_wrapper(self): + """Test that class code is extracted during wrapper initialization.""" + + class TestClass: + def __init__(self): + pass + + def test_method(self): + return "test" + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance = RemoteWrapper() + + # Verify class code was extracted + assert instance._clean_class_code is not None + assert "class TestClass:" in instance._clean_class_code + assert "def test_method(self):" in instance._clean_class_code + + # Verify it compiles + compile(instance._clean_class_code, "", "exec") + + def test_uuid_generation(self): + """Test that instance IDs are unique.""" + + class TestClass: + pass + + RemoteWrapper = create_remote_class( + TestClass, + self.mock_resource_config, + self.dependencies, + self.system_dependencies, + self.extra, + ) + + instance1 = RemoteWrapper() + instance2 = RemoteWrapper() + + assert instance1._instance_id != instance2._instance_id + assert instance1._instance_id.startswith("TestClass_") + assert instance2._instance_id.startswith("TestClass_") + + # Verify UUID format (8 hex characters) + id1_suffix = instance1._instance_id.split("_")[1] + id2_suffix = instance2._instance_id.split("_")[1] + assert len(id1_suffix) == 8 + assert len(id2_suffix) == 8 + assert all(c in "0123456789abcdef" for c in id1_suffix) + assert all(c in "0123456789abcdef" for c in id2_suffix) + + +class TestExecuteClassIntegration: + """Integration tests for execute_class module functionality.""" + + def test_full_workflow_mock(self): + """Test the complete workflow with mocked components.""" + + class CalculatorClass: + def __init__(self, initial_value=0): + self.value = initial_value + + def add(self, x): + self.value += x + return self.value + + def multiply(self, x): + self.value *= x + return self.value + + def get_value(self): + return self.value + + # Test that we can create a remote wrapper and it has the right structure + resource_config = ServerlessResource( + name="calculator-resource", image="python:3.9", cpu=1, memory=256 + ) + + RemoteCalculator = create_remote_class( + CalculatorClass, resource_config, ["numpy"], [], {"timeout": 60} + ) + + calculator = RemoteCalculator(10) + + # Verify the wrapper is set up correctly + assert calculator._class_type == CalculatorClass + assert calculator._constructor_args == (10,) + assert "class CalculatorClass:" in calculator._clean_class_code + assert "def add(self, x):" in calculator._clean_class_code + assert not calculator._initialized + + # Verify method proxies are created dynamically + add_method = calculator.add + assert callable(add_method) + assert asyncio.iscoroutinefunction(add_method) + + def test_class_code_preservation(self): + """Test that complex class structures are preserved in extracted code.""" + + class ComplexClass: + CLASS_VAR = "class_variable" + + def __init__(self, name, *args, **kwargs): + self.name = name + self.args = args + self.kwargs = kwargs + + @classmethod + def create_default(cls): + return cls("default") + + @staticmethod + def static_helper(x, y): + return x + y + + @property + def display_name(self): + return f"Complex: {self.name}" + + def complex_method( + self, a: int, b: str = "default", *args, **kwargs + ) -> str: + return f"{a}-{b}-{len(args)}-{len(kwargs)}" + + RemoteWrapper = create_remote_class( + ComplexClass, + ServerlessResource(name="test", image="test:latest", cpu=1, memory=256), + [], + [], + {}, + ) + + instance = RemoteWrapper("test", extra_arg=True) + code = instance._clean_class_code + + # Verify all elements are preserved + assert 'CLASS_VAR = "class_variable"' in code + assert "def __init__(self, name, *args, **kwargs):" in code + assert "def create_default(cls):" in code + assert "def static_helper(x, y):" in code + assert "def display_name(self):" in code + assert "def complex_method(" in code + assert ") -> str:" in code + + # Verify the code compiles and can be executed + namespace = {} + exec(code, namespace) + ReconstructedClass = namespace["ComplexClass"] + + # Test that the reconstructed class works + obj = ReconstructedClass("test") + assert obj.name == "test" + assert obj.CLASS_VAR == "class_variable"