diff --git a/.github/workflows/conformance-tests.yml b/.github/workflows/conformance-tests.yml new file mode 100644 index 00000000..9c67269e --- /dev/null +++ b/.github/workflows/conformance-tests.yml @@ -0,0 +1,126 @@ +name: Conformance Tests + +# Full-integration conformance run: discovers suites from template_.yaml, +# builds the Python handlers against the local monorepo SDK, and runs each suite +# as its own parallel matrix job with the pinned language-agnostic runner. + +on: + pull_request: + branches: ["main"] + paths: + - "packages/aws-durable-execution-sdk-python-conformance-tests/**" + - ".github/workflows/conformance-tests.yml" + workflow_dispatch: + inputs: + region: + description: "AWS Region for deploying and running conformance tests" + required: false + default: us-west-2 + type: string + +env: + AWS_REGION: ${{ github.event.inputs.region || 'us-west-2' }} + +concurrency: + group: ${{ github.head_ref || github.ref_name || github.run_id }}-conformance + cancel-in-progress: true + +permissions: + contents: read + +jobs: + discover_suites: + name: discover conformance suites + runs-on: ubuntu-latest + outputs: + suites: ${{ steps.discover.outputs.suites }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Discover suites from templates + id: discover + working-directory: packages/aws-durable-execution-sdk-python-conformance-tests + run: echo "suites=$(python3 scripts/discover_suites.py)" >> "$GITHUB_OUTPUT" + + conformance: + name: conformance (${{ matrix.suite }}) + needs: discover_suites + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for AWS OIDC credentials + strategy: + fail-fast: false + matrix: + suite: ${{ fromJSON(needs.discover_suites.outputs.suites) }} + defaults: + run: + working-directory: packages/aws-durable-execution-sdk-python-conformance-tests + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: "${{ secrets.TEST_ROLE_ARN }}" + role-session-name: pythonConformanceTest + aws-region: ${{ env.AWS_REGION }} + + - name: Verify AWS test account + env: + TEST_ACCOUNT_ID: ${{ secrets.TEST_ACCOUNT_ID }} + run: | + ACTUAL_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + if [ "$ACTUAL_ACCOUNT_ID" != "$TEST_ACCOUNT_ID" ]; then + echo "Expected AWS account $TEST_ACCOUNT_ID but assumed into $ACTUAL_ACCOUNT_ID" + exit 1 + fi + echo "Using AWS test account $ACTUAL_ACCOUNT_ID" + + - name: Setup SAM CLI + uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3 + + - name: Build conformance handlers + run: python3 scripts/build_examples.py + + - name: Install conformance runner + run: pip install aws-durable-execution-conformance-tests==0.1.0 + + - name: Inject Lambda execution role into template + env: + ROLE_ARN: ${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }} + run: | + if [ -z "$ROLE_ARN" ]; then + echo "TEST_LAMBDA_EXECUTION_ROLE_ARN not set; template will create its own role." + exit 0 + fi + python3 scripts/inject_execution_role.py \ + --template template_${{ matrix.suite }}.yaml \ + --role-arn "$ROLE_ARN" + + - name: Run conformance suite + run: | + python -m aws_durable_execution_conformance_tests.app \ + --template template_${{ matrix.suite }}.yaml \ + --language python \ + --suite ${{ matrix.suite }} \ + --name conf-py-${{ matrix.suite }}-${{ github.run_id }} \ + --region ${{ env.AWS_REGION }} \ + --history-dir history-${{ matrix.suite }} \ + --report junit \ + --report-file report-${{ matrix.suite }} + + - name: Upload conformance report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: conformance-report-${{ matrix.suite }} + path: | + packages/aws-durable-execution-sdk-python-conformance-tests/report-${{ matrix.suite }}.xml + packages/aws-durable-execution-sdk-python-conformance-tests/history-${{ matrix.suite }}/ + if-no-files-found: warn diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/.gitignore b/packages/aws-durable-execution-sdk-python-conformance-tests/.gitignore new file mode 100644 index 00000000..c6893f28 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/.gitignore @@ -0,0 +1,9 @@ +build/ +lambda-build/ +dist/ +.aws-sam/ +history-*/ +report-*.xml +report-*.json +__pycache__/ +*.pyc diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/README.md b/packages/aws-durable-execution-sdk-python-conformance-tests/README.md new file mode 100644 index 00000000..2b36060c --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/README.md @@ -0,0 +1,121 @@ +# Durable Execution Python SDK — Conformance Tests + +Cross-SDK **conformance test handlers** for the Durable Execution Python SDK. +These handlers deploy as AWS Lambda functions and are exercised by the +language-agnostic conformance runner +[`aws-durable-execution-conformance-tests`](https://pypi.org/project/aws-durable-execution-conformance-tests/), +which invokes each function, pulls its execution history, and asserts it matches +the shared requirement specification. + +The runner and requirement specifications are maintained in +[`aws/aws-durable-execution-conformance-tests`](https://github.com/aws/aws-durable-execution-conformance-tests). +This package owns the Python handlers and SAM templates that wire them to +requirement IDs. + +## Layout + +``` +handlers/ + / # one .py handler per conformance scenario +template_.yaml # maps suite handlers to requirement IDs +scripts/ + discover_suites.py # derives supported suites from templates + build_examples.py # assembles lambda-build/ from the local monorepo SDK + inject_execution_role.py # CI: point functions at a pre-existing role +tests/ # unit tests for the scripts +``` + +Each `template_.yaml` is a self-contained deployment for one conformance +suite. Templates are the single source of truth: the local build and GitHub +Actions matrix discover suites from them automatically. + +## How a handler maps to a requirement + +The link is the `TestingMetadata.TestDescription` field on each function in the +SAM template: + +```yaml +RequirementCase: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: [""] + Properties: + CodeUri: lambda-build/ + Handler: ..handler + Role: !GetAtt DurableFunctionRole.Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 +``` + +The runner invokes the function once per requirement ID using the requirement's +`Input`, then compares the execution history with `ExpectedExecutionHistory`. + +## Building locally + +`scripts/build_examples.py` assembles `lambda-build/` from the local monorepo +SDK source, not a PyPI release. `boto3` is provided by the Lambda runtime and is +not vendored. + +```bash +cd packages/aws-durable-execution-sdk-python-conformance-tests +python3 scripts/build_examples.py +``` + +The script discovers every `template_.yaml`, verifies a matching +`handlers//` directory exists, and copies all discovered suites: + +``` +lambda-build/ + aws_durable_execution_sdk_python/ + / +``` + +## Running a suite + +Prerequisites: Python ≥ 3.14, the AWS SAM CLI, and AWS credentials for an +account where Durable Execution is available. + +```bash +cd packages/aws-durable-execution-sdk-python-conformance-tests +SUITE= + +python3 scripts/build_examples.py +pip install aws-durable-execution-conformance-tests==0.1.0 + +python -m aws_durable_execution_conformance_tests.app \ + --template "template_${SUITE}.yaml" \ + --language python \ + --suite "$SUITE" \ + --name "conformance-python-${SUITE}-local" \ + --region us-west-2 \ + --history-dir "history-${SUITE}" \ + --report junit --report-file "report-${SUITE}" +``` + +The runner deploys the template, invokes each function, validates its result and +history, and cleans up the stack by default. + +## Authoring a new test case + +1. Find or add the requirement in the conformance repository under + `test-requirements//.yaml`. +2. Add `handlers//.py` exporting `handler`. Use the + SDK's real API; never hand-roll behavior to force an expected result. +3. Register it in `template_.yaml` with + `Handler: ..handler` and `TestDescription: [""]`. +4. Rebuild and run the suite. + +For a new suite, adding its template and handler directory is sufficient; the +build and CI matrix discover it automatically. + +## CI + +`.github/workflows/conformance-tests.yml` first discovers all suites from +`template_.yaml`, then runs one parallel matrix job per suite. CI assumes +AWS credentials through the repository's existing OIDC secrets. + +Before deployment, `scripts/inject_execution_role.py` points every function at +the pre-existing execution role (`TEST_LAMBDA_EXECUTION_ROLE_ARN`) and removes +the template's self-created `DurableFunctionRole`. The checked-in templates +remain self-contained for local runs. diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/__init__.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/__init__.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_and_wait_replay.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_and_wait_replay.py new file mode 100644 index 00000000..ea4c1a13 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_and_wait_replay.py @@ -0,0 +1,21 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def compute(_step_context: StepContext) -> str: + return "computed" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + result: str = context.step(compute()) + context.wait(Duration.from_seconds(2)) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_no_retry.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_no_retry.py new file mode 100644 index 00000000..fe43ca91 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_no_retry.py @@ -0,0 +1,35 @@ +"""1-17: AtMostOnce interrupted (no retry) - Lambda crash, StepInterruptedError, fails permanently.""" + +import os +import time +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig, StepSemantics +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import RetryPresets + + +@durable_step +def at_most_once_flaky_step(_step_context: StepContext, *, input_1: str) -> str: + print(input_1, flush=True) + time.sleep(1) # Allow time for logs to flush to CloudWatch + os._exit(1) # Simulate Lambda crash + return "unreachable" + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + result: str = context.step( + at_most_once_flaky_step(input_1=str(event)), + name="at_most_once_flaky_step", + config=StepConfig( + retry_strategy=RetryPresets.none(), + step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY, + ), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_with_retry.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_with_retry.py new file mode 100644 index 00000000..f1dad7c2 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_at_most_once_with_retry.py @@ -0,0 +1,50 @@ +"""1-18: AtMostOnce interrupted (with retry, uses the step context attempt number).""" + +import os +import time +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, StepConfig, StepSemantics +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_step +def at_most_once_step(step_context: StepContext, *, input_1: str) -> str: + # Print input to stdout each time step executes + print(input_1, flush=True) + time.sleep(1) # Allow time for logs to flush to CloudWatch + + # The attempt number is the SDK's built-in durable counter from the step + # context (1-based). Under AtMostOncePerRetry the interrupted first attempt + # is consumed, so the retry re-executes as attempt 2. + if step_context.attempt < 2: + # First attempt: simulate Lambda crash + os._exit(1) + # Second attempt (retry): succeed + return "succeeded on second attempt" + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=3, + initial_delay=Duration.from_seconds(1), + ) + + result: str = context.step( + at_most_once_step(input_1=str(event)), + config=StepConfig( + retry_strategy=create_retry_strategy(retry_config), + step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY, + ), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_basic.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_basic.py new file mode 100644 index 00000000..b63a294d --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_basic.py @@ -0,0 +1,19 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def greet(_step_context: StepContext, name: str) -> str: + return f"Hello, {name}!" + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + result: str = context.step(greet(event)) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_complex_object.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_complex_object.py new file mode 100644 index 00000000..d9a127cb --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_complex_object.py @@ -0,0 +1,27 @@ +"""1-4: Returning complex object.""" + +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def build_response(_step_context: StepContext, name: str, tags: list[str]) -> dict: + return { + "user": { + "name": name, + "tags": tags, + }, + "count": len(tags), + } + + +@durable_execution +def handler(event: Any, context: DurableContext) -> dict: + result: dict = context.step(build_response(event["name"], event["tags"])) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_custom_serdes.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_custom_serdes.py new file mode 100644 index 00000000..3aff3b6f --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_custom_serdes.py @@ -0,0 +1,36 @@ +"""1-6: Custom serdes (per-step) - transforms result to uppercase.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.serdes import SerDes, SerDesContext + + +class UppercaseSerDes(SerDes[str]): + """Custom serdes that transforms strings to uppercase on serialization.""" + + def serialize(self, value: str, _: SerDesContext) -> str: + return value.upper() + + def deserialize(self, data: str, _: SerDesContext) -> str: + return data + + +@durable_step +def return_input(_step_context: StepContext, value: str) -> str: + return value + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + result: str = context.step( + return_input(event), + config=StepConfig(serdes=UppercaseSerDes()), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_default_retry.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_default_retry.py new file mode 100644 index 00000000..4368a5f8 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_default_retry.py @@ -0,0 +1,27 @@ +"""1-13: Default retry strategy (uses the step context attempt number).""" + +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def unreliable(step_context: StepContext) -> str: + # Fail on the first two attempts, succeed on the third, using the SDK's + # built-in durable attempt counter from the step context (1-based). + if step_context.attempt < 3: + msg = f"Attempt {step_context.attempt} failed" + raise RuntimeError(msg) + return "recovered" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + # Step with no explicit retry config — uses SDK default + result: str = context.step(unreliable()) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_error_caught.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_error_caught.py new file mode 100644 index 00000000..dd2fa5cc --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_error_caught.py @@ -0,0 +1,37 @@ +"""1-20: Error caught and handled (try/catch) - step fails, error caught, execution continues.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import RetryPresets + + +@durable_step +def failing_step(_step_context: StepContext) -> str: + msg = "Something went wrong" + raise RuntimeError(msg) + + +@durable_step +def fallback_step(_step_context: StepContext) -> str: + return "fallback_result" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + try: + context.step( + failing_step(), + config=StepConfig(retry_strategy=RetryPresets.none()), + ) + except Exception: + pass + + result: str = context.step(fallback_step()) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_logging.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_logging.py new file mode 100644 index 00000000..b0724208 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_logging.py @@ -0,0 +1,28 @@ +"""Step handler that uses the SDK's step context logger. + +Validates that the logger provided by StepContext correctly emits log +entries to CloudWatch during step execution. +""" + +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def greet(step_context: StepContext, name: str) -> str: + step_context.logger.info(f"Greeting step started for: {name}") + result = f"Hello, {name}!" + step_context.logger.info(f"Greeting step completed with: {result}") + return result + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + result: str = context.step(greet(event)) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_nested.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_nested.py new file mode 100644 index 00000000..e596e9d9 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_nested.py @@ -0,0 +1,25 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def step_one(_step_context: StepContext) -> str: + return "first" + + +@durable_step +def step_two(_step_context: StepContext, previous: str) -> str: + return f"{previous}_second" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + result1: str = context.step(step_one()) + result2: str = context.step(step_two(result1)) + return result2 diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_null_result.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_null_result.py new file mode 100644 index 00000000..d4f45047 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_null_result.py @@ -0,0 +1,21 @@ +"""1-5: Undefined/null result.""" + +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def do_nothing(_step_context: StepContext) -> None: + return None + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> None: + result = context.step(do_nothing()) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_rethrows_failed.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_rethrows_failed.py new file mode 100644 index 00000000..14fbe8da --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_rethrows_failed.py @@ -0,0 +1,33 @@ +"""1-10: Replay re-throws failed step - step logs once, proving no re-execution on replay.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import RetryPresets + + +@durable_step +def failing_with_log(step_context: StepContext) -> str: + step_context.logger.info("step executed") + msg = "Something went wrong" + raise RuntimeError(msg) + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + try: + context.step( + failing_with_log(), + config=StepConfig(retry_strategy=RetryPresets.none()), + ) + except Exception as e: + error_msg = str(e) + + context.wait(Duration.from_seconds(1)) + return f"caught: {error_msg}" diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_skips_succeeded.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_skips_succeeded.py new file mode 100644 index 00000000..762d90b6 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_replay_skips_succeeded.py @@ -0,0 +1,24 @@ +"""1-9: Replay skips succeeded step - step logs once, proving no re-execution on replay.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def compute_with_log(step_context: StepContext) -> str: + step_context.logger.info("step executed") + return "cached_value" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + result: str = context.step(compute_with_log()) + context.wait(Duration.from_seconds(1)) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_custom_config.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_custom_config.py new file mode 100644 index 00000000..7fcfe0fd --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_custom_config.py @@ -0,0 +1,41 @@ +"""1-14: Retry with custom config (uses the step context attempt number).""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, JitterStrategy, StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_step +def flaky(step_context: StepContext) -> str: + # Fail on the first two attempts, succeed on the third, using the SDK's + # built-in durable attempt counter from the step context (1-based). + if step_context.attempt < 3: + msg = f"Attempt {step_context.attempt} failed" + raise RuntimeError(msg) + return "finally succeeded" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=5, + initial_delay=Duration.from_seconds(2), + backoff_rate=3, + jitter_strategy=JitterStrategy.NONE, + ) + + result: str = context.step( + flaky(), + config=StepConfig(retry_strategy=create_retry_strategy(retry_config)), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_exhaustion.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_exhaustion.py new file mode 100644 index 00000000..285c9387 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_exhaustion.py @@ -0,0 +1,37 @@ +"""1-12: Retry exhaustion (max attempts) - always fails, 4 total attempts.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, JitterStrategy, StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_step +def always_fail(_step_context: StepContext) -> str: + msg = "Always fails" + raise RuntimeError(msg) + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=4, + initial_delay=Duration.from_seconds(1), + backoff_rate=1, + jitter_strategy=JitterStrategy.NONE, + ) + + result: str = context.step( + always_fail(), + config=StepConfig(retry_strategy=create_retry_strategy(retry_config)), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_non_retryable.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_non_retryable.py new file mode 100644 index 00000000..8328c0ac --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_non_retryable.py @@ -0,0 +1,43 @@ +"""1-16: Retry specific exception (non-retryable fails) - TransientError not in retryable list.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +class TransientError(Exception): + """Custom error that is NOT in the retryable list.""" + + +class ValidationError(Exception): + """The only error type configured as retryable.""" + + +@durable_step +def throw_transient(_step_context: StepContext) -> str: + raise TransientError("transient failure") + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=3, + initial_delay=Duration.from_seconds(1), + retryable_error_types=[ValidationError], + ) + + result: str = context.step( + throw_transient(), + config=StepConfig(retry_strategy=create_retry_strategy(retry_config)), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_specific_exception.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_specific_exception.py new file mode 100644 index 00000000..94feea4b --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_retry_specific_exception.py @@ -0,0 +1,43 @@ +"""1-15: Retry specific exception (uses the step context attempt number).""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration, StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +class TransientError(Exception): + """Custom transient error that should be retried.""" + + +@durable_step +def transient_on_first(step_context: StepContext) -> str: + # Fail on the first attempt, succeed on the second, using the SDK's + # built-in durable attempt counter from the step context (1-based). + if step_context.attempt < 2: + raise TransientError("Temporary failure") + return "recovered from transient" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=3, + initial_delay=Duration.from_seconds(1), + retryable_error_types=[TransientError], + ) + + result: str = context.step( + transient_on_first(), + config=StepConfig(retry_strategy=create_retry_strategy(retry_config)), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_error.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_error.py new file mode 100644 index 00000000..f8e372e2 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_error.py @@ -0,0 +1,25 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import RetryPresets + + +@durable_step +def failing_step(_step_context: StepContext) -> str: + msg = "Something went wrong" + raise RuntimeError(msg) + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + result: str = context.step( + failing_step(), + config=StepConfig(retry_strategy=RetryPresets.none()), + ) + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_name.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_name.py new file mode 100644 index 00000000..8aeb3a59 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_name.py @@ -0,0 +1,19 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_step +def greet(_step_context: StepContext, name: str) -> str: + return f"Hello, {name}!" + + +@durable_execution +def handler(event: Any, context: DurableContext) -> str: + result: str = context.step(greet(event), name="custom_step_name") + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_retry.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_retry.py new file mode 100644 index 00000000..21e18fb8 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/step_with_retry.py @@ -0,0 +1,40 @@ +"""1-11: Step with retry (fails then succeeds, uses the step context attempt number).""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + StepContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_execution +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_step +def unreliable_operation(step_context: StepContext) -> str: + # Fail on the first attempt, succeed on the second. The attempt number is + # the SDK's built-in durable counter from the step context (1-based). + if step_context.attempt < 2: + msg = f"Attempt {step_context.attempt} failed" + raise RuntimeError(msg) + return "Operation succeeded" + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + retry_config = RetryStrategyConfig( + max_attempts=3, + retryable_error_types=[RuntimeError], + ) + + result: str = context.step( + unreliable_operation(), + config=StepConfig(create_retry_strategy(retry_config)), + ) + + return result diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/__init__.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_basic.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_basic.py new file mode 100644 index 00000000..c5bdbadc --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_basic.py @@ -0,0 +1,11 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + context.wait(Duration.from_seconds(2)) + return "Wait completed" diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_duration_units.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_duration_units.py new file mode 100644 index 00000000..b13b7ea8 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_duration_units.py @@ -0,0 +1,13 @@ +"""2-4: Wait with different duration units.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + context.wait(Duration.from_minutes(1)) + return "Wait with minutes completed" diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_long_duration.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_long_duration.py new file mode 100644 index 00000000..502d3c56 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_long_duration.py @@ -0,0 +1,13 @@ +"""2-5: Wait with long duration (1 hour).""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + context.wait(Duration.from_hours(1)) + return "Wait with hours completed" diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_multiple_sequential.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_multiple_sequential.py new file mode 100644 index 00000000..bc454b63 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_multiple_sequential.py @@ -0,0 +1,14 @@ +"""2-3: Multiple sequential waits.""" + +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> dict: + context.wait(Duration.from_seconds(2), name="wait-1") + context.wait(Duration.from_seconds(2), name="wait-2") + return {"completedWaits": 2} diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_with_name.py b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_with_name.py new file mode 100644 index 00000000..afa91a39 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/handlers/wait/wait_with_name.py @@ -0,0 +1,11 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_execution + + +@durable_execution +def handler(_event: Any, context: DurableContext) -> str: + context.wait(Duration.from_seconds(2), name="custom_wait_name") + return "Wait with name completed" diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/pyproject.toml b/packages/aws-durable-execution-sdk-python-conformance-tests/pyproject.toml new file mode 100644 index 00000000..14f6bd9b --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "aws-durable-execution-sdk-python-conformance-tests" +version = "0.0.0" +description = "Cross-SDK conformance test handlers for the AWS Durable Execution SDK for Python, exercised by the aws-durable-execution-conformance-tests runner." +requires-python = ">=3.11" +dependencies = [ + "aws-durable-execution-sdk-python==1.7.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["handlers"] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +preview = true +select = ["E4", "E7", "E9", "F", "TID252"] # pycodestyle (E4/E7/E9) + Pyflakes + absolute imports + +[tool.ruff.lint.isort] +known-first-party = ["aws_durable_execution_sdk_python"] +force-single-line = false +lines-after-imports = 2 + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "ARG001", + "ARG002", + "ARG005", + "E402", + "S101", + "PLR2004", + "SIM117", + "TRY301", +] diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/build_examples.py b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/build_examples.py new file mode 100644 index 00000000..116cf3d4 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/build_examples.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Assemble the Lambda deployment package for the conformance handlers. + +Builds against the **local monorepo SDK source** (not a PyPI release) so the +handlers exercise exactly the code in this checkout. Output lands in an ignored +``lambda-build/`` directory that the SAM templates reference via +``CodeUri: lambda-build/``. + +Suites are discovered from checked-in ``template_.yaml`` files. Each +template must have a matching ``handlers//`` directory containing at +least one handler module. + +Layout produced:: + + lambda-build/ + aws_durable_execution_sdk_python/ # copied from the local SDK package + / # copied from handlers/ + +``boto3`` (the core SDK's only runtime dependency) is provided by the Lambda +Python runtime, so it is intentionally not vendored here. + +Usage:: + + python3 scripts/build_examples.py + python3 scripts/build_examples.py --sdk-src /path/to/aws_durable_execution_sdk_python +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +from discover_suites import discover_suites + + +PKG_DIR = Path(__file__).resolve().parents[1] +BUILD_DIR = PKG_DIR / "lambda-build" + +# Default local SDK source: packages/aws-durable-execution-sdk-python/src/... +DEFAULT_SDK_SRC = ( + PKG_DIR.parent + / "aws-durable-execution-sdk-python" + / "src" + / "aws_durable_execution_sdk_python" +) + + +def build(sdk_src: Path) -> None: + if not sdk_src.is_dir(): + raise SystemExit(f"Local SDK source not found: {sdk_src}") + + suites = discover_suites(PKG_DIR) + + if BUILD_DIR.exists(): + shutil.rmtree(BUILD_DIR) + BUILD_DIR.mkdir(parents=True) + + shutil.copytree( + sdk_src, + BUILD_DIR / "aws_durable_execution_sdk_python", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + + for suite in suites: + shutil.copytree( + PKG_DIR / "handlers" / suite, + BUILD_DIR / suite, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + + print(f"Build complete: {BUILD_DIR}") + print(f" SDK source: {sdk_src}") + print(f" Suites: {', '.join(suites)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--sdk-src", + type=Path, + default=DEFAULT_SDK_SRC, + help="Path to the local aws_durable_execution_sdk_python source package.", + ) + args = parser.parse_args() + build(args.sdk_src) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/discover_suites.py b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/discover_suites.py new file mode 100644 index 00000000..697e2fd7 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/discover_suites.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Discover conformance suites from template files.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +PKG_DIR = Path(__file__).resolve().parents[1] +TEMPLATE_PREFIX = "template_" +TEMPLATE_SUFFIX = ".yaml" + + +def discover_suites(package_dir: Path = PKG_DIR) -> tuple[str, ...]: + """Return sorted suites with matching templates and non-empty handler dirs.""" + templates = sorted(package_dir.glob(f"{TEMPLATE_PREFIX}*{TEMPLATE_SUFFIX}")) + if not templates: + raise SystemExit(f"No {TEMPLATE_PREFIX}{TEMPLATE_SUFFIX} files found") + + suites: list[str] = [] + for template in templates: + suite = template.name[len(TEMPLATE_PREFIX) : -len(TEMPLATE_SUFFIX)] + if not suite: + raise SystemExit(f"Invalid conformance template name: {template.name}") + + handlers_dir = package_dir / "handlers" / suite + if not handlers_dir.is_dir(): + raise SystemExit( + f"Template {template.name} has no matching handler directory: " + f"{handlers_dir}" + ) + + handler_files = [ + path for path in handlers_dir.glob("*.py") if path.name != "__init__.py" + ] + if not handler_files: + raise SystemExit( + f"No handler modules found for suite {suite}: {handlers_dir}" + ) + + suites.append(suite) + + return tuple(suites) + + +def main() -> None: + print(json.dumps(discover_suites(), separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/inject_execution_role.py b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/inject_execution_role.py new file mode 100644 index 00000000..ca343f54 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/scripts/inject_execution_role.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Inject a pre-existing Lambda execution role into a conformance SAM template. + +Rewrites the given template in place so that every AWS::Serverless::Function +uses the provided execution role ARN, and removes the self-created +DurableFunctionRole resource. Used by CI to avoid creating an IAM role per +deploy; the checked-in template stays self-contained for local runs. + +Usage: + python3 scripts/inject_execution_role.py --template template_step.yaml \ + --role-arn arn:aws:iam::123456789012:role/my-execution-role + +Requires PyYAML (already a dependency of the conformance runner). +""" + +from __future__ import annotations + +import argparse +import sys + +import yaml + + +SELF_CREATED_ROLE = "DurableFunctionRole" +FUNCTION_TYPE = "AWS::Serverless::Function" + + +def inject(template_path: str, role_arn: str) -> int: + """Rewrite template_path in place; return the number of functions updated.""" + with open(template_path, encoding="utf-8") as f: + doc = yaml.safe_load(f) + + resources = doc.get("Resources") + if not isinstance(resources, dict): + raise SystemExit(f"{template_path}: no Resources section found") + + resources.pop(SELF_CREATED_ROLE, None) + + updated = 0 + for resource in resources.values(): + if resource.get("Type") == FUNCTION_TYPE: + resource.setdefault("Properties", {})["Role"] = role_arn + updated += 1 + + if updated == 0: + raise SystemExit(f"{template_path}: no {FUNCTION_TYPE} resources found") + + with open(template_path, "w", encoding="utf-8") as f: + yaml.safe_dump(doc, f, sort_keys=False) + + return updated + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--template", + required=True, + help="Path to the SAM template to rewrite in place.", + ) + parser.add_argument( + "--role-arn", + required=True, + help="Execution role ARN to set on every serverless function.", + ) + args = parser.parse_args() + + updated = inject(args.template, args.role_arn) + print(f"Injected execution role into {updated} functions in {args.template}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/template_step.yaml b/packages/aws-durable-execution-sdk-python-conformance-tests/template_step.yaml new file mode 100644 index 00000000..66a34169 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/template_step.yaml @@ -0,0 +1,330 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Globals: + Function: + Runtime: python3.13 + Timeout: 60 + MemorySize: 128 +Resources: + DurableFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: DurableExecutionPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - lambda:CheckpointDurableExecution + - lambda:GetDurableExecutionState + Resource: '*' + StepBasic: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-1"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_basic.handler + Description: Step basic (succeeds on first attempt) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepWithName: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-2"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_with_name.handler + Description: Step with explicit name parameter + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepNested: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-3"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_nested.handler + Description: Sequential steps where second depends on first + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepComplexObject: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-4"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_complex_object.handler + Description: Returning complex object with nested structure + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepNullResult: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-5"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_null_result.handler + Description: Undefined/null result + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepCustomSerdes: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-6"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_custom_serdes.handler + Description: Custom serdes (per-step) transforms to uppercase + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepLogging: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-7"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_logging.handler + Description: Step with context logger + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepAndWaitReplay: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-8"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_and_wait_replay.handler + Description: Step and wait with replay + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepReplaySkipsSucceeded: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-9"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_replay_skips_succeeded.handler + Description: Replay skips succeeded step + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepReplayRethrowsFailed: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-10"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_replay_rethrows_failed.handler + Description: Replay re-throws failed step + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepWithRetry: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-11"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_with_retry.handler + Description: Step with retry (fails then succeeds) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepRetryExhaustion: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-12"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_retry_exhaustion.handler + Description: Retry exhaustion (max attempts) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepDefaultRetry: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-13"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_default_retry.handler + Description: Default retry strategy + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepRetryCustomConfig: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-14"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_retry_custom_config.handler + Description: Retry with custom config (fixed interval and backoff) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepRetrySpecificException: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-15"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_retry_specific_exception.handler + Description: Retry specific exception + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepRetryNonRetryable: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-16"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_retry_non_retryable.handler + Description: Retry specific exception (non-retryable fails) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepAtMostOnceNoRetry: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-17"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_at_most_once_no_retry.handler + Description: Step with AtMostOncePerRetry semantics (interrupted, no retry) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepAtMostOnceWithRetry: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-18"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_at_most_once_with_retry.handler + Description: AtMostOnce interrupted (with retry, succeeds on second attempt) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepWithError: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-19"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_with_error.handler + Description: Step with error (fails permanently) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + StepErrorCaught: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["1-20"] + Properties: + CodeUri: lambda-build/ + Handler: step.step_error_caught.handler + Description: Error caught and handled (try/catch) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/template_wait.yaml b/packages/aws-durable-execution-sdk-python-conformance-tests/template_wait.yaml new file mode 100644 index 00000000..5082c5cc --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/template_wait.yaml @@ -0,0 +1,105 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Globals: + Function: + Runtime: python3.13 + Timeout: 60 + MemorySize: 128 +Resources: + DurableFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: DurableExecutionPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - lambda:CheckpointDurableExecution + - lambda:GetDurableExecutionState + Resource: '*' + WaitBasic: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["2-1"] + Properties: + CodeUri: lambda-build/ + Handler: wait.wait_basic.handler + Description: Wait basic + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + WaitWithName: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["2-2"] + Properties: + CodeUri: lambda-build/ + Handler: wait.wait_with_name.handler + Description: Wait with explicit name parameter + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + WaitMultipleSequential: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["2-3"] + Properties: + CodeUri: lambda-build/ + Handler: wait.wait_multiple_sequential.handler + Description: Multiple sequential waits + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + WaitDurationUnits: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["2-4"] + Properties: + CodeUri: lambda-build/ + Handler: wait.wait_duration_units.handler + Description: Wait with different duration units (minutes) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 3700 + WaitLongDuration: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["2-5"] + Properties: + CodeUri: lambda-build/ + Handler: wait.wait_long_duration.handler + Description: Wait with long duration (1 hour) + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 7200 diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_discover_suites.py b/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_discover_suites.py new file mode 100644 index 00000000..62d35dd5 --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_discover_suites.py @@ -0,0 +1,56 @@ +"""Unit tests for scripts/discover_suites.py.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import discover_suites # noqa: E402 + + +def _add_suite(package_dir: Path, suite: str) -> None: + (package_dir / f"template_{suite}.yaml").write_text("Resources: {}\n") + handlers_dir = package_dir / "handlers" / suite + handlers_dir.mkdir(parents=True) + (handlers_dir / "case.py").write_text("def handler():\n pass\n") + + +def test_discovers_suites_from_templates_in_sorted_order(tmp_path: Path) -> None: + _add_suite(tmp_path, "zeta") + _add_suite(tmp_path, "alpha") + + assert discover_suites.discover_suites(tmp_path) == ("alpha", "zeta") + + +def test_ignores_non_template_yaml_files(tmp_path: Path) -> None: + _add_suite(tmp_path, "example") + (tmp_path / "samconfig.yaml").write_text("version: 0.1\n") + + assert discover_suites.discover_suites(tmp_path) == ("example",) + + +def test_fails_when_no_templates_exist(tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="No template_.yaml files found"): + discover_suites.discover_suites(tmp_path) + + +def test_fails_when_handler_directory_is_missing(tmp_path: Path) -> None: + (tmp_path / "template_example.yaml").write_text("Resources: {}\n") + + with pytest.raises(SystemExit, match="no matching handler directory"): + discover_suites.discover_suites(tmp_path) + + +def test_fails_when_handler_directory_is_empty(tmp_path: Path) -> None: + (tmp_path / "template_example.yaml").write_text("Resources: {}\n") + (tmp_path / "handlers" / "example").mkdir(parents=True) + + with pytest.raises(SystemExit, match="No handler modules found"): + discover_suites.discover_suites(tmp_path) diff --git a/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_inject_execution_role.py b/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_inject_execution_role.py new file mode 100644 index 00000000..117644dd --- /dev/null +++ b/packages/aws-durable-execution-sdk-python-conformance-tests/tests/test_inject_execution_role.py @@ -0,0 +1,112 @@ +"""Unit tests for scripts/inject_execution_role.py.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import yaml + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import inject_execution_role # noqa: E402 + + +ROLE_ARN = "arn:aws:iam::123456789012:role/my-execution-role" + + +def _write(tmp_path: Path, doc: dict) -> str: + path = tmp_path / "template.yaml" + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(doc, f, sort_keys=False) + return str(path) + + +def test_inject_rewrites_functions_and_drops_role(tmp_path: Path) -> None: + template = _write( + tmp_path, + { + "Resources": { + "DurableFunctionRole": {"Type": "AWS::IAM::Role"}, + "StepBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Handler": "step.step_basic.handler", + "Role": {"Fn::GetAtt": ["DurableFunctionRole", "Arn"]}, + }, + }, + "WaitBasic": { + "Type": "AWS::Serverless::Function", + "Properties": {"Handler": "wait.wait_basic.handler"}, + }, + } + }, + ) + + updated = inject_execution_role.inject(template, ROLE_ARN) + + assert updated == 2 + with open(template, encoding="utf-8") as f: + doc = yaml.safe_load(f) + resources = doc["Resources"] + assert "DurableFunctionRole" not in resources + assert resources["StepBasic"]["Properties"]["Role"] == ROLE_ARN + assert resources["WaitBasic"]["Properties"]["Role"] == ROLE_ARN + + +def test_inject_sets_role_when_properties_missing(tmp_path: Path) -> None: + template = _write( + tmp_path, + { + "Resources": { + "StepBasic": {"Type": "AWS::Serverless::Function"}, + } + }, + ) + + updated = inject_execution_role.inject(template, ROLE_ARN) + + assert updated == 1 + with open(template, encoding="utf-8") as f: + doc = yaml.safe_load(f) + assert doc["Resources"]["StepBasic"]["Properties"]["Role"] == ROLE_ARN + + +def test_inject_ignores_non_function_resources(tmp_path: Path) -> None: + template = _write( + tmp_path, + { + "Resources": { + "SomeTable": {"Type": "AWS::DynamoDB::Table"}, + "StepBasic": {"Type": "AWS::Serverless::Function"}, + } + }, + ) + + updated = inject_execution_role.inject(template, ROLE_ARN) + + assert updated == 1 + with open(template, encoding="utf-8") as f: + doc = yaml.safe_load(f) + assert "Role" not in doc["Resources"]["SomeTable"] + + +def test_inject_raises_when_no_resources(tmp_path: Path) -> None: + template = _write(tmp_path, {"AWSTemplateFormatVersion": "2010-09-09"}) + + with pytest.raises(SystemExit): + inject_execution_role.inject(template, ROLE_ARN) + + +def test_inject_raises_when_no_functions(tmp_path: Path) -> None: + template = _write( + tmp_path, + {"Resources": {"DurableFunctionRole": {"Type": "AWS::IAM::Role"}}}, + ) + + with pytest.raises(SystemExit): + inject_execution_role.inject(template, ROLE_ARN) diff --git a/pyproject.toml b/pyproject.toml index 3634f33f..653e7836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pytest-xdist", "opentelemetry-sdk>=1.20.0,<=1.42.1", "aws-durable-execution-sdk-python-testing", + "PyYAML==6.0.2", ] [tool.hatch.envs.test.scripts] @@ -31,6 +32,7 @@ testpaths = [ "packages/aws-durable-execution-sdk-python-otel/tests", "packages/aws-durable-execution-sdk-python-testing/tests", "packages/aws-durable-execution-sdk-python-examples/test", + "packages/aws-durable-execution-sdk-python-conformance-tests/tests", ] markers = [ # Used for test selection with -m example