diff --git a/docs/tutorials/runner-setup.md b/docs/tutorials/runner-setup.md index 008e37b9..b6209489 100644 --- a/docs/tutorials/runner-setup.md +++ b/docs/tutorials/runner-setup.md @@ -99,13 +99,13 @@ class Counter: return self._count with Runner("counter-app") as runner: - counter = runner.service(Counter(10), stateful=True, warmup=1) + counter = runner.service(Counter(10), warmup=1) counter.add(1).wait() counter.add(3).wait() print(counter.get().get()) ``` -Classes and instances default to fixed sessions. Passing `warmup=N` with `autoscale=False` creates `N` fixed instances. Use `stateful=True` only with instances, not classes. +Functions, builtins, and classes are stateless. Object instances are stateful fixed services and support only `warmup=0` or `warmup=1`. ### Passing ObjectFuture Values @@ -144,7 +144,7 @@ Runner(name: str, fail_if_exists: bool = False) Methods: -- `service(execution_object, stateful=None, autoscale=None, warmup=0, resreq=None)`: create a `RunnerService`. +- `service(execution_object, autoscale=None, warmup=0, resreq=None)`: create a `RunnerService`. - `get(futures)`: resolve multiple `ObjectFuture` values to concrete objects. - `ref(futures)`: resolve multiple `ObjectFuture` values to `ObjectRef` values. - `wait(futures)`: wait for multiple futures without fetching objects. @@ -173,13 +173,15 @@ with Runner("cpu-app") as runner: - Every remote call returns `ObjectFuture`. - `close()` closes the underlying Flame session. -Default scaling behavior: +Default service behavior with `warmup=0`: -| Execution object | Default `autoscale` | Default `stateful` | -|------------------|---------------------|--------------------| -| Function | `True` | `False` | -| Class | `False` | `False` | -| Instance | `False` | `False` | +| Execution object | Default `stateful` | Default `autoscale` | Effective `min_instances` | Effective `max_instances` | +|------------------|--------------------|---------------------|---------------------------|---------------------------| +| Function or builtin | `False` | `True` | `0` | unlimited | +| Class | `False` | `True` | `0` | unlimited | +| Instance | `True` | `False` | `1` | `1` | + +For functions, builtins, and classes, `autoscale` is configurable. When `warmup=N` and `N > 0`, autoscaled services use `min_instances=N` and no max limit; fixed services use `min_instances=N` and `max_instances=N`. Object instances are always fixed, always stateful, and reject `warmup` values other than `0` or `1`. ### ObjectFuture diff --git a/e2e/src/e2e/helpers.py b/e2e/src/e2e/helpers.py index 1f722da9..449910bd 100644 --- a/e2e/src/e2e/helpers.py +++ b/e2e/src/e2e/helpers.py @@ -288,17 +288,20 @@ class RecursiveService: using the open_session API. """ - def __init__(self, session_id: str, app_name: str): + _session_context: Optional[SessionContext] = None + + def __init__(self, session_id: Optional[str] = None, app_name: Optional[str] = None): """Initialize with session ID and app name for recursive calls. Args: session_id: The shared session ID for recursive calls. app_name: The shared application name for Runner. """ - self._session_context = SessionContext( - session_id=session_id, - application_name=app_name, - ) + if session_id is not None and app_name is not None: + self._session_context = SessionContext( + session_id=session_id, + application_name=app_name, + ) def compute_recursive(self, depth: int) -> int: """Compute recursively by creating new Runner and service instances. @@ -312,6 +315,9 @@ def compute_recursive(self, depth: int) -> int: logger = logging.getLogger(__name__) logger.info(f"[RecursiveService] compute_recursive called with depth={depth}") + if self._session_context is None: + raise ValueError("RecursiveService requires _session_context") + logger.info(f"[RecursiveService] session_context: session_id={self._session_context.session_id}, app_name={self._session_context.application_name}") if depth <= 0: @@ -326,11 +332,12 @@ def compute_recursive(self, depth: int) -> int: with Runner(self._session_context.application_name) as inner_runner: logger.info(f"[RecursiveService] Inner Runner created, _app_registered={inner_runner._app_registered}") - # Create service using Runner.service() with self + # Create service using Runner.service() with the class # This reuses the existing session via _session_context - # Use autoscale=True to allow multiple executors for recursive calls - logger.info("[RecursiveService] Creating inner service with self") - inner_service = inner_runner.service(self, autoscale=True) + # Use a class service with autoscale=True to allow multiple + # executors for recursive calls. + logger.info("[RecursiveService] Creating inner service with class") + inner_service = inner_runner.service(type(self), autoscale=True) logger.info(f"[RecursiveService] Inner service created, session_id={inner_service._session.id}") # Call the inner service recursively diff --git a/e2e/tests/test_runner.py b/e2e/tests/test_runner.py index 30a29f67..58dc660b 100644 --- a/e2e/tests/test_runner.py +++ b/e2e/tests/test_runner.py @@ -104,8 +104,8 @@ def test_runner_with_instance(check_package_config, check_flmrun_app): # Set initial count to 10 by adding 10 counter.add(10) - # Create a stateful service with the instance - cnt_os = rr.service(counter, stateful=True, autoscale=False) + # Instance services are stateful and fixed by default. + cnt_os = rr.service(counter) # Apply state changes sequentially so the expected total is deterministic. cnt_os.increment().wait() @@ -124,8 +124,8 @@ def test_runner_with_objectfuture_args(check_package_config, check_flmrun_app): counter = Counter() counter.add(10) - # Create a stateful service with the instance - cnt_os = rr.service(counter, stateful=True, autoscale=False) + # Instance services are stateful and fixed by default. + cnt_os = rr.service(counter) # Apply state changes sequentially so ObjectFuture chaining starts from # a deterministic counter value. @@ -284,13 +284,13 @@ def test_runner_error_no_storage_config(): def test_runner_stateful_instance(check_package_config, check_flmrun_app): - """Test Case 14: Test Runner with stateful=True for instance.""" + """Test Case 14: Test Runner with default stateful instance.""" with runner.Runner("test-runner-stateful") as rr: # Create a Counter instance counter = Counter() - # Create a stateful service (state should persist across tasks) - cnt_service = rr.service(counter, stateful=True, autoscale=False) + # Instance services are stateful by default. + cnt_service = rr.service(counter) # Call methods cnt_service.add(5).wait() @@ -306,7 +306,7 @@ def test_runner_stateless_function(check_package_config, check_flmrun_app): """Test Case 15: Test Runner with stateless function (default behavior).""" with runner.Runner("test-runner-stateless-func") as rr: # Create a service with a function (stateless by default) - sum_service = rr.service(sum_func, stateful=False, autoscale=True) + sum_service = rr.service(sum_func) # Call the function multiple times results = [sum_service(i, i + 1) for i in range(5)] @@ -321,7 +321,7 @@ def test_runner_class_single_instance(check_package_config, check_flmrun_app): """Test Case 16: Test Runner with class and autoscale=False (single instance).""" with runner.Runner("test-runner-class-single") as rr: # Create a service with a class, single instance mode - calc_service = rr.service(Calculator, stateful=False, autoscale=False) + calc_service = rr.service(Calculator, autoscale=False) # Call methods result1 = calc_service.add(10, 5) @@ -331,14 +331,14 @@ def test_runner_class_single_instance(check_package_config, check_flmrun_app): assert values == [15, 12], f"Expected [15, 12], got {values}" -def test_runner_error_stateful_class(check_package_config, check_flmrun_app): - """Test Case 17: Test that stateful=True raises error for class.""" - with runner.Runner("test-runner-stateful-class-error") as rr: - # Trying to create a stateful service with a class should raise ValueError +def test_runner_error_object_autoscale(check_package_config, check_flmrun_app): + """Test Case 17: Test that object instances cannot autoscale.""" + with runner.Runner("test-runner-object-autoscale-error") as rr: + # Object instance services are always fixed. with pytest.raises(ValueError) as exc_info: - rr.service(Counter, stateful=True) + rr.service(Counter(), autoscale=True) - assert "Cannot set stateful=True for a class" in str(exc_info.value) + assert "always fixed" in str(exc_info.value) def test_runner_defaults_function(check_package_config, check_flmrun_app): @@ -354,9 +354,9 @@ def test_runner_defaults_function(check_package_config, check_flmrun_app): def test_runner_defaults_class(check_package_config, check_flmrun_app): - """Test Case 19: Test default parameters for class (stateful=False, autoscale=False).""" + """Test Case 19: Test default parameters for class (stateful=False, autoscale=True).""" with runner.Runner("test-runner-defaults-class") as rr: - # Create service with class using defaults (should be stateful=False, autoscale=False) + # Create service with class using defaults (should be stateful=False, autoscale=True) calc_service = rr.service(Calculator) # Use a stateless method because class services cannot be stateful. @@ -367,20 +367,19 @@ def test_runner_defaults_class(check_package_config, check_flmrun_app): def test_runner_defaults_instance(check_package_config, check_flmrun_app): - """Test Case 20: Test default parameters for instance (stateful=False, autoscale=False).""" + """Test Case 20: Test default parameters for instance (stateful=True, autoscale=False).""" with runner.Runner("test-runner-defaults-instance") as rr: - # Create an instance - calc = Calculator() + counter = Counter() - # Create service with instance using defaults (should be stateful=False, autoscale=False) - calc_service = rr.service(calc) + # Create service with instance using defaults (should be stateful=True, autoscale=False) + counter_service = rr.service(counter) - # Call methods - result1 = calc_service.add(5, 3) - result2 = calc_service.subtract(10, 4) + counter_service.add(5).wait() + counter_service.increment().wait() + result = counter_service.get_count() - values = rr.get([result1, result2]) - assert values == [8, 6], f"Expected [8, 6], got {values}" + value = result.get() + assert value == 6, f"Expected 6, got {value}" def test_runner_auto_start(check_package_config, check_flmrun_app): @@ -715,26 +714,28 @@ def test_runner_recursive_same_session(check_package_config, check_flmrun_app): """ import logging import time + import uuid logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Shared application name and session ID - shared_app_name = "test-runner-recursive" - shared_session_id = "recursive-session-001" + recursive_suffix = uuid.uuid4().hex[:8] + shared_app_name = f"test-runner-recursive-{recursive_suffix}" + shared_session_id = f"recursive-session-{recursive_suffix}" logger.info(f"[TEST] Starting recursive test: app={shared_app_name}, session={shared_session_id}") - # Create an instance with the shared session ID and app name - recursive_instance = RecursiveService( - session_id=shared_session_id, - app_name=shared_app_name, - ) + class RecursiveTestService(RecursiveService): + _session_context = SessionContext( + session_id=shared_session_id, + application_name=shared_app_name, + ) with runner.Runner(shared_app_name) as rr: # Use autoscale=True to allow multiple executors for recursive calls # Without autoscale, a single executor would deadlock waiting for its own recursive task - service = rr.service(recursive_instance, autoscale=True) + service = rr.service(RecursiveTestService, autoscale=True) logger.info(f"[TEST] Service created, session_id={service._session.id}") # Verify the session ID matches @@ -1123,7 +1124,7 @@ def test_task_chaining_sequential(self, check_package_config, check_flmrun_app): """Test sequential task chaining where output of one task feeds into next.""" with runner.Runner("test-drf-chain-seq") as rr: counter = Counter() - cnt_service = rr.service(counter, stateful=True, autoscale=False) + cnt_service = rr.service(counter) cnt_service.add(10).wait() cnt_service.add(5).wait() @@ -1137,7 +1138,7 @@ def test_task_chaining_with_objectfuture(self, check_package_config, check_flmru """Test chaining using ObjectFuture as argument to next task.""" with runner.Runner("test-drf-chain-objfuture") as rr: counter = Counter() - cnt_service = rr.service(counter, stateful=True, autoscale=False) + cnt_service = rr.service(counter) cnt_service.add(10).wait() intermediate = cnt_service.get_count() @@ -1279,7 +1280,7 @@ def test_stateful_counter_operations(self, check_package_config, check_flmrun_ap """Test stateful counter with multiple operations.""" with runner.Runner("test-drf-stateful-counter") as rr: counter = Counter() - cnt_service = rr.service(counter, stateful=True, autoscale=False) + cnt_service = rr.service(counter) cnt_service.add(100).wait() cnt_service.increment().wait() @@ -1296,8 +1297,8 @@ def test_stateful_isolation_between_services(self, check_package_config, check_f counter1 = Counter() counter2 = Counter() - svc1 = rr.service(counter1, stateful=True, autoscale=False) - svc2 = rr.service(counter2, stateful=True, autoscale=False) + svc1 = rr.service(counter1) + svc2 = rr.service(counter2) svc1.add(10).wait() svc1.increment().wait() diff --git a/sdk/python/docs/API.md b/sdk/python/docs/API.md index d0655d60..46c06b1b 100644 --- a/sdk/python/docs/API.md +++ b/sdk/python/docs/API.md @@ -178,7 +178,7 @@ with Runner("add-app") as runner: Key classes and helpers: - `Runner(name, fail_if_exists=False)` -- `Runner.service(execution_object, stateful=None, autoscale=None, warmup=0, resreq=None)` +- `Runner.service(execution_object, autoscale=None, warmup=0, resreq=None)` - `Runner.get(futures)`, `Runner.ref(futures)`, `Runner.wait(futures)`, `Runner.select(futures)` - `ObjectFuture.get()`, `ObjectFuture.ref()`, `ObjectFuture.wait()` - `get_data(data)` for decoding Runner task input/output payloads diff --git a/sdk/python/src/flamepy/runner/runner.py b/sdk/python/src/flamepy/runner/runner.py index a45b01ed..f9efad64 100644 --- a/sdk/python/src/flamepy/runner/runner.py +++ b/sdk/python/src/flamepy/runner/runner.py @@ -132,8 +132,7 @@ def __init__( self, app: str, execution_object: Any, - stateful: bool = False, - autoscale: bool = True, + autoscale: Optional[bool] = None, warmup: int = 0, resreq: Optional[ResourceRequirement] = None, ): @@ -147,13 +146,13 @@ def __init__( or function. If the object has a `_session_context` attribute of type SessionContext, its session_id will be used instead of auto-generating one. - stateful: If True, persist the execution object state back to flame-cache - after each task. If False, do not persist state. - autoscale: If True, create instances dynamically based on pending tasks - (min=warmup or 0, max=None). If False, create a fixed number - of instances (min=max=warmup or 1). + autoscale: For functions, builtins, and classes, whether to create + instances dynamically based on pending tasks. Defaults to + True for those service types. Object instances are always + fixed and cannot autoscale. warmup: Number of instances to pre-create at session start. When - autoscale=False, this sets the fixed instance count. + autoscale=False, this sets the fixed instance count. Object + instances only support warmup values 0 and 1. resreq: Optional explicit resource requirements. When omitted, the server applies cluster.resource_requirement (or a hardcoded fallback when that is unset). @@ -179,12 +178,12 @@ def __init__( # Create a session with flamepy.runner.runpy service # For RL module: serialize RunnerContext with cloudpickle, put in cache to get ObjectRef, # then encode ObjectRef to bytes for core API - runner_context = RunnerContext(execution_object=execution_object, stateful=stateful, autoscale=autoscale, warmup=warmup) + runner_context = RunnerContext(execution_object=execution_object, autoscale=autoscale, warmup=warmup) # Serialize the context using cloudpickle serialized_ctx = cloudpickle.dumps(runner_context, protocol=cloudpickle.DEFAULT_PROTOCOL) # Put in cache with / key prefix key_prefix = f"{app}/{session_id}" - logger.debug(f"[RunnerService] Putting RunnerContext in cache: key_prefix={key_prefix}, stateful={stateful}, autoscale={autoscale}") + logger.debug(f"[RunnerService] Putting RunnerContext in cache: key_prefix={key_prefix}, stateful={runner_context.stateful}, autoscale={runner_context.autoscale}") object_ref = put_object(key_prefix, serialized_ctx) logger.debug(f"[RunnerService] RunnerContext cached: key={object_ref.key}, version={object_ref.version}") # Encode ObjectRef to bytes for core API @@ -564,7 +563,6 @@ def close(self) -> None: def service( self, execution_object: Any, - stateful: Optional[bool] = None, autoscale: Optional[bool] = None, warmup: int = 0, resreq: Optional[ResourceRequirement] = None, @@ -573,15 +571,13 @@ def service( Args: execution_object: A function, class, or class instance to expose as a service - stateful: If True, persist the execution object state back to flame-cache - after each task. If False, do not persist state. If None, use default - based on execution_object type (default: False for all types). - autoscale: If True, create instances dynamically based on pending tasks - (min=warmup or 0, max=None). If False, create a fixed number - of instances (min=max=warmup or 1). - If None, use default based on execution_object type. + autoscale: Functions, builtins, and classes can autoscale; their default + is True. Object instances are fixed and cannot autoscale. warmup: Number of instances to pre-create at session start. When - autoscale=False, this sets the fixed instance count. Default: 0. + autoscale=False, this sets the fixed instance count. With + warmup=0, fixed services create one instance and autoscaled + services start from zero. Object instances only support warmup + values 0 and 1. Default: 0. resreq: Optional explicit resource requirements. When omitted, the server applies cluster.resource_requirement (or a hardcoded fallback when that is unset). @@ -590,29 +586,14 @@ def service( A RunnerService instance Raises: - ValueError: If stateful=True is set for a class (only instances can be stateful) + ValueError: If the requested stateful/autoscale/warmup combination is + not supported for the execution object type. """ - is_function = callable(execution_object) and not inspect.isclass(execution_object) - is_class = inspect.isclass(execution_object) - - if stateful is None: - stateful = False - - if autoscale is None: - if is_function: - autoscale = True - else: - autoscale = False - - if stateful and is_class: - raise ValueError("Cannot set stateful=True for a class. Classes themselves cannot maintain state; only instances can. Pass an instance instead, or set stateful=False.") - - logger.debug(f"Creating service for {type(execution_object).__name__} (stateful={stateful}, autoscale={autoscale}, warmup={warmup})") + logger.debug(f"Creating service for {type(execution_object).__name__} (autoscale={autoscale}, warmup={warmup})") runner_service = RunnerService( self._name, execution_object, - stateful=stateful, autoscale=autoscale, warmup=warmup, resreq=resreq, diff --git a/sdk/python/src/flamepy/runner/types.py b/sdk/python/src/flamepy/runner/types.py index c930e5c8..4225419f 100644 --- a/sdk/python/src/flamepy/runner/types.py +++ b/sdk/python/src/flamepy/runner/types.py @@ -74,31 +74,54 @@ class RunnerContext: Attributes: execution_object: The execution object for the customized session. - stateful: If True, persist the execution object state back to flame-cache after each task. + stateful: Derived from execution_object type unless explicitly provided. + Object instances are always stateful. Functions, builtins, and + classes are always stateless. autoscale: If True, create instances dynamically (min=warmup or 0, max=None). - If False, create fixed instances (min=max=warmup or 1). + If False, create fixed instances (min=max=warmup or 1). Object + instances are always fixed. warmup: Number of instances to pre-create. When autoscale=True, sets min_instances. When autoscale=False, sets both min_instances and max_instances. + Object instances only support warmup values 0 and 1. min_instances: Minimum number of instances (computed from autoscale and warmup) max_instances: Maximum number of instances (computed from autoscale and warmup) """ execution_object: Any - stateful: bool = False - autoscale: bool = True + stateful: Optional[bool] = None + autoscale: Optional[bool] = None warmup: int = 0 min_instances: int = field(init=False, repr=False) max_instances: Optional[int] = field(init=False, repr=False) def __post_init__(self) -> None: """Compute min/max instances and validate configuration.""" + if self.warmup < 0: + raise ValueError("warmup must be a non-negative integer.") + + is_function = callable(self.execution_object) and not inspect.isclass(self.execution_object) + is_class = inspect.isclass(self.execution_object) + is_object = not is_function and not is_class + + if is_object: + if self.stateful is False: + raise ValueError("Object instance services are always stateful. Omit stateful or pass stateful=True.") + if self.autoscale is True: + raise ValueError("Object instance services are always fixed. Omit autoscale or pass autoscale=False.") + if self.warmup not in (0, 1): + raise ValueError("Object instance services only support warmup=0 or warmup=1.") + self.stateful = True + self.autoscale = False + else: + if self.stateful is True: + raise ValueError("Functions, builtins, and classes are always stateless. Omit stateful or pass stateful=False.") + self.stateful = False + self.autoscale = True if self.autoscale is None else self.autoscale + default_min = 0 if self.autoscale else 1 self.min_instances = self.warmup if self.warmup > 0 else default_min self.max_instances = None if self.autoscale else self.min_instances - if self.stateful and inspect.isclass(self.execution_object): - raise ValueError("Cannot set stateful=True for a class. Classes themselves cannot maintain state; only instances can. Pass an instance instead, or set stateful=False.") - @dataclass class RunnerRequest: diff --git a/sdk/python/tests/test_runner.py b/sdk/python/tests/test_runner.py index 0756e952..9ffd97af 100644 --- a/sdk/python/tests/test_runner.py +++ b/sdk/python/tests/test_runner.py @@ -434,6 +434,120 @@ def close(self): rs._session.close.assert_called_once() +def test_runner_context_defaults_by_execution_object(): + """Test RunnerContext default stateful/autoscale/warmup values by execution object type.""" + import functools + + def sample_func(x=0): + return x + + class SampleService: + def method(self): + return "ok" + + contexts = [ + RunnerContext(sample_func), + RunnerContext(functools.partial(sample_func, x=1)), + RunnerContext(len), + RunnerContext(SampleService), + RunnerContext(SampleService, autoscale=False, warmup=2), + RunnerContext(SampleService()), + RunnerContext(SampleService(), warmup=1), + ] + + assert [(ctx.stateful, ctx.autoscale, ctx.warmup, ctx.min_instances, ctx.max_instances) for ctx in contexts] == [ + (False, True, 0, 0, None), + (False, True, 0, 0, None), + (False, True, 0, 0, None), + (False, True, 0, 0, None), + (False, False, 2, 2, 2), + (True, False, 0, 1, 1), + (True, False, 1, 1, 1), + ] + + +def test_runner_service_passes_public_options(monkeypatch): + """Test Runner.service forwards only public options to RunnerService.""" + from flamepy.runner.runner import Runner + + calls = [] + + class FakeRunnerService: + def __init__(self, app, execution_object, autoscale=None, warmup=0, resreq=None): + calls.append((app, execution_object, autoscale, warmup, resreq)) + + def sample_func(): + return "ok" + + runner = object.__new__(Runner) + runner._name = "test-runner-service-options" + runner._services = [] + resreq = object() + + monkeypatch.setattr("flamepy.runner.runner.RunnerService", FakeRunnerService) + + runner.service(sample_func, autoscale=False, warmup=2, resreq=resreq) + + assert calls == [("test-runner-service-options", sample_func, False, 2, resreq)] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"autoscale": True}, "always fixed"), + ({"warmup": 2}, "only support warmup=0 or warmup=1"), + ], +) +def test_runner_service_rejects_unsupported_options(kwargs, message): + """Test Runner.service rejects unsupported object autoscale/warmup combinations.""" + from flamepy.runner.runner import Runner + + runner = object.__new__(Runner) + runner._name = "test-runner-invalid-defaults" + runner._services = [] + + with pytest.raises(ValueError, match=message): + runner.service(object(), **kwargs) + + +@pytest.mark.parametrize( + ("execution_object", "kwargs", "message"), + [ + (lambda: None, {"stateful": True}, "always stateless"), + (str, {"stateful": True}, "always stateless"), + (object(), {"stateful": False}, "always stateful"), + (object(), {"autoscale": True}, "always fixed"), + (object(), {"warmup": 2}, "only support warmup=0 or warmup=1"), + (lambda: None, {"warmup": -1}, "warmup must be a non-negative integer"), + ], +) +def test_runner_context_rejects_unsupported_options(execution_object, kwargs, message): + """Test RunnerContext rejects unsupported stateful/autoscale/warmup combinations.""" + with pytest.raises(ValueError, match=message): + RunnerContext(execution_object, **kwargs) + + +def test_runner_service_does_not_expose_stateful(): + """Test Runner.service derives statefulness instead of exposing it.""" + import inspect + + from flamepy.runner.runner import Runner + + assert "stateful" not in inspect.signature(Runner.service).parameters + + +def test_runner_service_rejects_stateful_keyword(): + """Test removed public stateful option fails as an unsupported keyword.""" + from flamepy.runner.runner import Runner + + runner = object.__new__(Runner) + runner._name = "test-runner-stateful-keyword" + runner._services = [] + + with pytest.raises(TypeError, match="unexpected keyword argument 'stateful'"): + runner.service(object(), stateful=True) + + def test_runpy_resolves_object_ref_to_cached_none(): """Test cached None is a valid ObjectRef value, not a retrieval miss.""" from flamepy.core.cache import ObjectRef @@ -610,14 +724,14 @@ class MyClass: ctx = RunnerContext(execution_object=instance, stateful=True) assert ctx.stateful is True - def test_runner_context_stateful_with_function(self): - """Test RunnerContext stateful=True is allowed for functions.""" + def test_runner_context_stateful_with_function_raises(self): + """Test RunnerContext rejects stateful=True for functions.""" def my_func(): pass - ctx = RunnerContext(execution_object=my_func, stateful=True) - assert ctx.stateful is True + with pytest.raises(ValueError, match="always stateless"): + RunnerContext(execution_object=my_func, stateful=True) def test_runner_context_stateful_with_class_raises(self): """Test RunnerContext stateful=True raises for classes.""" @@ -625,7 +739,7 @@ def test_runner_context_stateful_with_class_raises(self): class MyClass: pass - with pytest.raises(ValueError, match="Cannot set stateful=True for a class"): + with pytest.raises(ValueError, match="always stateless"): RunnerContext(execution_object=MyClass, stateful=True) def test_runner_context_stateful_false_with_class(self):