From a3afe7a8f57c4ed7cb794eec568c1e76b1d47377 Mon Sep 17 00:00:00 2001 From: yaythomas Date: Wed, 29 Apr 2026 14:49:58 -0700 Subject: [PATCH 1/2] fix: virtual ctx fail checkpoint & refactor Virtual child contexts (FLAT-mode map/parallel branches) no longer write FAIL checkpoints when the user function raises. The branch is a logical scope only; it does not appear in the execution history regardless of outcome, aligning with the JS reference SDK. Also fixes an incoherent state when a user set ChildConfig.is_virtual=True via run_in_child_context: lifecycle checkpoints were suppressed, but the child context's _parent_id was the child's own operation id (never announced in the checkpoint stream), so inner operations stamped a parent_id pointing to a dangling reference. Nesting produced a chain of such references. The two decisions (lifecycle suppression, parent-id propagation) are now coupled through ChildConfig.is_virtual. Refactor of the supporting mechanism: - Single source of truth for the virtual-vs-real decision. create_child_context computes two fields (_parent_id, _step_id_prefix) at construction; no per-operation-method knowledge is required. New operations just read self._parent_id and work correctly under both modes. - Field names match their roles. _parent_id is "the id my inner operations stamp as their parent"; _step_id_prefix is "how I prefix step ids". Each field has one job. - is_virtual is encapsulated in the context as a cached property. Callers opt in with create_child_context(..., is_virtual=True); the property makes the state inspectable. - Nested virtual-in-virtual matches the JS reference. A virtual child of a virtual parent inherits its parent's reporting ancestor, so chained FLAT layers collapse to the outermost non-virtual context without dangling parent-id references. - ChildConfig.is_virtual drives lifecycle-checkpoint suppression in ChildOperationExecutor (START, SUCCEED, FAIL) and, via run_in_child_context, the child context's own virtual-ness. The field remains public, matching the JS SDK's ChildConfig.virtualContext. - Fewer parameters threaded through. operation_identifier is gone from ConcurrentExecutor, MapExecutor, and ParallelExecutor constructors and from_items/from_callables; the concurrency layer no longer needs an OperationIdentifier to figure out the reporting parent. - Tests exercise the invariants directly with a real DurableContext and assert wire-format decisions, including nested-virtual-in- virtual coverage. Improves observability (no phantom FAILED CONTEXT entries for virtual branches), cost (no billable operation per failed virtual branch), and cross-SDK wire parity. fixes #362, fixes #363 --- .../concurrency/executor.py | 40 ++- .../config.py | 42 ++- .../context.py | 88 ++++-- .../operation/child.py | 61 ++-- .../operation/map.py | 5 - .../operation/parallel.py | 5 - tests/concurrency_test.py | 287 ++++++++++++++---- tests/context_test.py | 203 ++++++++++++- tests/operation/child_test.py | 157 +++++++--- tests/operation/map_test.py | 23 +- tests/operation/parallel_test.py | 40 +-- 11 files changed, 713 insertions(+), 238 deletions(-) diff --git a/src/aws_durable_execution_sdk_python/concurrency/executor.py b/src/aws_durable_execution_sdk_python/concurrency/executor.py index 397fd7c..24c7657 100644 --- a/src/aws_durable_execution_sdk_python/concurrency/executor.py +++ b/src/aws_durable_execution_sdk_python/concurrency/executor.py @@ -137,7 +137,6 @@ class ConcurrentExecutor(ABC, Generic[CallableType, ResultType]): def __init__( self, - operation_identifier: OperationIdentifier, executables: list[Executable[CallableType]], max_concurrency: int | None, completion_config: CompletionConfig, @@ -158,7 +157,6 @@ def __init__( handle large BatchResult payloads efficiently. Matches TypeScript behavior in run-in-child-context-handler.ts. """ - self.operation_identifier = operation_identifier self.executables = executables self.max_concurrency = max_concurrency self.completion_config = completion_config @@ -398,36 +396,36 @@ def _execute_item_in_child_context( """ Execute a single item in a derived child context. - instead of relying on `executor_context.run_in_child_context` - we generate an operation_id for the child, and then call `child_handler` - directly. This avoids the hidden mutation of the context's internal counter. - we can do this because we explicitly control the generation of step_id and do it - using executable.index. + Instead of relying on `executor_context.run_in_child_context` we + generate an operation_id for the child, then call `child_handler` + directly. This avoids the hidden mutation of the context's + internal counter. We explicitly derive the child's operation_id + from `executable.index` so that the same input always produces + the same id regardless of the order branches actually run in. - - invariant: `operation_id` for a given executable is deterministic, - and execution order invariant. + Invariant: `operation_id` for a given executable is deterministic + and execution-order invariant. """ - operation_id = executor_context._create_step_id_for_logical_step( # noqa: SLF001 + operation_id: str = executor_context._create_step_id_for_logical_step( # noqa: SLF001 executable.index ) - name = f"{self.name_prefix}{executable.index}" - non_virtual_parent_id = ( - self.operation_identifier.operation_id - if self.nesting_type is NestingType.FLAT - else None - ) - child_context = executor_context.create_child_context( - operation_id, non_virtual_parent_id + name: str = f"{self.name_prefix}{executable.index}" + is_virtual: bool = self.nesting_type is NestingType.FLAT + + child_context: DurableContext = executor_context.create_child_context( + operation_id, is_virtual=is_virtual ) + # For NESTED this is for branch's START/SUCCEED/FAIL checkpoints (not the children of the branch). + # For FLAT `child_handler` skips checkpoints, so not used. + # Construct it unconditionally to keep the call simple. operation_identifier = OperationIdentifier( operation_id, executor_context._parent_id, # noqa: SLF001 name, ) - def run_in_child_handler(): + def run_in_child_handler() -> ResultType: return self.execute_item(child_context, executable) result: ResultType = child_handler( @@ -438,7 +436,7 @@ def run_in_child_handler(): serdes=self.item_serdes or self.serdes, sub_type=self.sub_type_iteration, summary_generator=self.summary_generator, - is_virtual=self.nesting_type is NestingType.FLAT, + is_virtual=is_virtual, ), ) child_context.state.track_replay(operation_id=operation_id) diff --git a/src/aws_durable_execution_sdk_python/config.py b/src/aws_durable_execution_sdk_python/config.py index 08d9a95..980786d 100644 --- a/src/aws_durable_execution_sdk_python/config.py +++ b/src/aws_durable_execution_sdk_python/config.py @@ -77,15 +77,39 @@ class TerminationMode(Enum): class NestingType(Enum): - """ - How child operations should inherit context from their parent. + """Control how child contexts are created for batch operations. + + Applies to `map` and `parallel`. Each branch or iteration runs inside a + child context. + + - NESTED: full checkpointed context + - FLAT: a virtual context that skips checkpoints for the branch/iteration. - - NESTED: Each child operation runs in its own isolated context - - FLAT: All child operations share the same parent context """ NESTED = "NESTED" + """Create CONTEXT operations for each branch/iteration with full checkpointing. + + Operations within each branch/iteration are wrapped in their own context. + + - Observability: high — each branch/iteration appears as a separate + operation in execution history. + - Cost: higher — consumes more operations due to CONTEXT creation + overhead. + - Scale: lower maximum iterations due to operation limits. + """ + FLAT = "FLAT" + """Skip CONTEXT operations for branches/iterations using virtual contexts. + + Operations execute directly without individual context wrapping. + + - Observability: lower — branches/iterations don't appear as separate + operations in execution history. + - Cost: ~30% lower — reduces operation consumption by skipping CONTEXT + overhead. + - Scale: higher maximum iterations possible within operation limits. + """ @dataclass(frozen=True) @@ -271,9 +295,13 @@ class ChildConfig(Generic[T]): Used internally by map/parallel operations to handle large BatchResult payloads. Signature: (result: T) -> str - is_virtual: Whether the child operation is virtual (doesn't represent a real operation). - Virtual contexts are used for concurrency operations and don't appear in - the final execution history. Default is False. + is_virtual: When True, skip all checkpoints (START, SUCCEED, + FAIL) for this child context and propagate the caller's reporting + parent id through to operations created inside the child. The + branch is a logical scope for step-id prefixing but does not + appear in the execution history. Used internally by + NestingType.FLAT branches. Use this to group operations without + adding a CONTEXT entry to the execution history. See TypeScript reference: aws-durable-execution-sdk-js/src/types/index.ts """ diff --git a/src/aws_durable_execution_sdk_python/context.py b/src/aws_durable_execution_sdk_python/context.py index 4e93cde..bfded98 100644 --- a/src/aws_durable_execution_sdk_python/context.py +++ b/src/aws_durable_execution_sdk_python/context.py @@ -237,14 +237,21 @@ def __init__( lambda_context: LambdaContext | None = None, parent_id: str | None = None, logger: Logger | None = None, - non_virtual_parent_id: str | None = None, + step_id_prefix: str | None = None, ) -> None: self.state: ExecutionState = state self.execution_context: ExecutionContext = execution_context self.lambda_context = lambda_context + # operations inside this context use this id as their parent self._parent_id: str | None = parent_id + # child operations use this to generate deterministic step ids. + # differs from `parent_id` only for virtual contexts. + self._step_id_prefix: str | None = ( + step_id_prefix if step_id_prefix is not None else parent_id + ) + # cached at construction to make invariant even if parent/prefix mutates. + self._is_virtual: bool = self._parent_id != self._step_id_prefix self._step_counter: OrderedCounter = OrderedCounter() - self._non_virtual_parent_id = non_virtual_parent_id or parent_id log_info = LogInfo( execution_state=state, @@ -256,6 +263,18 @@ def __init__( info=log_info, ) + @property + def is_virtual(self) -> bool: + """True if this context does not checkpoint its own start and completion. + + You create a virtual context by `create_child_context(..., is_virtual=True)`. + FLAT-mode `map`/`parallel` branches uses virtual contexts. Inner operations + use the grandfather as parent (enclosing non-virtual ancestor, skipping the branch level + in the hierarchy), while step ids are still prefixed with the branch's own + operation id so replay stays deterministic. + """ + return self._is_virtual + # region factories @staticmethod def from_lambda_context( @@ -272,22 +291,44 @@ def from_lambda_context( ) def create_child_context( - self, parent_id: str, non_virtual_parent_id=None + self, operation_id: str, *, is_virtual: bool = False ) -> DurableContext: - """Create a child context from the given parent.""" - logger.debug("Creating child context for parent %s", parent_id) + """Create a child context for the given operation. + + Args: + operation_id: The operation id that owns the child context. Used as + the child's step-id prefix in all cases. + is_virtual: When `True`, create a virtual child whose inner + operations report to this context's own `_parent_id` (one + level up the hierarchy). When `False` (default), produce a + regular child whose inner operations report to + `operation_id`. + + Returns: + A new `DurableContext` child for the current context. + """ + # For a virtual child, propagate the current `_parent_id` so its + # inner operations refer to the grandparent rather than the parent. + # For a regular non-virtual child, the child's own `operation_id` is + # the parent id for its inner operations (standard nesting). + child_parent_id: str | None = self._parent_id if is_virtual else operation_id + logger.debug( + "Creating child context for operation %s (is_virtual=%s)", + operation_id, + is_virtual, + ) return DurableContext( state=self.state, execution_context=self.execution_context, lambda_context=self.lambda_context, - parent_id=parent_id, + parent_id=child_parent_id, + step_id_prefix=operation_id, logger=self.logger.with_log_info( LogInfo( execution_state=self.state, - parent_id=parent_id, + parent_id=child_parent_id, ) ), - non_virtual_parent_id=non_virtual_parent_id, ) # endregion factories @@ -315,7 +356,8 @@ def _create_step_id_for_logical_step(self, step: int) -> str: This allows us to recover operation ids or even look forward without changing the internal state of this context. """ - step_id = f"{self._parent_id}-{step}" if self._parent_id else str(step) + prefix: str | None = self._step_id_prefix + step_id: str = f"{prefix}-{step}" if prefix else str(step) return hashlib.blake2b(step_id.encode()).hexdigest()[:64] def _create_step_id(self) -> str: @@ -353,7 +395,7 @@ def create_callback( state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=name, ), config=config, @@ -395,7 +437,7 @@ def invoke( state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=name, ), config=config, @@ -417,10 +459,10 @@ def map( operation_id = self._create_step_id() operation_identifier = OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=map_name, ) - map_context = self.create_child_context(parent_id=operation_id) + map_context = self.create_child_context(operation_id=operation_id) def map_in_child_context() -> BatchResult[R]: # map_context is a child_context of the context upon which `.map` @@ -461,9 +503,9 @@ def parallel( """Execute multiple callables in parallel.""" # _create_step_id() is thread-safe. rest of method is safe, since using local copy of parent id operation_id = self._create_step_id() - parallel_context = self.create_child_context(parent_id=operation_id) + parallel_context = self.create_child_context(operation_id=operation_id) operation_identifier = OperationIdentifier( - operation_id=operation_id, parent_id=self._non_virtual_parent_id, name=name + operation_id=operation_id, parent_id=self._parent_id, name=name ) def parallel_in_child_context() -> BatchResult[T]: @@ -517,15 +559,21 @@ def run_in_child_context( # _create_step_id() is thread-safe. rest of method is safe, since using local copy of parent id operation_id = self._create_step_id() + is_virtual: bool = config.is_virtual if config else False + def callable_with_child_context(): - return func(self.create_child_context(parent_id=operation_id)) + return func( + self.create_child_context( + operation_id=operation_id, is_virtual=is_virtual + ) + ) result: T = child_handler( func=callable_with_child_context, state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=step_name, ), config=config, @@ -550,7 +598,7 @@ def step( state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=step_name, ), context_logger=self.logger, @@ -577,7 +625,7 @@ def wait(self, duration: Duration, name: str | None = None) -> None: state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=name, ), ) @@ -632,7 +680,7 @@ def wait_for_condition( state=self.state, operation_identifier=OperationIdentifier( operation_id=operation_id, - parent_id=self._non_virtual_parent_id, + parent_id=self._parent_id, name=name, ), context_logger=self.logger, diff --git a/src/aws_durable_execution_sdk_python/operation/child.py b/src/aws_durable_execution_sdk_python/operation/child.py index 189f0a5..ecaf0f9 100644 --- a/src/aws_durable_execution_sdk_python/operation/child.py +++ b/src/aws_durable_execution_sdk_python/operation/child.py @@ -67,6 +67,7 @@ def __init__( self.state = state self.operation_identifier = operation_identifier self.config = config + self.is_virtual: bool = config.is_virtual self.sub_type = config.sub_type or OperationSubType.RUN_IN_CHILD_CONTEXT def check_result_status(self) -> CheckResult[T]: @@ -118,7 +119,7 @@ def check_result_status(self) -> CheckResult[T]: checkpointed_result.raise_callable_error() # Create START checkpoint if not exists - if not checkpointed_result.is_existent() and not self.config.is_virtual: + if not checkpointed_result.is_existent() and not self.is_virtual: start_operation: OperationUpdate = OperationUpdate.create_context_start( identifier=self.operation_identifier, sub_type=self.sub_type, @@ -157,7 +158,7 @@ def execute(self, checkpointed_result: CheckpointedResult) -> T: try: raw_result: T = self.func() - if self.config.is_virtual: + if self.is_virtual: logger.debug( "Virtual context: Exiting child context without creating another checkpoint. id: %s, name: %s", self.operation_identifier.operation_id, @@ -235,21 +236,23 @@ def execute(self, checkpointed_result: CheckpointedResult) -> T: raise except Exception as e: error_object = ErrorObject.from_exception(e) - fail_operation: OperationUpdate = OperationUpdate.create_context_fail( - identifier=self.operation_identifier, - error=error_object, - sub_type=self.sub_type, - ) - # Checkpoint child context FAIL with blocking (is_sync=True, default). - # Must ensure the failure state is persisted before raising the exception. - # This guarantees the error is durable and child operations won't be re-executed on replay. - self.state.create_checkpoint(operation_update=fail_operation) - - # InvocationError and its derivatives can be retried - # When we encounter an invocation error (in all of its forms), we bubble that - # error upwards (with the checkpoint in place) such that we reach the - # execution handler at the very top, which will then induce a retry from the - # dataplane. + # Virtual deliberately does not write checkpoints, but exception still propagates below + if not self.is_virtual: + fail_operation: OperationUpdate = OperationUpdate.create_context_fail( + identifier=self.operation_identifier, + error=error_object, + sub_type=self.sub_type, + ) + # Checkpoint child context FAIL with blocking (is_sync=True, default). + # Must ensure the failure state is persisted before raising the exception. + # This guarantees the error is durable and child operations won't be re-executed on replay. + self.state.create_checkpoint(operation_update=fail_operation) + + # InvocationError and its derivatives can be retried. + # When we encounter an invocation error (in all of its forms), we + # bubble that error upwards (with the checkpoint in place for + # non-virtual) such that we reach the execution handler at the + # very top, which will then make the backend retry. if isinstance(e, InvocationError): raise raise error_object.to_callable_runtime_error() from e @@ -261,24 +264,28 @@ def child_handler( operation_identifier: OperationIdentifier, config: ChildConfig | None, ) -> T: - """Public API for child context operations - maintains existing signature. + """Run a function in a child context. - This function creates a ChildOperationExecutor and delegates to its process() method, - maintaining backward compatibility with existing code that calls child_handler. + Create a ChildOperationExecutor and delegates to its process() method. Args: - func: The child context function to execute - state: The execution state - operation_identifier: The operation identifier - config: The child configuration (optional) + func: The child context function to execute. + state: The execution state. + operation_identifier: The operation identifier for this child context. + config: The child configuration (optional). When `config.is_virtual` + is True, the child context does not checkpoint (START, SUCCEED, FAIL) + for itself. Returns: - The result of executing the child context + The result of executing the child context. Raises: - May raise operation-specific errors during execution + May raise operation-specific errors during execution. """ executor = ChildOperationExecutor( - func, state, operation_identifier, config or ChildConfig() + func, + state, + operation_identifier, + config or ChildConfig(), ) return executor.process() diff --git a/src/aws_durable_execution_sdk_python/operation/map.py b/src/aws_durable_execution_sdk_python/operation/map.py index 667c553..8e9fb6a 100644 --- a/src/aws_durable_execution_sdk_python/operation/map.py +++ b/src/aws_durable_execution_sdk_python/operation/map.py @@ -36,7 +36,6 @@ class MapExecutor(Generic[T, R], ConcurrentExecutor[Callable, R]): # noqa: PYI059 def __init__( self, - operation_identifier: OperationIdentifier, executables: list[Executable[Callable]], items: Sequence[T], max_concurrency: int | None, @@ -50,7 +49,6 @@ def __init__( nesting_type: NestingType = NestingType.NESTED, ): super().__init__( - operation_identifier=operation_identifier, executables=executables, max_concurrency=max_concurrency, completion_config=completion_config, @@ -67,7 +65,6 @@ def __init__( @classmethod def from_items( cls, - operation_identifier: OperationIdentifier, items: Sequence[T], func: Callable, config: MapConfig, @@ -78,7 +75,6 @@ def from_items( ] return cls( - operation_identifier=operation_identifier, executables=executables, items=items, max_concurrency=config.max_concurrency, @@ -116,7 +112,6 @@ def map_handler( # See TypeScript reference: aws-durable-execution-sdk-js/src/handlers/map-handler/map-handler.ts (~line 79) executor: MapExecutor[T, R] = MapExecutor.from_items( - operation_identifier=operation_identifier, items=items, func=func, config=config or MapConfig(summary_generator=MapSummaryGenerator()), diff --git a/src/aws_durable_execution_sdk_python/operation/parallel.py b/src/aws_durable_execution_sdk_python/operation/parallel.py index d5e1531..4d7094a 100644 --- a/src/aws_durable_execution_sdk_python/operation/parallel.py +++ b/src/aws_durable_execution_sdk_python/operation/parallel.py @@ -29,7 +29,6 @@ class ParallelExecutor(ConcurrentExecutor[Callable, R]): def __init__( self, - operation_identifier: OperationIdentifier, executables: list[Executable[Callable]], max_concurrency: int | None, completion_config, @@ -42,7 +41,6 @@ def __init__( nesting_type: NestingType = NestingType.NESTED, ): super().__init__( - operation_identifier=operation_identifier, executables=executables, max_concurrency=max_concurrency, completion_config=completion_config, @@ -58,7 +56,6 @@ def __init__( @classmethod def from_callables( cls, - operation_identifier: OperationIdentifier, callables: Sequence[Callable], config: ParallelConfig, ) -> ParallelExecutor: @@ -67,7 +64,6 @@ def from_callables( Executable(index=i, func=func) for i, func in enumerate(callables) ] return cls( - operation_identifier=operation_identifier, executables=executables, max_concurrency=config.max_concurrency, completion_config=config.completion_config, @@ -102,7 +98,6 @@ def parallel_handler( # See TypeScript reference: aws-durable-execution-sdk-js/src/handlers/parallel-handler/parallel-handler.ts (~line 112) executor = ParallelExecutor.from_callables( - operation_identifier, callables, config or ParallelConfig(summary_generator=ParallelSummaryGenerator()), ) diff --git a/tests/concurrency_test.py b/tests/concurrency_test.py index 4761db5..3d9ae27 100644 --- a/tests/concurrency_test.py +++ b/tests/concurrency_test.py @@ -31,6 +31,10 @@ NestingType, ChildConfig, ) +from aws_durable_execution_sdk_python.context import ( + DurableContext, + ExecutionContext, +) from aws_durable_execution_sdk_python.exceptions import ( CallableRuntimeError, InvalidStateError, @@ -872,7 +876,6 @@ def execute_item(self, child_context, executable): # Test with NESTED (default) executor_nested = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -886,7 +889,6 @@ def execute_item(self, child_context, executable): # Test with FLAT executor_flat = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -910,7 +912,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -936,7 +937,6 @@ def execute_item(self, child_context, executable): tolerated_failure_percentage=None, ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -998,7 +998,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1037,7 +1036,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1073,7 +1071,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1123,7 +1120,6 @@ def failure_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -1138,7 +1134,7 @@ def failure_callable(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor.execute(execution_state, executor_context) @@ -1165,7 +1161,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1177,7 +1172,7 @@ def execute_item(self, child_context, executable): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor._execute_item_in_child_context( # noqa: SLF001 executor_context, executables[0] @@ -1216,7 +1211,6 @@ def failure_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1254,7 +1248,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1269,7 +1262,7 @@ def execute_item(self, child_context, executable): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should raise TimedSuspendExecution since no other tasks running with pytest.raises(TimedSuspendExecution): @@ -1301,7 +1294,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig.all_completed() executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -1316,7 +1308,7 @@ def execute_item(self, child_context, executable): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should raise TimedSuspendExecution after Task B completes with pytest.raises(TimedSuspendExecution): @@ -1347,7 +1339,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1362,7 +1353,7 @@ def execute_item(self, child_context, executable): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should raise TimedSuspendExecution since single task suspends with pytest.raises(TimedSuspendExecution): @@ -1421,7 +1412,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -1436,7 +1426,7 @@ def execute_item(self, child_context, executable): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should complete successfully after B resubmits and both tasks finish result = executor.execute(execution_state, executor_context) @@ -1486,7 +1476,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1525,7 +1514,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1567,7 +1555,6 @@ def failure_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1582,7 +1569,7 @@ def failure_callable(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor.execute(execution_state, executor_context) @@ -1628,7 +1615,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -1670,7 +1656,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -1747,7 +1732,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=3, completion_config=completion_config, @@ -1796,7 +1780,6 @@ def failure_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1811,7 +1794,7 @@ def failure_callable(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor.execute(execution_state, executor_context) @@ -1859,7 +1842,6 @@ def success_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1874,7 +1856,7 @@ def success_callable(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor.execute(execution_state, executor_context) @@ -1902,7 +1884,6 @@ def suspend_callable(): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1917,7 +1898,7 @@ def suspend_callable(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should raise SuspendExecution since single task suspends with pytest.raises(SuspendExecution): @@ -1936,7 +1917,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -1971,7 +1951,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2009,7 +1988,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2047,7 +2025,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2085,7 +2062,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2122,7 +2098,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2167,7 +2142,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=6, completion_config=completion_config, @@ -2254,7 +2228,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=3) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=3, completion_config=completion_config, @@ -2297,7 +2270,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=3, completion_config=completion_config, @@ -2341,7 +2313,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=4, completion_config=completion_config, @@ -2389,7 +2360,6 @@ def execute_item(self, child_context, executable): completion_config = CompletionConfig(min_successful=0) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=1, completion_config=completion_config, @@ -2543,7 +2513,10 @@ def execute_item(self, child_context, executable): captured_associations = [] def patched_child_handler( - func, execution_state, operation_identifier, config: ChildConfig + func, + execution_state, + operation_identifier, + config: ChildConfig, ): """Patched child handler that captures operation_id -> result mapping.""" assert config.is_virtual @@ -2569,7 +2542,6 @@ def patched_child_handler( random.shuffle(executables) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -2589,7 +2561,7 @@ def create_step_id(index): executor_context._create_step_id_for_logical_step = create_step_id # noqa SLF001 - def create_child_context(operation_id, non_virtual_parent_id): + def create_child_context(operation_id, *, is_virtual=False): child_ctx = Mock() child_ctx.state = execution_state return child_ctx @@ -2625,7 +2597,6 @@ def func1(ctx, item, idx, items): config = MapConfig() executor = MapExecutor.from_items( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), items=items, func=func1, config=config, @@ -2684,7 +2655,6 @@ def func1(ctx, item, idx, items): config = MapConfig() executor = MapExecutor.from_items( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), items=items, func=func1, config=config, @@ -2724,7 +2694,6 @@ def func1(ctx, item, idx, items): config = MapConfig() executor = MapExecutor.from_items( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), items=items, func=func1, config=config, @@ -2856,7 +2825,6 @@ def execute_item(self, child_context, executable): ) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -2870,7 +2838,7 @@ def execute_item(self, child_context, executable): execution_state.create_checkpoint = Mock() executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() # Should return (not hang) and batch should reflect one FAILED and one SUCCEEDED result = executor.execute(execution_state, executor_context) @@ -2899,7 +2867,6 @@ def task_func(ctx, item, idx, items): ) executor = MapExecutor.from_items( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), items=items, func=task_func, config=config, @@ -2909,7 +2876,7 @@ def task_func(ctx, item, idx, items): execution_state.create_checkpoint = Mock() executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = executor.execute(execution_state, executor_context) @@ -2952,7 +2919,6 @@ def slow_branch(): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -2968,7 +2934,7 @@ def slow_branch(): executor_context._create_step_id_for_logical_step = lambda idx: f"step_{idx}" # noqa: SLF001 executor_context._parent_id = "parent" # noqa: SLF001 - def create_child_context(op_id, non_virtual_parent_id): + def create_child_context(op_id, *, is_virtual=False): child = Mock() child.state = execution_state return child @@ -3026,7 +2992,6 @@ def slow_branch(): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -3041,7 +3006,7 @@ def slow_branch(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda idx: f"step_{idx}" # noqa: SLF001 executor_context._parent_id = "parent" # noqa: SLF001 - executor_context.create_child_context = lambda op_id, non_virtual_parent_id: Mock( + executor_context.create_child_context = lambda op_id, *, is_virtual=False: Mock( state=execution_state ) @@ -3087,7 +3052,6 @@ def slow_func(): completion_config = CompletionConfig(min_successful=1) executor = TestExecutor( - operation_identifier=OperationIdentifier("op-1", "parent-1", "name-1"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -3102,7 +3066,7 @@ def slow_func(): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda idx: f"step_{idx}" # noqa: SLF001 executor_context._parent_id = "parent" # noqa: SLF001 - executor_context.create_child_context = lambda op_id, non_virtual_parent_id: Mock( + executor_context.create_child_context = lambda op_id, *, is_virtual=False: Mock( state=execution_state ) @@ -3386,3 +3350,206 @@ def test_from_items_all_succeeded(): # endregion Completion Reason Inference Tests + +# region Virtual-context wire-format tests + + +def test_flat_mode_stamps_grandparent_as_inner_op_parent_id(): + """In FLAT mode, inner operations in a branch stamp the map/parallel op id as parent_id. + + This is the core FLAT-mode invariant. Inner operations must not + stamp the branch's own operation id (that would reproduce the + NESTED hierarchy) — they must stamp the enclosing map/parallel op + id, so the branch is collapsed out of the observable hierarchy + even though it still exists as a logical scope for concurrency + and step-id prefixing. + + The test drives `_execute_item_in_child_context` with a real + non-virtual executor context, captures the child context the + executor builds for the branch, and asserts the branch's + `_parent_id` equals the executor_context's own `_parent_id`. + """ + + class TestExecutor(ConcurrentExecutor): + def execute_item(self, child_context, executable): + # Record the child context we receive so the assertions below can + # inspect its identity fields. + self.last_child_context = child_context + return executable.func(child_context) + + execution_state = Mock() + execution_state.create_checkpoint = Mock() + + # Mock out the checkpoint so the real child_handler reports "not + # existent" (non-existent checkpoint -> normal execution path). + mock_checkpoint = Mock() + mock_checkpoint.is_succeeded.return_value = False + mock_checkpoint.is_failed.return_value = False + mock_checkpoint.is_existent.return_value = False + mock_checkpoint.is_replay_children.return_value = False + execution_state.get_checkpoint_result.return_value = mock_checkpoint + + # Build a real DurableContext that represents the map/parallel op. + map_op_id = "map-op-id" + execution_context = ExecutionContext( + durable_execution_arn="arn:aws:durable:us-east-1:0:execution/test" + ) + executor_context = DurableContext( + state=execution_state, + execution_context=execution_context, + parent_id=map_op_id, # This context *is* the map/parallel op. + ) + + executables = [Executable(index=0, func=lambda ctx: "ok")] + executor = TestExecutor( + executables=executables, + max_concurrency=1, + completion_config=CompletionConfig(min_successful=1), + sub_type_top="MAP", + sub_type_iteration="MAP_ITER", + name_prefix="branch-", + serdes=None, + nesting_type=NestingType.FLAT, + ) + + executor._execute_item_in_child_context(executor_context, executables[0]) # noqa: SLF001 + + # The branch's child context must be virtual AND propagate the + # map/parallel op id as its _parent_id. Inner operations stamping + # self._parent_id will therefore report to the map/parallel op. + branch_ctx = executor.last_child_context + assert branch_ctx.is_virtual is True + assert branch_ctx._parent_id == map_op_id # noqa: SLF001 + # The step-id prefix is the branch's own operation id (stable replay id). + assert branch_ctx._step_id_prefix != map_op_id # noqa: SLF001 + + +def test_nested_mode_stamps_branch_op_as_inner_op_parent_id(): + """In NESTED mode, inner operations in a branch stamp the branch's own operation id as parent_id.""" + + class TestExecutor(ConcurrentExecutor): + def execute_item(self, child_context, executable): + self.last_child_context = child_context + return executable.func(child_context) + + execution_state = Mock() + execution_state.create_checkpoint = Mock() + + mock_checkpoint = Mock() + mock_checkpoint.is_succeeded.return_value = False + mock_checkpoint.is_failed.return_value = False + mock_checkpoint.is_existent.return_value = False + mock_checkpoint.is_replay_children.return_value = False + execution_state.get_checkpoint_result.return_value = mock_checkpoint + + map_op_id = "map-op-id" + execution_context = ExecutionContext( + durable_execution_arn="arn:aws:durable:us-east-1:0:execution/test" + ) + executor_context = DurableContext( + state=execution_state, + execution_context=execution_context, + parent_id=map_op_id, + ) + + executables = [Executable(index=0, func=lambda ctx: "ok")] + executor = TestExecutor( + executables=executables, + max_concurrency=1, + completion_config=CompletionConfig(min_successful=1), + sub_type_top="MAP", + sub_type_iteration="MAP_ITER", + name_prefix="branch-", + serdes=None, + nesting_type=NestingType.NESTED, + ) + + executor._execute_item_in_child_context(executor_context, executables[0]) # noqa: SLF001 + + # In NESTED mode, the branch is a regular child — its _parent_id is + # its own operation id, not the grandparent. + branch_ctx = executor.last_child_context + assert branch_ctx.is_virtual is False + assert branch_ctx._parent_id == branch_ctx._step_id_prefix # noqa: SLF001 + assert branch_ctx._parent_id != map_op_id # noqa: SLF001 + + +# endregion Virtual-context wire-format tests + + +def test_flat_mode_produces_deterministic_step_ids_across_runs(): + """Step ids and inner parent_ids must be deterministic under FLAT mode. + + Replay depends on regenerating the same operation ids for the same + logical inputs. This test runs the same executor twice against + fresh executor contexts and asserts that the resulting set of + (step_id_prefix, parent_id) pairs is identical. Any source of + non-determinism (e.g. step prefixes that depend on thread + completion order, or parent-id propagation that's different on + the second run) would show up here as a mismatch and would cause + `NonDeterministicExecutionException` at replay time in production. + """ + + class TestExecutor(ConcurrentExecutor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.captured = [] + + def execute_item(self, child_context, executable): + self.captured.append( + ( + child_context._step_id_prefix, # noqa: SLF001 + child_context._parent_id, # noqa: SLF001 + ) + ) + return executable.func(child_context) + + def make_run(): + execution_state = Mock() + execution_state.create_checkpoint = Mock() + + mock_checkpoint = Mock() + mock_checkpoint.is_succeeded.return_value = False + mock_checkpoint.is_failed.return_value = False + mock_checkpoint.is_existent.return_value = False + mock_checkpoint.is_replay_children.return_value = False + execution_state.get_checkpoint_result.return_value = mock_checkpoint + + execution_context = ExecutionContext( + durable_execution_arn="arn:aws:durable:us-east-1:0:execution/test" + ) + executor_context = DurableContext( + state=execution_state, + execution_context=execution_context, + parent_id="map-op-id", + ) + + executables = [ + Executable(index=i, func=lambda ctx, i=i: f"r{i}") for i in range(3) + ] + executor = TestExecutor( + executables=executables, + max_concurrency=3, + completion_config=CompletionConfig(min_successful=3), + sub_type_top="MAP", + sub_type_iteration="MAP_ITER", + name_prefix="branch-", + serdes=None, + nesting_type=NestingType.FLAT, + ) + executor.execute(execution_state, executor_context) + return executor.captured + + run_a = make_run() + run_b = make_run() + + # Ordering of captured items is non-deterministic because branches run + # on a ThreadPoolExecutor. What matters is that the SET of (prefix, + # parent_id) pairs is identical across runs — i.e. replay reconstructs + # the same branch identity regardless of completion order. + assert sorted(run_a) == sorted(run_b), ( + "FLAT-mode branch step-id prefixes and parent_ids must be identical " + f"across runs. Run A: {run_a!r}; Run B: {run_b!r}" + ) + # Sanity: all branches reported grandparent (map op id) as their parent. + assert all(parent_id == "map-op-id" for _prefix, parent_id in run_a) diff --git a/tests/context_test.py b/tests/context_test.py index 507cfc5..af32a3e 100644 --- a/tests/context_test.py +++ b/tests/context_test.py @@ -1,5 +1,6 @@ """Unit tests for context.""" +import hashlib import json import random from itertools import islice @@ -1923,7 +1924,7 @@ def test_execution_context_propagates_to_child_context(): mock_state.durable_execution_arn = parent_arn parent_context = create_test_context(state=mock_state) - child_context = parent_context.create_child_context(parent_id="parent-op-123") + child_context = parent_context.create_child_context("parent-op-123") assert child_context.execution_context is not None assert child_context.execution_context.durable_execution_arn == parent_arn @@ -1959,3 +1960,203 @@ def test_execution_context_type(): # endregion ExecutionContext tests + +# region Virtual-context identity tests + + +def test_should_default_step_id_prefix_to_parent_id_when_not_specified(): + """A non-virtual context holds parent_id and step_id_prefix equal.""" + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + execution_context = ExecutionContext( + durable_execution_arn=mock_state.durable_execution_arn + ) + + ctx = DurableContext( + state=mock_state, + execution_context=execution_context, + parent_id="parent-op-1", + ) + + assert ctx._parent_id == "parent-op-1" # noqa: SLF001 + assert ctx._step_id_prefix == "parent-op-1" # noqa: SLF001 + assert ctx.is_virtual is False + + +def test_should_mark_context_virtual_when_parent_id_differs_from_step_prefix(): + """A virtual context holds parent_id and step_id_prefix with different values.""" + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + execution_context = ExecutionContext( + durable_execution_arn=mock_state.durable_execution_arn + ) + + ctx = DurableContext( + state=mock_state, + execution_context=execution_context, + parent_id="grandparent-op", + step_id_prefix="branch-op", + ) + + assert ctx._parent_id == "grandparent-op" # noqa: SLF001 + assert ctx._step_id_prefix == "branch-op" # noqa: SLF001 + assert ctx.is_virtual is True + + +def test_should_use_step_id_prefix_when_generating_step_ids(): + """Step ids derive from the step_id_prefix, not parent_id. + + For virtual contexts this is load-bearing: step ids must stay stable + across virtual/non-virtual construction so replay ids match. + """ + + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + execution_context = ExecutionContext( + durable_execution_arn=mock_state.durable_execution_arn + ) + + virtual = DurableContext( + state=mock_state, + execution_context=execution_context, + parent_id="grandparent-op", + step_id_prefix="branch-op", + ) + expected_prefixed = hashlib.blake2b(b"branch-op-1").hexdigest()[:64] + + assert virtual._create_step_id_for_logical_step(1) == expected_prefixed # noqa: SLF001 + + +def test_should_use_parent_id_as_step_prefix_when_non_virtual(): + """Non-virtual contexts prefix step ids with parent_id (default fallback). + + For the non-virtual case `step_id_prefix` is not passed explicitly; + it defaults to `parent_id`. Replay stability for executions produced + before the virtual-context refactor depends on this fallback + matching the pre-refactor behaviour exactly. + """ + + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + execution_context = ExecutionContext( + durable_execution_arn=mock_state.durable_execution_arn + ) + + non_virtual = DurableContext( + state=mock_state, + execution_context=execution_context, + parent_id="parent-op", + ) + expected = hashlib.blake2b(b"parent-op-1").hexdigest()[:64] + + assert non_virtual._create_step_id_for_logical_step(1) == expected # noqa: SLF001 + assert non_virtual.is_virtual is False + + +def test_should_create_non_virtual_child_when_is_virtual_false(): + """create_child_context(op_id) returns a non-virtual child.""" + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + parent = create_test_context(state=mock_state, parent_id="parent-op") + + child = parent.create_child_context("child-op") + + assert child._parent_id == "child-op" # noqa: SLF001 + assert child._step_id_prefix == "child-op" # noqa: SLF001 + assert child.is_virtual is False + + +def test_should_create_virtual_child_that_propagates_grandparent_id(): + """create_child_context(op_id, is_virtual=True) propagates the grandparent as parent_id.""" + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + parent = create_test_context(state=mock_state, parent_id="grandparent-op") + + child = parent.create_child_context("child-op", is_virtual=True) + + assert child._parent_id == "grandparent-op" # noqa: SLF001 + assert child._step_id_prefix == "child-op" # noqa: SLF001 + assert child.is_virtual is True + + +def test_should_create_virtual_child_with_none_parent_when_parent_is_root(): + """Virtual child of a root context (parent_id=None) keeps parent_id=None. + + Inner operations then report at the top level; step ids still prefix + on the child's own operation id. + """ + + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + root_parent = create_test_context(state=mock_state, parent_id=None) + + child = root_parent.create_child_context("child-op", is_virtual=True) + + assert child._parent_id is None # noqa: SLF001 + assert child._step_id_prefix == "child-op" # noqa: SLF001 + assert child.is_virtual is True + + expected = hashlib.blake2b(b"child-op-1").hexdigest()[:64] + assert child._create_step_id_for_logical_step(1) == expected # noqa: SLF001 + + +def test_should_propagate_outer_parent_id_when_virtual_is_nested_in_virtual(): + """A virtual child of a virtual parent still reports to the outer non-virtual ancestor. + + Nested concurrency is a real scenario: e.g. a FLAT `map` inside a + FLAT `parallel`. Each layer creates a virtual child. The inner + virtual child inherits `_parent_id` from its immediate (virtual) + parent, which in turn inherited it from its non-virtual + grandparent. The expected end result is that inner operations in + the doubly-nested virtual branch still stamp the outer + non-virtual ancestor's id — every virtual layer collapses out of + the observable hierarchy without accumulating. + """ + + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = ( + "arn:aws:durable:us-east-1:123456789012:execution/test" + ) + + # Non-virtual outer context (e.g. the top-level parallel operation's context). + outer = create_test_context(state=mock_state, parent_id="outer-parallel-op") + + # First virtual layer: outer parallel is FLAT, so its branch is virtual. + outer_branch = outer.create_child_context("outer-branch-op", is_virtual=True) + assert outer_branch._parent_id == "outer-parallel-op" # noqa: SLF001 + assert outer_branch._step_id_prefix == "outer-branch-op" # noqa: SLF001 + assert outer_branch.is_virtual is True + + # Second virtual layer: an inner FLAT map inside the outer branch, + # whose per-item branch is also virtual. + inner_branch = outer_branch.create_child_context("inner-branch-op", is_virtual=True) + # Inner branch's parent_id must be the outermost non-virtual + # ancestor's id, not the outer virtual branch's id — otherwise the + # inner operations would report to a logical layer that does not + # appear in the execution history, breaking the hierarchy. + assert inner_branch._parent_id == "outer-parallel-op" # noqa: SLF001 + assert inner_branch._step_id_prefix == "inner-branch-op" # noqa: SLF001 + assert inner_branch.is_virtual is True + + # Step ids inside the inner branch still prefix on the inner branch's + # own operation id; they must not leak the outer ancestor into the + # step-id namespace. + expected = hashlib.blake2b(b"inner-branch-op-1").hexdigest()[:64] + assert inner_branch._create_step_id_for_logical_step(1) == expected # noqa: SLF001 + + +# endregion Virtual-context identity tests diff --git a/tests/operation/child_test.py b/tests/operation/child_test.py index f94f46c..3915fd2 100644 --- a/tests/operation/child_test.py +++ b/tests/operation/child_test.py @@ -644,13 +644,55 @@ def my_summary(result: str) -> str: assert success_operation.payload == '"small_payload"' # JSON serialized +def test_small_payload_without_summary_generator(): + """Test: small payload without summary_generator -> replay_children=False. + + Restored from pre-PR #351. For small payloads we always checkpoint + the actual result (JSON-serialized); ReplayChildren mode exists only + to handle payloads that exceed the size limit, so a small payload + without a summary generator must still round-trip through a normal + SUCCEED checkpoint. + """ + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = "test_arn" + mock_result = Mock() + mock_result.is_succeeded.return_value = False + mock_result.is_failed.return_value = False + mock_result.is_started.return_value = False + mock_result.is_replay_children.return_value = False + mock_result.is_existent.return_value = False + mock_state.get_checkpoint_result.return_value = mock_result + + # Small payload (< 256KB); no summary_generator provided + small_result = "small_payload" + mock_callable = Mock(return_value=small_result) + + child_config: ChildConfig[str] = ChildConfig[str]() + + actual_result = child_handler( + mock_callable, + mock_state, + OperationIdentifier("op1", None, "test_name"), + child_config, + ) + + assert actual_result == small_result + assert mock_state.get_checkpoint_result.call_count == 1 + + success_call = mock_state.create_checkpoint.call_args_list[1] + success_operation = success_call[1]["operation_update"] + + # Small payload MUST NOT trigger replay_children. + assert not success_operation.context_options.replay_children + # Payload MUST be the JSON-serialized result, not a summary. + assert success_operation.payload == '"small_payload"' + + def test_child_handler_is_virtual_no_start(): - """Test child_handler with is_virtual skips START checkpoint. + """Skip the START checkpoint when is_virtual=True. - Verifies: - - NO_CHECKPOINT mode: no START checkpoint created when operation not started - - Only SUCCEED checkpoint created - - Function executes normally + A virtual branch is a logical scope for step-id prefixing but does + not appear in the execution history, so no START entry is emitted. """ mock_state = Mock(spec=ExecutionState) mock_state.durable_execution_arn = "test_arn" @@ -666,7 +708,10 @@ def test_child_handler_is_virtual_no_start(): config = ChildConfig(is_virtual=True) result = child_handler( - mock_callable, mock_state, OperationIdentifier("op1", None, "test_name"), config + mock_callable, + mock_state, + OperationIdentifier("op1", None, "test_name"), + config, ) assert result == "no_checkpoint_result" @@ -674,19 +719,18 @@ def test_child_handler_is_virtual_no_start(): # Verify get_checkpoint_result called once assert mock_state.get_checkpoint_result.call_count == 1 - # Verify only SUCCEED checkpoint created (no START) + # Verify no checkpoints created (virtual context writes none) assert mock_state.create_checkpoint.call_count == 0 mock_callable.assert_called_once() def test_child_handler_is_virtual_no_succeed(): - """Test child_handler with is_virtual skips SUCCEED checkpoint. + """Skip the SUCCEED checkpoint when is_virtual=True. - Verifies: - - NO_CHECKPOINT mode: no SUCCEED checkpoint created - - Function executes and returns result - - No checkpoints created at all + A virtual branch is not represented in the execution history; its + successful completion is observable only via the values returned + to the calling concurrency executor. """ mock_state = Mock(spec=ExecutionState) mock_state.durable_execution_arn = "test_arn" @@ -702,7 +746,10 @@ def test_child_handler_is_virtual_no_succeed(): config = ChildConfig(is_virtual=True) result = child_handler( - mock_callable, mock_state, OperationIdentifier("op2", None, "test_name"), config + mock_callable, + mock_state, + OperationIdentifier("op2", None, "test_name"), + config, ) assert result == "no_checkpoint_result" @@ -714,12 +761,7 @@ def test_child_handler_is_virtual_no_succeed(): def test_child_handler_not_is_virtual_finish_mode(): - """Test child_handler with is_virtual=False creates both checkpoints. - - Verifies: - - CHECKPOINT_AT_START_AND_FINISH mode: both START and SUCCEED checkpoints created - - Function executes normally - """ + """Create START + SUCCEED checkpoints when is_virtual=False.""" mock_state = Mock(spec=ExecutionState) mock_state.durable_execution_arn = "test_arn" mock_result = Mock() @@ -734,7 +776,10 @@ def test_child_handler_not_is_virtual_finish_mode(): config = ChildConfig(is_virtual=False) result = child_handler( - mock_callable, mock_state, OperationIdentifier("op3", None, "test_name"), config + mock_callable, + mock_state, + OperationIdentifier("op3", None, "test_name"), + config, ) assert result == "checkpoint_result" @@ -757,11 +802,14 @@ def test_child_handler_not_is_virtual_finish_mode(): def test_child_handler_is_virtual_with_exception(): - """Test child_handler with is_virtual still checkpoints failures. - - Verifies: - - NO_CHECKPOINT mode: FAIL checkpoint still created for exceptions - - Exception is properly raised + """Skip the FAIL checkpoint when is_virtual=True and the user function raises. + + A virtual branch emits no lifecycle entries in the execution + history, so a failure inside the branch does not get its own FAIL + checkpoint. The exception still propagates (wrapped as + CallableRuntimeError for non-InvocationError exceptions) so the + concurrency executor records the failure in the BatchResult and + its completion-tolerance logic still applies. """ mock_state = Mock(spec=ExecutionState) mock_state.durable_execution_arn = "test_arn" @@ -784,35 +832,52 @@ def test_child_handler_is_virtual_with_exception(): config, ) - # Verify FAIL checkpoint created (failures are always checkpointed) - assert mock_state.create_checkpoint.call_count == 1 - fail_call = mock_state.create_checkpoint.call_args_list[0] - fail_operation = fail_call[1]["operation_update"] - assert fail_operation.action.value == "FAIL" + # Verify NO FAIL checkpoint created (virtual contexts suppress all lifecycle checkpoints). + assert mock_state.create_checkpoint.call_count == 0 mock_callable.assert_called_once() -def test_child_config_is_virtual(): - """Test ChildConfig is_virtual works with other parameters.""" +def test_child_handler_not_is_virtual_with_exception(): + """Create a FAIL checkpoint when is_virtual=False and the user function raises.""" + mock_state = Mock(spec=ExecutionState) + mock_state.durable_execution_arn = "test_arn" + mock_result = Mock() + mock_result.is_succeeded.return_value = False + mock_result.is_failed.return_value = False + mock_result.is_started.return_value = False + mock_result.is_replay_children.return_value = False + mock_result.is_existent.return_value = False + mock_state.get_checkpoint_result.return_value = mock_result + mock_callable = Mock(side_effect=ValueError("Test error")) + + config = ChildConfig(is_virtual=False) + + with pytest.raises(CallableRuntimeError): + child_handler( + mock_callable, + mock_state, + OperationIdentifier("op5", None, "test_name"), + config, + ) - config = ChildConfig( - is_virtual=True, - sub_type=OperationSubType.STEP, - serdes=None, - ) + # Verify START + FAIL checkpoints created (non-virtual path). + assert mock_state.create_checkpoint.call_count == 2 + start_call = mock_state.create_checkpoint.call_args_list[0] + start_operation = start_call[1]["operation_update"] + assert start_operation.action.value == "START" + fail_call = mock_state.create_checkpoint.call_args_list[1] + fail_operation = fail_call[1]["operation_update"] + assert fail_operation.action.value == "FAIL" - assert config.is_virtual - assert config.sub_type == OperationSubType.STEP - assert config.serdes is None + mock_callable.assert_called_once() def test_child_handler_is_virtual_comparison(): - """Test comparing behavior between different is_virtual + """Compare checkpoint counts between is_virtual=True and is_virtual=False for success. - Verifies: - - CHECKPOINT_AT_START_AND_FINISH: creates 2 checkpoints (START + SUCCEED) - - NO_CHECKPOINT: creates 0 checkpoints for success case + - is_virtual=False: 2 checkpoints (START + SUCCEED) + - is_virtual=True: 0 checkpoints """ # Setup common mocks @@ -829,7 +894,7 @@ def setup_mocks(): mock_callable = Mock(return_value="test_result") return mock_state, mock_callable - # Test CHECKPOINT_AT_START_AND_FINISH mode + # is_virtual=False: 2 checkpoints mock_state1, mock_callable1 = setup_mocks() config1 = ChildConfig(is_virtual=False) @@ -843,7 +908,7 @@ def setup_mocks(): assert result1 == "test_result" assert mock_state1.create_checkpoint.call_count == 2 # START + SUCCEED - # Test NO_CHECKPOINT mode + # is_virtual=True: 0 checkpoints mock_state2, mock_callable2 = setup_mocks() config2 = ChildConfig(is_virtual=True) diff --git a/tests/operation/map_test.py b/tests/operation/map_test.py index b496cdb..b3c979d 100644 --- a/tests/operation/map_test.py +++ b/tests/operation/map_test.py @@ -54,7 +54,6 @@ def test_map_executor_init(): items = ["item1"] executor = MapExecutor( - operation_identifier=OperationIdentifier("id-1", "parent-1", "name-1"), executables=executables, items=items, max_concurrency=2, @@ -80,9 +79,7 @@ def callable_func(ctx, item, idx, items): config = MapConfig(max_concurrency=3, nesting_type=NestingType.FLAT) - executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, config - ) + executor = MapExecutor.from_items(items, callable_func, config) assert len(executor.executables) == 3 assert executor.items == items @@ -99,7 +96,6 @@ def callable_func(ctx, item, idx, items): return item executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -119,7 +115,6 @@ def callable_func(ctx, item, idx, items): return f"{item}_{idx}" executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -142,7 +137,6 @@ def callable_func(ctx, item, idx, items): return item * 2 + idx executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -234,7 +228,6 @@ def callable_func(ctx, item, idx, items_list): return f"{item}_{idx}_{len(items_list)}" executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -254,7 +247,6 @@ def callable_func(ctx, item, idx, items): return item executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -272,7 +264,6 @@ def callable_func(ctx, item, idx, items): return f"processed_{item}" executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -291,7 +282,6 @@ def callable_func(ctx, item, idx, items): return item executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, MapConfig(), @@ -317,7 +307,7 @@ def callable_func(ctx, item, idx, items): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() with patch.object( MapExecutor, "execute", return_value=mock_batch_result @@ -368,7 +358,7 @@ def callable_func(ctx, item, idx, items): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() class MockExecutionState: def get_checkpoint_result(self, operation_id): @@ -414,7 +404,7 @@ def callable_func(ctx, item, idx, items): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() class MockExecutionState: def get_checkpoint_result(self, operation_id): @@ -496,9 +486,7 @@ def mock_summary_generator(result): config = MapConfig(summary_generator=mock_summary_generator) - executor = MapExecutor.from_items( - OperationIdentifier("id-1", "parent-1", "name-1"), items, callable_func, config - ) + executor = MapExecutor.from_items(items, callable_func, config) # Verify that the summary_generator is preserved in the executor assert executor.summary_generator is mock_summary_generator @@ -550,7 +538,6 @@ def mock_summary_generator(result): return f"Summary: {result}" executor = MapExecutor( - operation_identifier=OperationIdentifier("id-1", "parent-1", "name-1"), executables=executables, items=items, max_concurrency=2, diff --git a/tests/operation/parallel_test.py b/tests/operation/parallel_test.py index 6ed9123..15a8346 100644 --- a/tests/operation/parallel_test.py +++ b/tests/operation/parallel_test.py @@ -58,7 +58,6 @@ def test_parallel_executor_init(): completion_config = CompletionConfig.all_successful() executor = ParallelExecutor( - operation_identifier=OperationIdentifier("test_op", "parent", "test_parallel"), executables=executables, max_concurrency=2, completion_config=completion_config, @@ -90,9 +89,7 @@ def func2(ctx): callables = [func1, func2] config = ParallelConfig(max_concurrency=3, nesting_type=NestingType.FLAT) - executor = ParallelExecutor.from_callables( - OperationIdentifier("test_op", "parent", "test_parallel"), callables, config - ) + executor = ParallelExecutor.from_callables(callables, config) assert len(executor.executables) == 2 assert executor.executables[0].index == 0 @@ -115,9 +112,7 @@ def func1(ctx): callables = [func1] config = ParallelConfig() - executor = ParallelExecutor.from_callables( - OperationIdentifier("test_op", "parent", "test_parallel"), callables, config - ) + executor = ParallelExecutor.from_callables(callables, config) assert len(executor.executables) == 1 assert executor.max_concurrency is None @@ -133,7 +128,6 @@ def test_func(ctx): executable = Executable(index=0, func=test_func) executor = ParallelExecutor( - operation_identifier=OperationIdentifier("test_op", "parent", "test_parallel"), executables=[executable], max_concurrency=None, completion_config=CompletionConfig.all_successful(), @@ -158,7 +152,6 @@ def failing_func(ctx): executable = Executable(index=0, func=failing_func) executor = ParallelExecutor( - operation_identifier=OperationIdentifier("test_op", "parent", "test_parallel"), executables=[executable], max_concurrency=None, completion_config=CompletionConfig.all_successful(), @@ -273,7 +266,7 @@ def get_checkpoint_result(self, operation_id): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() with patch.object(ParallelExecutor, "from_callables") as mock_from_callables: mock_executor = Mock() @@ -285,9 +278,7 @@ def get_checkpoint_result(self, operation_id): callables, config, execution_state, executor_context, operation_identifier ) - mock_from_callables.assert_called_once_with( - operation_identifier, callables, config - ) + mock_from_callables.assert_called_once_with(callables, config) mock_executor.execute.assert_called_once_with( execution_state, executor_context=executor_context ) @@ -313,7 +304,7 @@ def get_checkpoint_result(self, operation_id): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() with patch.object(ParallelExecutor, "from_callables") as mock_from_callables: mock_executor = Mock() @@ -328,18 +319,16 @@ def get_checkpoint_result(self, operation_id): assert result == mock_batch_result # Verify that a default ParallelConfig was created args, _ = mock_from_callables.call_args - assert args[0] == operation_identifier - assert args[1] == callables - assert isinstance(args[2], ParallelConfig) - assert args[2].max_concurrency is None - assert args[2].completion_config == CompletionConfig.all_successful() + assert args[0] == callables + assert isinstance(args[1], ParallelConfig) + assert args[1].max_concurrency is None + assert args[1].completion_config == CompletionConfig.all_successful() def test_parallel_executor_inheritance(): """Test that ParallelExecutor properly inherits from ConcurrentExecutor.""" executables = [Executable(index=0, func=lambda x: x)] executor = ParallelExecutor( - operation_identifier=OperationIdentifier("test_op", "parent", "test_parallel"), executables=executables, max_concurrency=None, completion_config=CompletionConfig.all_successful(), @@ -357,9 +346,7 @@ def test_parallel_executor_from_callables_empty_list(): callables = [] config = ParallelConfig() - executor = ParallelExecutor.from_callables( - OperationIdentifier("test_op", "parent", "test_parallel"), callables, config - ) + executor = ParallelExecutor.from_callables(callables, config) assert len(executor.executables) == 0 assert executor.max_concurrency is None @@ -378,7 +365,6 @@ def dict_func(ctx): return {"key": "value"} executor = ParallelExecutor( - operation_identifier=OperationIdentifier("test_op", "parent", "test_parallel"), executables=[], max_concurrency=None, completion_config=CompletionConfig.all_successful(), @@ -417,7 +403,7 @@ def get_checkpoint_result(self, operation_id): executor_context = Mock() executor_context._create_step_id_for_logical_step = lambda *args: "1" # noqa SLF001 - executor_context.create_child_context = lambda *args: Mock() + executor_context.create_child_context = lambda *args, **kwargs: Mock() result = parallel_handler( callables, @@ -479,9 +465,7 @@ def mock_summary_generator(result): callables = [func1] config = ParallelConfig(summary_generator=mock_summary_generator) - executor = ParallelExecutor.from_callables( - OperationIdentifier("test_op", "parent", "test_parallel"), callables, config - ) + executor = ParallelExecutor.from_callables(callables, config) # Verify that the summary_generator is preserved in the executor assert executor.summary_generator is mock_summary_generator From 264b4f22b26cb3b19ead65d32f40438ba86d56c8 Mon Sep 17 00:00:00 2001 From: yaythomas Date: Wed, 29 Apr 2026 14:53:59 -0700 Subject: [PATCH 2/2] docs: vscode interpreter spaced paths Kiro and VS Code mangle the hatch interpreter path when it contains spaces, breaking "Select Interpreter". Document the `.venv` symlink workaround and split the existing VS Code section into Interpreter and Linting subsections. --- CONTRIBUTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb1a407..dfd80e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,9 +139,24 @@ echo "$(hatch env find)/bin/python" ``` ### VS Code +#### Interpreter If you're using VS Code, "Python: Select Interpreter" and use the hatch venv Python interpreter as found with the `hatch env find` command. +Kiro and VS Code mangles the interpreter path if it contains spaces, which results in +errors finding the interpreter. You can create a local .venv file symlink _without_ spaces +in the path: + +```bash +# create a symlink at: ./.venv/bin/python +rm -rf .venv && ln -s "$(hatch env find)" .venv +``` + +When you "Select Interpreter", enter path `./.venv/bin/python`. + +You'll have to rerun this command whenever you recreate your hatch envs. + +#### Linting Hatch uses Ruff for static analysis. You might want to install the [Ruff extension for VS Code](https://github.com/astral-sh/ruff-vscode)