Skip to content

Commit f2b0719

Browse files
wangyb-AAlex Wang
andauthored
feat: add Python conformance test package (#556)
* feat: add Python conformance test package Add step and wait conformance handlers, SAM templates, execution-role injection, and a parallel GitHub Actions matrix using the pinned public runner. * docs: make conformance README suite-agnostic * ci: discover conformance suites from templates --------- Co-authored-by: Alex Wang <wangyb@amazon.com>
1 parent 7d819fa commit f2b0719

40 files changed

Lines changed: 1810 additions & 0 deletions
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
name: Conformance Tests
2+
3+
# Full-integration conformance run: discovers suites from template_<suite>.yaml,
4+
# builds the Python handlers against the local monorepo SDK, and runs each suite
5+
# as its own parallel matrix job with the pinned language-agnostic runner.
6+
7+
on:
8+
pull_request:
9+
branches: ["main"]
10+
paths:
11+
- "packages/aws-durable-execution-sdk-python-conformance-tests/**"
12+
- ".github/workflows/conformance-tests.yml"
13+
workflow_dispatch:
14+
inputs:
15+
region:
16+
description: "AWS Region for deploying and running conformance tests"
17+
required: false
18+
default: us-west-2
19+
type: string
20+
21+
env:
22+
AWS_REGION: ${{ github.event.inputs.region || 'us-west-2' }}
23+
24+
concurrency:
25+
group: ${{ github.head_ref || github.ref_name || github.run_id }}-conformance
26+
cancel-in-progress: true
27+
28+
permissions:
29+
contents: read
30+
31+
jobs:
32+
discover_suites:
33+
name: discover conformance suites
34+
runs-on: ubuntu-latest
35+
outputs:
36+
suites: ${{ steps.discover.outputs.suites }}
37+
steps:
38+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
39+
40+
- name: Discover suites from templates
41+
id: discover
42+
working-directory: packages/aws-durable-execution-sdk-python-conformance-tests
43+
run: echo "suites=$(python3 scripts/discover_suites.py)" >> "$GITHUB_OUTPUT"
44+
45+
conformance:
46+
name: conformance (${{ matrix.suite }})
47+
needs: discover_suites
48+
runs-on: ubuntu-latest
49+
permissions:
50+
contents: read
51+
id-token: write # Required for AWS OIDC credentials
52+
strategy:
53+
fail-fast: false
54+
matrix:
55+
suite: ${{ fromJSON(needs.discover_suites.outputs.suites) }}
56+
defaults:
57+
run:
58+
working-directory: packages/aws-durable-execution-sdk-python-conformance-tests
59+
steps:
60+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
61+
62+
- name: Setup Python
63+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
64+
with:
65+
python-version: "3.14"
66+
67+
- name: Configure AWS credentials
68+
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
69+
with:
70+
role-to-assume: "${{ secrets.TEST_ROLE_ARN }}"
71+
role-session-name: pythonConformanceTest
72+
aws-region: ${{ env.AWS_REGION }}
73+
74+
- name: Verify AWS test account
75+
env:
76+
TEST_ACCOUNT_ID: ${{ secrets.TEST_ACCOUNT_ID }}
77+
run: |
78+
ACTUAL_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
79+
if [ "$ACTUAL_ACCOUNT_ID" != "$TEST_ACCOUNT_ID" ]; then
80+
echo "Expected AWS account $TEST_ACCOUNT_ID but assumed into $ACTUAL_ACCOUNT_ID"
81+
exit 1
82+
fi
83+
echo "Using AWS test account $ACTUAL_ACCOUNT_ID"
84+
85+
- name: Setup SAM CLI
86+
uses: aws-actions/setup-sam@89ddb14d60e682855e3fea4be85b3c56485de310 # v3
87+
88+
- name: Build conformance handlers
89+
run: python3 scripts/build_examples.py
90+
91+
- name: Install conformance runner
92+
run: pip install aws-durable-execution-conformance-tests==0.1.0
93+
94+
- name: Inject Lambda execution role into template
95+
env:
96+
ROLE_ARN: ${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }}
97+
run: |
98+
if [ -z "$ROLE_ARN" ]; then
99+
echo "TEST_LAMBDA_EXECUTION_ROLE_ARN not set; template will create its own role."
100+
exit 0
101+
fi
102+
python3 scripts/inject_execution_role.py \
103+
--template template_${{ matrix.suite }}.yaml \
104+
--role-arn "$ROLE_ARN"
105+
106+
- name: Run conformance suite
107+
run: |
108+
python -m aws_durable_execution_conformance_tests.app \
109+
--template template_${{ matrix.suite }}.yaml \
110+
--language python \
111+
--suite ${{ matrix.suite }} \
112+
--name conf-py-${{ matrix.suite }}-${{ github.run_id }} \
113+
--region ${{ env.AWS_REGION }} \
114+
--history-dir history-${{ matrix.suite }} \
115+
--report junit \
116+
--report-file report-${{ matrix.suite }}
117+
118+
- name: Upload conformance report
119+
if: always()
120+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
121+
with:
122+
name: conformance-report-${{ matrix.suite }}
123+
path: |
124+
packages/aws-durable-execution-sdk-python-conformance-tests/report-${{ matrix.suite }}.xml
125+
packages/aws-durable-execution-sdk-python-conformance-tests/history-${{ matrix.suite }}/
126+
if-no-files-found: warn
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
build/
2+
lambda-build/
3+
dist/
4+
.aws-sam/
5+
history-*/
6+
report-*.xml
7+
report-*.json
8+
__pycache__/
9+
*.pyc
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Durable Execution Python SDK — Conformance Tests
2+
3+
Cross-SDK **conformance test handlers** for the Durable Execution Python SDK.
4+
These handlers deploy as AWS Lambda functions and are exercised by the
5+
language-agnostic conformance runner
6+
[`aws-durable-execution-conformance-tests`](https://pypi.org/project/aws-durable-execution-conformance-tests/),
7+
which invokes each function, pulls its execution history, and asserts it matches
8+
the shared requirement specification.
9+
10+
The runner and requirement specifications are maintained in
11+
[`aws/aws-durable-execution-conformance-tests`](https://github.com/aws/aws-durable-execution-conformance-tests).
12+
This package owns the Python handlers and SAM templates that wire them to
13+
requirement IDs.
14+
15+
## Layout
16+
17+
```
18+
handlers/
19+
<suite>/ # one .py handler per conformance scenario
20+
template_<suite>.yaml # maps suite handlers to requirement IDs
21+
scripts/
22+
discover_suites.py # derives supported suites from templates
23+
build_examples.py # assembles lambda-build/ from the local monorepo SDK
24+
inject_execution_role.py # CI: point functions at a pre-existing role
25+
tests/ # unit tests for the scripts
26+
```
27+
28+
Each `template_<suite>.yaml` is a self-contained deployment for one conformance
29+
suite. Templates are the single source of truth: the local build and GitHub
30+
Actions matrix discover suites from them automatically.
31+
32+
## How a handler maps to a requirement
33+
34+
The link is the `TestingMetadata.TestDescription` field on each function in the
35+
SAM template:
36+
37+
```yaml
38+
RequirementCase:
39+
Type: AWS::Serverless::Function
40+
TestingMetadata:
41+
TestDescription: ["<requirement-id>"]
42+
Properties:
43+
CodeUri: lambda-build/
44+
Handler: <suite>.<handler_module>.handler
45+
Role: !GetAtt DurableFunctionRole.Arn
46+
DurableConfig:
47+
RetentionPeriodInDays: 7
48+
ExecutionTimeout: 300
49+
```
50+
51+
The runner invokes the function once per requirement ID using the requirement's
52+
`Input`, then compares the execution history with `ExpectedExecutionHistory`.
53+
54+
## Building locally
55+
56+
`scripts/build_examples.py` assembles `lambda-build/` from the local monorepo
57+
SDK source, not a PyPI release. `boto3` is provided by the Lambda runtime and is
58+
not vendored.
59+
60+
```bash
61+
cd packages/aws-durable-execution-sdk-python-conformance-tests
62+
python3 scripts/build_examples.py
63+
```
64+
65+
The script discovers every `template_<suite>.yaml`, verifies a matching
66+
`handlers/<suite>/` directory exists, and copies all discovered suites:
67+
68+
```
69+
lambda-build/
70+
aws_durable_execution_sdk_python/
71+
<suite>/
72+
```
73+
74+
## Running a suite
75+
76+
Prerequisites: Python ≥ 3.14, the AWS SAM CLI, and AWS credentials for an
77+
account where Durable Execution is available.
78+
79+
```bash
80+
cd packages/aws-durable-execution-sdk-python-conformance-tests
81+
SUITE=<suite>
82+
83+
python3 scripts/build_examples.py
84+
pip install aws-durable-execution-conformance-tests==0.1.0
85+
86+
python -m aws_durable_execution_conformance_tests.app \
87+
--template "template_${SUITE}.yaml" \
88+
--language python \
89+
--suite "$SUITE" \
90+
--name "conformance-python-${SUITE}-local" \
91+
--region us-west-2 \
92+
--history-dir "history-${SUITE}" \
93+
--report junit --report-file "report-${SUITE}"
94+
```
95+
96+
The runner deploys the template, invokes each function, validates its result and
97+
history, and cleans up the stack by default.
98+
99+
## Authoring a new test case
100+
101+
1. Find or add the requirement in the conformance repository under
102+
`test-requirements/<suite>/<id>.yaml`.
103+
2. Add `handlers/<suite>/<descriptive_name>.py` exporting `handler`. Use the
104+
SDK's real API; never hand-roll behavior to force an expected result.
105+
3. Register it in `template_<suite>.yaml` with
106+
`Handler: <suite>.<name>.handler` and `TestDescription: ["<id>"]`.
107+
4. Rebuild and run the suite.
108+
109+
For a new suite, adding its template and handler directory is sufficient; the
110+
build and CI matrix discover it automatically.
111+
112+
## CI
113+
114+
`.github/workflows/conformance-tests.yml` first discovers all suites from
115+
`template_<suite>.yaml`, then runs one parallel matrix job per suite. CI assumes
116+
AWS credentials through the repository's existing OIDC secrets.
117+
118+
Before deployment, `scripts/inject_execution_role.py` points every function at
119+
the pre-existing execution role (`TEST_LAMBDA_EXECUTION_ROLE_ARN`) and removes
120+
the template's self-created `DurableFunctionRole`. The checked-in templates
121+
remain self-contained for local runs.

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/__init__.py

Whitespace-only changes.

packages/aws-durable-execution-sdk-python-conformance-tests/handlers/step/__init__.py

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from typing import Any
2+
3+
from aws_durable_execution_sdk_python.config import Duration
4+
from aws_durable_execution_sdk_python.context import (
5+
DurableContext,
6+
StepContext,
7+
durable_step,
8+
)
9+
from aws_durable_execution_sdk_python.execution import durable_execution
10+
11+
12+
@durable_step
13+
def compute(_step_context: StepContext) -> str:
14+
return "computed"
15+
16+
17+
@durable_execution
18+
def handler(_event: Any, context: DurableContext) -> str:
19+
result: str = context.step(compute())
20+
context.wait(Duration.from_seconds(2))
21+
return result
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""1-17: AtMostOnce interrupted (no retry) - Lambda crash, StepInterruptedError, fails permanently."""
2+
3+
import os
4+
import time
5+
from typing import Any
6+
7+
from aws_durable_execution_sdk_python.config import StepConfig, StepSemantics
8+
from aws_durable_execution_sdk_python.context import (
9+
DurableContext,
10+
StepContext,
11+
durable_step,
12+
)
13+
from aws_durable_execution_sdk_python.execution import durable_execution
14+
from aws_durable_execution_sdk_python.retries import RetryPresets
15+
16+
17+
@durable_step
18+
def at_most_once_flaky_step(_step_context: StepContext, *, input_1: str) -> str:
19+
print(input_1, flush=True)
20+
time.sleep(1) # Allow time for logs to flush to CloudWatch
21+
os._exit(1) # Simulate Lambda crash
22+
return "unreachable"
23+
24+
25+
@durable_execution
26+
def handler(event: Any, context: DurableContext) -> str:
27+
result: str = context.step(
28+
at_most_once_flaky_step(input_1=str(event)),
29+
name="at_most_once_flaky_step",
30+
config=StepConfig(
31+
retry_strategy=RetryPresets.none(),
32+
step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
33+
),
34+
)
35+
return result
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""1-18: AtMostOnce interrupted (with retry, uses the step context attempt number)."""
2+
3+
import os
4+
import time
5+
from typing import Any
6+
7+
from aws_durable_execution_sdk_python.config import Duration, StepConfig, StepSemantics
8+
from aws_durable_execution_sdk_python.context import (
9+
DurableContext,
10+
StepContext,
11+
durable_step,
12+
)
13+
from aws_durable_execution_sdk_python.execution import durable_execution
14+
from aws_durable_execution_sdk_python.retries import (
15+
RetryStrategyConfig,
16+
create_retry_strategy,
17+
)
18+
19+
20+
@durable_step
21+
def at_most_once_step(step_context: StepContext, *, input_1: str) -> str:
22+
# Print input to stdout each time step executes
23+
print(input_1, flush=True)
24+
time.sleep(1) # Allow time for logs to flush to CloudWatch
25+
26+
# The attempt number is the SDK's built-in durable counter from the step
27+
# context (1-based). Under AtMostOncePerRetry the interrupted first attempt
28+
# is consumed, so the retry re-executes as attempt 2.
29+
if step_context.attempt < 2:
30+
# First attempt: simulate Lambda crash
31+
os._exit(1)
32+
# Second attempt (retry): succeed
33+
return "succeeded on second attempt"
34+
35+
36+
@durable_execution
37+
def handler(event: Any, context: DurableContext) -> str:
38+
retry_config = RetryStrategyConfig(
39+
max_attempts=3,
40+
initial_delay=Duration.from_seconds(1),
41+
)
42+
43+
result: str = context.step(
44+
at_most_once_step(input_1=str(event)),
45+
config=StepConfig(
46+
retry_strategy=create_retry_strategy(retry_config),
47+
step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY,
48+
),
49+
)
50+
return result
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from typing import Any
2+
3+
from aws_durable_execution_sdk_python.context import (
4+
DurableContext,
5+
StepContext,
6+
durable_step,
7+
)
8+
from aws_durable_execution_sdk_python.execution import durable_execution
9+
10+
11+
@durable_step
12+
def greet(_step_context: StepContext, name: str) -> str:
13+
return f"Hello, {name}!"
14+
15+
16+
@durable_execution
17+
def handler(event: Any, context: DurableContext) -> str:
18+
result: str = context.step(greet(event))
19+
return result

0 commit comments

Comments
 (0)