diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index d8809b3..9ed3388 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -85,11 +85,8 @@ jobs: FUNCTION_NAME="${EXAMPLE_NAME_CLEAN}-Python" fi - # Extract handler file name - HANDLER_FILE=$(echo "${{ matrix.example.handler }}" | sed 's/\.handler$//') - - echo "Deploying $HANDLER_FILE as $FUNCTION_NAME" - hatch run examples:deploy "$HANDLER_FILE" "$FUNCTION_NAME" + echo "Deploying ${{ matrix.example.name }} as $FUNCTION_NAME" + hatch run examples:deploy "${{ matrix.example.name }}" --function-name "$FUNCTION_NAME" # Store function name for later steps echo "FUNCTION_NAME=$FUNCTION_NAME" >> $GITHUB_ENV diff --git a/.gitignore b/.gitignore index be21c25..b8781d3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ dist/ .kiro/ .idea .env + +examples/build/* +examples/*.zip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f2846e..d43e9fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,6 +121,47 @@ Mimic the package structure in the src/aws_durable_execution_sdk_python director Name your module so that src/mypackage/mymodule.py has a dedicated unit test file tests/mypackage/mymodule_test.py +## Examples and Deployment + +The project includes a unified CLI tool for managing examples, deployment, and AWS account setup: + +### Bootstrap AWS Account +```bash +# Set up IAM role and KMS key for durable functions +export AWS_ACCOUNT_ID=your-account-id +hatch run examples:bootstrap +``` + +### Build and Deploy Examples +```bash +# Build all examples with dependencies +hatch run examples:build + +# Generate SAM template for all examples +hatch run examples:generate-sam + +# List available examples +hatch run examples:list + +# Deploy specific example (when durable functions are available) +hatch run examples:deploy "Hello World" +``` + +### Other CLI Commands +```bash +# Invoke deployed function +hatch run examples:invoke function-name --payload '{}' + +# Get execution details +hatch run examples:get execution-arn + +# Get execution history +hatch run examples:history execution-arn + +# Clean build artifacts +hatch run examples:clean +``` + ## Coverage ``` hatch run test:cov diff --git a/examples/.env.template b/examples/.env.template deleted file mode 100644 index 6459850..0000000 --- a/examples/.env.template +++ /dev/null @@ -1,6 +0,0 @@ -# AWS Configuration for Lambda Deployment -AWS_REGION=us-west-2 -AWS_ACCOUNT_ID=123456789012 -LAMBDA_ENDPOINT=https://lambda.us-west-2.amazonaws.com -INVOKE_ACCOUNT_ID=123456789012 -KMS_KEY_ARN=arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012 diff --git a/examples/.gitignore b/examples/.gitignore deleted file mode 100644 index dd6e2ba..0000000 --- a/examples/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -build/ -*.zip -.env -.aws-sam/ diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 1cbc2e2..0000000 --- a/examples/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Python Durable Functions Examples - -## Local Testing with SAM - -Test functions locally: -```bash -sam local invoke HelloWorldFunction -``` - -Test with custom event: -```bash -sam local invoke HelloWorldFunction -e event.json -``` - -## Deploy Functions - -Deploy with Python script: -```bash -python3 deploy.py hello_world -``` - -Deploy with SAM: -```bash -sam build -sam deploy --guided -``` - -## Environment Variables - -- `AWS_ACCOUNT_ID`: Your AWS account ID -- `LAMBDA_ENDPOINT`: Your Lambda service endpoint -- `INVOKE_ACCOUNT_ID`: Account ID allowed to invoke functions -- `AWS_REGION`: AWS region (default: us-west-2) -- `KMS_KEY_ARN`: KMS key for encryption (optional) - -## Available Examples - -- **hello_world**: Simple hello world function - -## Adding New Examples - -1. Add your Python function to `src/` -2. Update `examples-catalog.json` and `template.yaml` -3. Deploy using either script above diff --git a/examples/build.py b/examples/build.py deleted file mode 100755 index ccd4d3b..0000000 --- a/examples/build.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 - -import shutil -import site -from pathlib import Path - - -def build(): - """Build examples with SDK dependencies from current environment.""" - examples_dir = Path(__file__).parent - build_dir = examples_dir / "build" - - # Clean build directory - if build_dir.exists(): - shutil.rmtree(build_dir) - build_dir.mkdir() - - print("Copying SDK from current environment...") - - # Copy the SDK from current environment (hatch installs it) - for site_dir in site.getsitepackages(): - sdk_path = Path(site_dir) / "aws_durable_execution_sdk_python" - if sdk_path.exists(): - shutil.copytree(sdk_path, build_dir / "aws_durable_execution_sdk_python") - print(f"Copied SDK from {sdk_path}") - break - else: - print("SDK not found in site-packages") - - print("Copying testing SDK source...") - - # Copy testing SDK source - sdk_src = examples_dir.parent / "src" / "aws_durable_execution_sdk_python_testing" - if sdk_src.exists(): - shutil.copytree(sdk_src, build_dir / "aws_durable_execution_sdk_python_testing") - - print("Copying example functions...") - - # Copy example source files - src_dir = examples_dir / "src" - for py_file in src_dir.glob("*.py"): - if py_file.name != "__init__.py": - shutil.copy2(py_file, build_dir) - - print(f"Build complete: {build_dir}") - - -if __name__ == "__main__": - build() diff --git a/examples/cli.py b/examples/cli.py new file mode 100755 index 0000000..c501531 --- /dev/null +++ b/examples/cli.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 + +import argparse +import contextlib +import json +import logging +import os +import shutil +import sys +import zipfile +from pathlib import Path + + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +try: + import boto3 + from aws_durable_execution_sdk_python.lambda_service import LambdaClient +except ImportError: + sys.exit(1) + + +def load_catalog(): + """Load examples catalog.""" + catalog_path = Path(__file__).parent / "examples-catalog.json" + with open(catalog_path) as f: + return json.load(f) + + +def build_examples(): + """Build examples with SDK dependencies.""" + + build_dir = Path(__file__).parent / "build" + src_dir = Path(__file__).parent / "src" + + logger.info("Building examples...") + + # Clean and create build directory + if build_dir.exists(): + logger.info("Cleaning existing build directory") + shutil.rmtree(build_dir) + build_dir.mkdir() + + # Copy SDK from current environment + try: + import aws_durable_execution_sdk_python + + sdk_path = Path(aws_durable_execution_sdk_python.__file__).parent + logger.info("Copying SDK from %s", sdk_path) + shutil.copytree(sdk_path, build_dir / "aws_durable_execution_sdk_python") + except (ImportError, OSError): + logger.exception("Failed to copy SDK") + return False + + # Copy testing SDK source + testing_src = ( + Path(__file__).parent.parent + / "src" + / "aws_durable_execution_sdk_python_testing" + ) + logger.info("Copying testing SDK from %s", testing_src) + shutil.copytree(testing_src, build_dir / "aws_durable_execution_sdk_python_testing") + + # Copy example functions + logger.info("Copying examples from %s", src_dir) + shutil.copytree(src_dir, build_dir / "src") + + logger.info("Build completed successfully") + return True + + +def create_kms_key(kms_client, account_id): + """Create KMS key for durable functions encryption.""" + key_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "kms:*", + "Resource": "*", + }, + { + "Sid": "Allow Lambda service", + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": ["kms:Decrypt", "kms:Encrypt", "kms:CreateGrant"], + "Resource": "*", + }, + ], + } + + try: + response = kms_client.create_key( + Description="KMS key for Lambda Durable Functions environment variable encryption", + KeyUsage="ENCRYPT_DECRYPT", + KeySpec="SYMMETRIC_DEFAULT", + Policy=json.dumps(key_policy), + ) + + return response["KeyMetadata"]["Arn"] + + except (kms_client.exceptions.ClientError, KeyError): + return None + + +def bootstrap_account(): + """Bootstrap account with necessary IAM role and KMS key.""" + account_id = os.getenv("AWS_ACCOUNT_ID") + region = os.getenv("AWS_REGION", "us-west-2") + + if not account_id: + return False + + # Create KMS key first + kms_client = boto3.client("kms", region_name=region) + kms_key_arn = create_kms_key(kms_client, account_id) + if not kms_key_arn: + return False + + iam_client = boto3.client("iam", region_name=region) + role_name = "DurableFunctionsIntegrationTestRole" + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": ["lambda.amazonaws.com", "devo.lambda.aws.internal"] + }, + "Action": "sts:AssumeRole", + } + ], + } + + lambda_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "lambda:CheckpointDurableExecution", + "lambda:GetDurableExecutionState", + ], + "Resource": "*", + "Effect": "Allow", + } + ], + } + + logs_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Resource": "*", + "Effect": "Allow", + } + ], + } + + kms_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["kms:CreateGrant", "kms:Decrypt", "kms:Encrypt"], + "Resource": kms_key_arn, + "Effect": "Allow", + } + ], + } + + try: + iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust_policy), + Description="Role for AWS Durable Functions integration testing", + ) + + iam_client.put_role_policy( + RoleName=role_name, + PolicyName="LambdaPolicy", + PolicyDocument=json.dumps(lambda_policy), + ) + + iam_client.put_role_policy( + RoleName=role_name, + PolicyName="LogsPolicy", + PolicyDocument=json.dumps(logs_policy), + ) + + iam_client.put_role_policy( + RoleName=role_name, + PolicyName="DurableFunctionsLambdaStagingKMSPolicy", + PolicyDocument=json.dumps(kms_policy), + ) + + except iam_client.exceptions.EntityAlreadyExistsException: + pass + except iam_client.exceptions.ClientError: + return False + else: + return True + + return True + + +def generate_sam_template(): + """Generate SAM template for all examples.""" + catalog = load_catalog() + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Globals": { + "Function": { + "Runtime": "python3.13", + "Timeout": 60, + "MemorySize": 128, + "Environment": { + "Variables": {"DEX_ENDPOINT": {"Ref": "LambdaEndpoint"}} + }, + } + }, + "Parameters": { + "LambdaEndpoint": { + "Type": "String", + "Default": "https://lambda.us-west-2.amazonaws.com", + } + }, + "Resources": {}, + } + + for example in catalog["examples"]: + function_name = example["handler"].replace("_", "").title() + "Function" + template["Resources"][function_name] = { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "build/", + "Handler": f"{example['handler']}.handler", + "Description": example["description"], + }, + } + + if "durableConfig" in example: + template["Resources"][function_name]["Properties"]["DurableConfig"] = ( + example["durableConfig"] + ) + + import yaml + + with open("template.yaml", "w") as f: + yaml.dump(template, f, default_flow_style=False, sort_keys=False) + + return True + + +def create_deployment_package(example_name: str) -> Path: + """Create deployment package for example.""" + + build_dir = Path(__file__).parent / "build" + if not build_dir.exists() and not build_examples(): + msg = "Failed to build examples" + raise ValueError(msg) + + zip_path = Path(__file__).parent / f"{example_name}.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + # Add SDK dependencies + for file_path in build_dir.rglob("*"): + if file_path.is_file() and not file_path.is_relative_to(build_dir / "src"): + zf.write(file_path, file_path.relative_to(build_dir)) + + # Add example files at root level + src_dir = build_dir / "src" + for file_path in src_dir.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(src_dir)) + + return zip_path + + +def get_aws_config(): + """Get AWS configuration from environment.""" + config = { + "region": os.getenv("AWS_REGION", "us-west-2"), + "lambda_endpoint": os.getenv("LAMBDA_ENDPOINT"), + "account_id": os.getenv("AWS_ACCOUNT_ID"), + "invoke_account_id": os.getenv("INVOKE_ACCOUNT_ID"), + "kms_key_arn": os.getenv("KMS_KEY_ARN"), + } + + if not all( + [config["account_id"], config["lambda_endpoint"], config["invoke_account_id"]] + ): + msg = "Missing required environment variables" + raise ValueError(msg) + + return config + + +def get_lambda_client(): + """Get configured Lambda client.""" + config = get_aws_config() + LambdaClient.load_preview_botocore_models() + return boto3.client( + "lambda", + endpoint_url=config["lambda_endpoint"], + region_name=config["region"], + config=boto3.session.Config(parameter_validation=False), + ) + + +def deploy_function(example_name: str, function_name: str | None = None): + """Deploy function to AWS Lambda.""" + catalog = load_catalog() + + example_config = None + for example in catalog["examples"]: + if example["name"] == example_name: + example_config = example + break + + if not example_config: + logger.error("Example not found: '%s'", example_name) + list_examples() + return False + + if not function_name: + function_name = f"{example_name.replace(' ', '')}-Python" + + handler_file = example_config["handler"].replace(".handler", "") + zip_path = create_deployment_package(handler_file) + config = get_aws_config() + lambda_client = get_lambda_client() + + role_arn = ( + f"arn:aws:iam::{config['account_id']}:role/DurableFunctionsIntegrationTestRole" + ) + + function_config = { + "FunctionName": function_name, + "Runtime": "python3.13", + "Role": role_arn, + "Handler": example_config["handler"], + "Description": example_config["description"], + "Timeout": 60, + "MemorySize": 128, + # "Environment": {"Variables": {"AWS_ENDPOINT_URL_LAMBDA": config["lambda_endpoint"]}}, + "DurableConfig": example_config["durableConfig"], + } + + if config["kms_key_arn"]: + function_config["KMSKeyArn"] = config["kms_key_arn"] + + with open(zip_path, "rb") as f: + zip_content = f.read() + + try: + lambda_client.get_function(FunctionName=function_name) + lambda_client.update_function_code( + FunctionName=function_name, ZipFile=zip_content + ) + lambda_client.update_function_configuration(**function_config) + + except lambda_client.exceptions.ResourceNotFoundException: + lambda_client.create_function(**function_config, Code={"ZipFile": zip_content}) + + # Update invoke permission for worker account if needed + try: + policy_response = lambda_client.get_policy(FunctionName=function_name) + policy = json.loads(policy_response["Policy"]) + + # Check if permission exists with correct principal + needs_update = True + for statement in policy.get("Statement", []): + if ( + statement.get("Sid") == "dex-invoke-permission" + and statement.get("Principal", {}).get("AWS") + == config["invoke_account_id"] + ): + needs_update = False + break + + if needs_update: + with contextlib.suppress( + lambda_client.exceptions.ResourceNotFoundException + ): + lambda_client.remove_permission( + FunctionName=function_name, StatementId="dex-invoke-permission" + ) + + lambda_client.add_permission( + FunctionName=function_name, + StatementId="dex-invoke-permission", + Action="lambda:InvokeFunction", + Principal=config["invoke_account_id"], + ) + + except lambda_client.exceptions.ResourceNotFoundException: + # No policy exists, add permission + lambda_client.add_permission( + FunctionName=function_name, + StatementId="dex-invoke-permission", + Action="lambda:InvokeFunction", + Principal=config["invoke_account_id"], + ) + + logger.info("Function deployed successfully! %s", function_name) + return True + + +def invoke_function(function_name: str, payload: str = "{}"): + """Invoke a deployed function.""" + lambda_client = get_lambda_client() + + try: + response = lambda_client.invoke(FunctionName=function_name, Payload=payload) + + result = json.loads(response["Payload"].read()) + + if "DurableExecutionArn" in result: + pass + + return result.get("DurableExecutionArn") + + except lambda_client.exceptions.ClientError: + return None + + +def get_execution(execution_arn: str): + """Get execution details.""" + lambda_client = get_lambda_client() + + try: + return lambda_client.get_durable_execution(DurableExecutionArn=execution_arn) + except lambda_client.exceptions.ClientError: + return None + + +def get_execution_history(execution_arn: str): + """Get execution history.""" + lambda_client = get_lambda_client() + + try: + return lambda_client.get_durable_execution_history( + DurableExecutionArn=execution_arn + ) + except lambda_client.exceptions.ClientError: + return None + + +def get_function_policy(function_name: str): + """Get function resource policy.""" + lambda_client = get_lambda_client() + + try: + response = lambda_client.get_policy(FunctionName=function_name) + return json.loads(response["Policy"]) + except lambda_client.exceptions.ResourceNotFoundException: + return None + except (lambda_client.exceptions.ClientError, json.JSONDecodeError): + return None + + +def list_examples(): + """List available examples.""" + catalog = load_catalog() + logger.info("Available examples:") + for example in catalog["examples"]: + logger.info(" - %s: %s", example["name"], example["description"]) + + +def main(): + """Main CLI function.""" + parser = argparse.ArgumentParser(description="Durable Functions Examples CLI") + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Bootstrap command + subparsers.add_parser("bootstrap", help="Bootstrap account with necessary IAM role") + + # Build command + subparsers.add_parser("build", help="Build examples with dependencies") + + # List command + subparsers.add_parser("list", help="List available examples") + + # SAM template command + subparsers.add_parser("sam", help="Generate SAM template for all examples") + + # Deploy command + deploy_parser = subparsers.add_parser("deploy", help="Deploy an example") + deploy_parser.add_argument("example_name", help="Name of example to deploy") + deploy_parser.add_argument("--function-name", help="Custom function name") + + # Invoke command + invoke_parser = subparsers.add_parser("invoke", help="Invoke a deployed function") + invoke_parser.add_argument("function_name", help="Name of function to invoke") + invoke_parser.add_argument("--payload", default="{}", help="JSON payload to send") + + # Get command + get_parser = subparsers.add_parser("get", help="Get execution details") + get_parser.add_argument("execution_arn", help="Execution ARN") + + # Policy command + policy_parser = subparsers.add_parser("policy", help="Get function resource policy") + policy_parser.add_argument("function_name", help="Function name") + + # History command + history_parser = subparsers.add_parser("history", help="Get execution history") + history_parser.add_argument("execution_arn", help="Execution ARN") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + if args.command == "bootstrap": + bootstrap_account() + elif args.command == "build": + build_examples() + elif args.command == "list": + list_examples() + elif args.command == "sam": + generate_sam_template() + elif args.command == "deploy": + deploy_function(args.example_name, args.function_name) + elif args.command == "invoke": + invoke_function(args.function_name, args.payload) + elif args.command == "policy": + get_function_policy(args.function_name) + elif args.command == "get": + get_execution(args.execution_arn) + elif args.command == "history": + get_execution_history(args.execution_arn) + except (KeyboardInterrupt, SystemExit): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/deploy.py b/examples/deploy.py deleted file mode 100755 index 9f9fcca..0000000 --- a/examples/deploy.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import sys -import zipfile -from pathlib import Path - - -try: - import boto3 - from aws_durable_execution_sdk_python.lambda_service import LambdaClient -except ImportError: - print("Error: boto3 and aws_durable_execution_sdk_python are required.") - sys.exit(1) - - -def load_catalog(): - """Load examples catalog.""" - catalog_path = Path(__file__).parent / "examples-catalog.json" - with open(catalog_path) as f: - return json.load(f) - - -def create_deployment_package(example_name: str) -> Path: - """Create deployment package for example.""" - print(f"Creating deployment package for {example_name}...") - - # Use the build directory that already has SDK + examples - build_dir = Path(__file__).parent / "build" - if not build_dir.exists(): - msg = "Build directory not found. Run 'hatch run examples:build' first." - raise ValueError(msg) - - # Create zip from build directory - zip_path = Path(__file__).parent / f"{example_name}.zip" - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for file_path in build_dir.rglob("*"): - if file_path.is_file(): - zf.write(file_path, file_path.relative_to(build_dir)) - - print(f"Package created: {zip_path}") - return zip_path - - -def deploy_function(example_config: dict, function_name: str): - """Deploy function to AWS Lambda.""" - handler_file = example_config["handler"].replace(".handler", "") - zip_path = create_deployment_package(handler_file) - - # AWS configuration - region = os.getenv("AWS_REGION", "us-west-2") - lambda_endpoint = os.getenv("LAMBDA_ENDPOINT") - account_id = os.getenv("AWS_ACCOUNT_ID") - invoke_account_id = os.getenv("INVOKE_ACCOUNT_ID") - kms_key_arn = os.getenv("KMS_KEY_ARN") - - print("Debug - Environment variables:") - print(f" AWS_REGION: {region}") - print(f" LAMBDA_ENDPOINT: {lambda_endpoint}") - print(f" AWS_ACCOUNT_ID: {account_id}") - print(f" INVOKE_ACCOUNT_ID: {invoke_account_id}") - - if not all([account_id, lambda_endpoint, invoke_account_id]): - msg = "Missing required environment variables" - raise ValueError(msg) - - # Initialize Lambda client with custom models - LambdaClient.load_preview_botocore_models() - - # Use regular lambda client for now - lambda_client = boto3.client( - "lambda", endpoint_url=lambda_endpoint, region_name=region - ) - - role_arn = f"arn:aws:iam::{account_id}:role/DurableFunctionsIntegrationTestRole" - - # Function configuration - function_config = { - "FunctionName": function_name, - "Runtime": "python3.13", - "Role": role_arn, - "Handler": example_config["handler"], - "Description": example_config["description"], - "Timeout": 60, - "MemorySize": 128, - "Environment": {"Variables": {"DEX_ENDPOINT": lambda_endpoint}}, - "DurableConfig": example_config["durableConfig"], - } - - if kms_key_arn: - function_config["KMSKeyArn"] = kms_key_arn - - # Read zip file - with open(zip_path, "rb") as f: - zip_content = f.read() - - try: - # Try to get existing function - lambda_client.get_function(FunctionName=function_name) - print(f"Updating existing function: {function_name}") - - # Update code - lambda_client.update_function_code( - FunctionName=function_name, ZipFile=zip_content - ) - - # Update configuration - lambda_client.update_function_configuration(**function_config) - - except lambda_client.exceptions.ResourceNotFoundException: - print(f"Creating new function: {function_name}") - - # Create function - lambda_client.create_function(**function_config, Code={"ZipFile": zip_content}) - - # Add invoke permission - try: - lambda_client.add_permission( - FunctionName=function_name, - StatementId="dex-invoke-permission", - Action="lambda:InvokeFunction", - Principal=invoke_account_id, - ) - print("Added invoke permission") - except lambda_client.exceptions.ResourceConflictException: - print("Invoke permission already exists") - - print(f"Successfully deployed: {function_name}") - - -def main(): - """Main deployment function.""" - if len(sys.argv) < 2: - print("Usage: python deploy.py [function-name]") - sys.exit(1) - - example_name = sys.argv[1] - function_name = sys.argv[2] if len(sys.argv) > 2 else f"{example_name}-Python" - - catalog = load_catalog() - - # Find example - example_config = None - for example in catalog["examples"]: - if example["handler"].startswith(example_name): - example_config = example - break - - if not example_config: - print(f"Example '{example_name}' not found in catalog") - sys.exit(1) - - deploy_function(example_config, function_name) - - -if __name__ == "__main__": - main() diff --git a/examples/event.json b/examples/event.json deleted file mode 100644 index fcf6566..0000000 --- a/examples/event.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "test": "data" -} diff --git a/examples/examples-catalog.json b/examples/examples-catalog.json index a18ef6b..5e18db9 100644 --- a/examples/examples-catalog.json +++ b/examples/examples-catalog.json @@ -11,6 +11,105 @@ "ExecutionTimeout": 300 }, "path": "./src/hello_world.py" + }, + { + "name": "Basic Step", + "description": "Basic usage of context.step() to checkpoint a simple operation", + "handler": "step.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/step.py" + }, + { + "name": "Step with Name", + "description": "Step operation with explicit name parameter", + "handler": "step_with_name.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/step_with_name.py" + }, + { + "name": "Step with Retry", + "description": "Usage of context.step() with retry configuration for fault tolerance", + "handler": "step_with_retry.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/step_with_retry.py" + }, + { + "name": "Wait State", + "description": "Basic usage of context.wait() to pause execution", + "handler": "wait.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/wait.py" + }, + { + "name": "Callback", + "description": "Basic usage of context.create_callback() to create a callback for external systems", + "handler": "callback.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/callback.py" + }, + { + "name": "Wait for Callback", + "description": "Usage of context.wait_for_callback() to wait for external system responses", + "handler": "wait_for_callback.handler", + "integration": false, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/wait_for_callback.py" + }, + { + "name": "Run in Child Context", + "description": "Usage of context.run_in_child_context() to execute operations in isolated contexts", + "handler": "run_in_child_context.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/run_in_child_context.py" + }, + { + "name": "Parallel Operations", + "description": "Executing multiple durable operations in parallel", + "handler": "parallel.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/parallel.py" + }, + { + "name": "Map Operations", + "description": "Processing collections using map-like durable operations", + "handler": "map_operations.handler", + "integration": true, + "durableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 300 + }, + "path": "./src/map_operations.py" } ] } diff --git a/examples/src/callback.py b/examples/src/callback.py new file mode 100644 index 0000000..4074a62 --- /dev/null +++ b/examples/src/callback.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING, Any + +from aws_durable_execution_sdk_python.config import CallbackConfig +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +if TYPE_CHECKING: + from aws_durable_execution_sdk_python.types import Callback + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + callback_config = CallbackConfig(timeout_seconds=120, heartbeat_timeout_seconds=60) + + callback: Callback[str] = context.create_callback( + name="example_callback", config=callback_config + ) + + # In a real scenario, you would pass callback.callback_id to an external system + # For this example, we'll just return the callback_id to show it was created + return f"Callback created with ID: {callback.callback_id}" diff --git a/examples/src/callback_with_timeout.py b/examples/src/callback_with_timeout.py new file mode 100644 index 0000000..484ee60 --- /dev/null +++ b/examples/src/callback_with_timeout.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING, Any + +from aws_durable_execution_sdk_python.config import CallbackConfig +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +if TYPE_CHECKING: + from aws_durable_execution_sdk_python.types import Callback + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Callback with custom timeout configuration + config = CallbackConfig(timeout_seconds=60, heartbeat_timeout_seconds=30) + + callback: Callback[str] = context.create_callback( + name="timeout_callback", config=config + ) + + return f"Callback created with 60s timeout: {callback.callback_id}" diff --git a/examples/src/map_operations.py b/examples/src/map_operations.py new file mode 100644 index 0000000..b04142d --- /dev/null +++ b/examples/src/map_operations.py @@ -0,0 +1,22 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +def square(x: int) -> int: + return x * x + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Process a list of items using map-like operations + items = [1, 2, 3, 4, 5] + + # Process each item as a separate durable step + results = [] + for i, item in enumerate(items): + result = context.step(lambda _, x=item: square(x), name=f"square_{i}") + results.append(result) + + return f"Squared results: {results}" diff --git a/examples/src/parallel.py b/examples/src/parallel.py new file mode 100644 index 0000000..1560ec7 --- /dev/null +++ b/examples/src/parallel.py @@ -0,0 +1,15 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Execute multiple operations in parallel + task1 = context.step(lambda _: "Task 1 complete", name="task1") + task2 = context.step(lambda _: "Task 2 complete", name="task2") + task3 = context.step(lambda _: "Task 3 complete", name="task3") + + # All tasks execute concurrently and results are collected + return f"Results: {task1}, {task2}, {task3}" diff --git a/examples/src/parallel_first_successful.py b/examples/src/parallel_first_successful.py new file mode 100644 index 0000000..8775aed --- /dev/null +++ b/examples/src/parallel_first_successful.py @@ -0,0 +1,27 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import CompletionConfig, ParallelConfig +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Parallel execution with first_successful completion strategy + config = ParallelConfig(completion_config=CompletionConfig.first_successful()) + + functions = [ + lambda ctx: ctx.step(lambda _: "Task 1", name="task1"), + lambda ctx: ctx.step(lambda _: "Task 2", name="task2"), + lambda ctx: ctx.step(lambda _: "Task 3", name="task3"), + ] + + results = context.parallel( + functions, name="first_successful_parallel", config=config + ) + + # Extract the first successful result + first_result = ( + results.successful_results[0] if results.successful_results else "None" + ) + return f"First successful result: {first_result}" diff --git a/examples/src/run_in_child_context.py b/examples/src/run_in_child_context.py new file mode 100644 index 0000000..27fef26 --- /dev/null +++ b/examples/src/run_in_child_context.py @@ -0,0 +1,22 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import ( + DurableContext, + durable_with_child_context, +) +from aws_durable_execution_sdk_python.execution import durable_handler + + +def multiply_by_two(value: int) -> int: + return value * 2 + + +@durable_with_child_context +def child_operation(ctx: DurableContext, value: int) -> int: + return ctx.step(lambda _: multiply_by_two(value), name="multiply") + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + result = context.run_in_child_context(child_operation(5)) + return f"Child context result: {result}" diff --git a/examples/src/step.py b/examples/src/step.py new file mode 100644 index 0000000..fddf91d --- /dev/null +++ b/examples/src/step.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_handler + + +@durable_step +def add_numbers(_step_context: StepContext, a: int, b: int) -> int: + return a + b + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> int: + result: int = context.step(add_numbers(5, 3)) + return result diff --git a/examples/src/step_no_name.py b/examples/src/step_no_name.py new file mode 100644 index 0000000..ba53a3a --- /dev/null +++ b/examples/src/step_no_name.py @@ -0,0 +1,11 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Step without explicit name - should use function name + result = context.step(lambda _: "Step without name") + return f"Result: {result}" diff --git a/examples/src/step_semantics_at_most_once.py b/examples/src/step_semantics_at_most_once.py new file mode 100644 index 0000000..6409cfc --- /dev/null +++ b/examples/src/step_semantics_at_most_once.py @@ -0,0 +1,18 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig, StepSemantics +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Step with AT_MOST_ONCE_PER_RETRY semantics + config = StepConfig(step_semantics=StepSemantics.AT_MOST_ONCE_PER_RETRY) + + result = context.step( + lambda _: "AT_MOST_ONCE_PER_RETRY semantics", + name="at_most_once_step", + config=config, + ) + return f"Result: {result}" diff --git a/examples/src/step_with_exponential_backoff.py b/examples/src/step_with_exponential_backoff.py new file mode 100644 index 0000000..1737002 --- /dev/null +++ b/examples/src/step_with_exponential_backoff.py @@ -0,0 +1,24 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Step with exponential backoff retry strategy + retry_config = RetryStrategyConfig( + max_attempts=3, initial_delay_seconds=1, max_delay_seconds=10, backoff_rate=2.0 + ) + + step_config = StepConfig(retry_strategy=create_retry_strategy(retry_config)) + + result = context.step( + lambda _: "Step with exponential backoff", name="retry_step", config=step_config + ) + return f"Result: {result}" diff --git a/examples/src/step_with_name.py b/examples/src/step_with_name.py new file mode 100644 index 0000000..05ee659 --- /dev/null +++ b/examples/src/step_with_name.py @@ -0,0 +1,11 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Step with explicit name + result = context.step(lambda _: "Step with explicit name", name="custom_step") + return f"Result: {result}" diff --git a/examples/src/step_with_retry.py b/examples/src/step_with_retry.py new file mode 100644 index 0000000..cf1246d --- /dev/null +++ b/examples/src/step_with_retry.py @@ -0,0 +1,37 @@ +from random import random +from typing import Any + +from aws_durable_execution_sdk_python.config import StepConfig +from aws_durable_execution_sdk_python.context import ( + DurableContext, + durable_step, +) +from aws_durable_execution_sdk_python.execution import durable_handler +from aws_durable_execution_sdk_python.retries import ( + RetryStrategyConfig, + create_retry_strategy, +) + + +@durable_step +def unreliable_operation() -> str: + failure_threshold = 0.5 + if random() > failure_threshold: # noqa: S311 + msg = "Random error occurred" + raise RuntimeError(msg) + return "Operation succeeded" + + +@durable_handler +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/examples/src/wait.py b/examples/src/wait.py new file mode 100644 index 0000000..f6b7272 --- /dev/null +++ b/examples/src/wait.py @@ -0,0 +1,10 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + context.wait(seconds=5) + return "Wait completed" diff --git a/examples/src/wait_for_callback.py b/examples/src/wait_for_callback.py new file mode 100644 index 0000000..15bf2cb --- /dev/null +++ b/examples/src/wait_for_callback.py @@ -0,0 +1,22 @@ +from typing import Any + +from aws_durable_execution_sdk_python.config import WaitForCallbackConfig +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +def external_system_call(_callback_id: str) -> None: + """Simulate calling an external system with callback ID.""" + # In real usage, this would make an API call to an external system + # passing the callback_id for the system to call back when done + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + config = WaitForCallbackConfig(timeout_seconds=120, heartbeat_timeout_seconds=60) + + result = context.wait_for_callback( + external_system_call, name="external_call", config=config + ) + + return f"External system result: {result}" diff --git a/examples/src/wait_with_name.py b/examples/src/wait_with_name.py new file mode 100644 index 0000000..eb27c20 --- /dev/null +++ b/examples/src/wait_with_name.py @@ -0,0 +1,11 @@ +from typing import Any + +from aws_durable_execution_sdk_python.context import DurableContext +from aws_durable_execution_sdk_python.execution import durable_handler + + +@durable_handler +def handler(_event: Any, context: DurableContext) -> str: + # Wait with explicit name + context.wait(seconds=2, name="custom_wait") + return "Wait with name completed" diff --git a/examples/template.yaml b/examples/template.yaml index c7544c1..3f3c301 100644 --- a/examples/template.yaml +++ b/examples/template.yaml @@ -1,6 +1,5 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 - Globals: Function: Runtime: python3.13 @@ -8,17 +7,103 @@ Globals: MemorySize: 128 Environment: Variables: - DEX_ENDPOINT: !Ref LambdaEndpoint - + DEX_ENDPOINT: + Ref: LambdaEndpoint Parameters: LambdaEndpoint: Type: String - Default: "https://lambda.us-west-2.amazonaws.com" - + Default: https://lambda.us-west-2.amazonaws.com Resources: - HelloWorldFunction: + Helloworld.HandlerFunction: Type: AWS::Serverless::Function Properties: CodeUri: build/ - Handler: hello_world.handler + Handler: hello_world.handler.handler Description: A simple hello world example with no durable operations + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Step.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: step.handler.handler + Description: Basic usage of context.step() to checkpoint a simple operation + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Stepwithname.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: step_with_name.handler.handler + Description: Step operation with explicit name parameter + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Stepwithretry.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: step_with_retry.handler.handler + Description: Usage of context.step() with retry configuration for fault tolerance + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Wait.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: wait.handler.handler + Description: Basic usage of context.wait() to pause execution + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Callback.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: callback.handler.handler + Description: Basic usage of context.create_callback() to create a callback for + external systems + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Waitforcallback.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: wait_for_callback.handler.handler + Description: Usage of context.wait_for_callback() to wait for external system + responses + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Runinchildcontext.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: run_in_child_context.handler.handler + Description: Usage of context.run_in_child_context() to execute operations in + isolated contexts + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Parallel.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: parallel.handler.handler + Description: Executing multiple durable operations in parallel + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + Mapoperations.HandlerFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: build/ + Handler: map_operations.handler.handler + Description: Processing collections using map-like durable operations + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 diff --git a/examples/test/test_callback.py b/examples/test/test_callback.py new file mode 100644 index 0000000..a3712c9 --- /dev/null +++ b/examples/test/test_callback.py @@ -0,0 +1,27 @@ +"""Tests for callback example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import callback + + +def test_callback(): + """Test callback example.""" + with DurableFunctionTestRunner(handler=callback.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result.startswith("Callback created with ID:") + + # Find the callback operation + callback_ops = [ + op for op in result.operations if op.operation_type.value == "CALLBACK" + ] + assert len(callback_ops) == 1 + callback_op = callback_ops[0] + assert callback_op.name == "example_callback" + assert callback_op.callback_id is not None diff --git a/examples/test/test_callback_permutations.py b/examples/test/test_callback_permutations.py new file mode 100644 index 0000000..3e5e0b8 --- /dev/null +++ b/examples/test/test_callback_permutations.py @@ -0,0 +1,25 @@ +"""Tests for callback operation permutations.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import callback_with_timeout + + +def test_callback_with_timeout(): + """Test callback with custom timeout configuration.""" + with DurableFunctionTestRunner(handler=callback_with_timeout.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result.startswith("Callback created with 60s timeout:") + + callback_ops = [ + op for op in result.operations if op.operation_type.value == "CALLBACK" + ] + assert len(callback_ops) == 1 + assert callback_ops[0].name == "timeout_callback" + assert callback_ops[0].callback_id is not None diff --git a/examples/test/test_map_operations.py b/examples/test/test_map_operations.py new file mode 100644 index 0000000..1106c66 --- /dev/null +++ b/examples/test/test_map_operations.py @@ -0,0 +1,26 @@ +"""Tests for map_operations example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import map_operations + + +def test_map_operations(): + """Test map_operations example.""" + with DurableFunctionTestRunner(handler=map_operations.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Squared results: [1, 4, 9, 16, 25]" + + # Verify all five step operations exist + step_ops = [op for op in result.operations if op.operation_type.value == "STEP"] + assert len(step_ops) == 5 + + step_names = {op.name for op in step_ops} + expected_names = {f"square_{i}" for i in range(5)} + assert step_names == expected_names diff --git a/examples/test/test_parallel.py b/examples/test/test_parallel.py new file mode 100644 index 0000000..b192f7c --- /dev/null +++ b/examples/test/test_parallel.py @@ -0,0 +1,25 @@ +"""Tests for parallel example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import parallel + + +def test_parallel(): + """Test parallel example.""" + with DurableFunctionTestRunner(handler=parallel.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Results: Task 1 complete, Task 2 complete, Task 3 complete" + + # Verify all three step operations exist + step_ops = [op for op in result.operations if op.operation_type.value == "STEP"] + assert len(step_ops) == 3 + + step_names = {op.name for op in step_ops} + assert step_names == {"task1", "task2", "task3"} diff --git a/examples/test/test_run_in_child_context.py b/examples/test/test_run_in_child_context.py new file mode 100644 index 0000000..9795cbd --- /dev/null +++ b/examples/test/test_run_in_child_context.py @@ -0,0 +1,24 @@ +"""Tests for run_in_child_context example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import run_in_child_context + + +def test_run_in_child_context(): + """Test run_in_child_context example.""" + with DurableFunctionTestRunner(handler=run_in_child_context.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Child context result: 10" + + # Verify child context operation exists + context_ops = [ + op for op in result.operations if op.operation_type.value == "CONTEXT" + ] + assert len(context_ops) >= 1 diff --git a/examples/test/test_step.py b/examples/test/test_step.py new file mode 100644 index 0000000..dda0693 --- /dev/null +++ b/examples/test/test_step.py @@ -0,0 +1,22 @@ +"""Tests for step example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, + StepOperation, +) +from src import step + + +def test_step(): + """Test basic step example.""" + with DurableFunctionTestRunner(handler=step.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == 8 + + step_result: StepOperation = result.get_step("add_numbers") + assert step_result.result == 8 diff --git a/examples/test/test_step_permutations.py b/examples/test/test_step_permutations.py new file mode 100644 index 0000000..60c0ecd --- /dev/null +++ b/examples/test/test_step_permutations.py @@ -0,0 +1,51 @@ +"""Tests for step operation permutations.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import step_no_name, step_with_exponential_backoff, step_with_name + + +def test_step_no_name(): + """Test step without explicit name.""" + with DurableFunctionTestRunner(handler=step_no_name.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Result: Step without name" + + step_ops = [op for op in result.operations if op.operation_type.value == "STEP"] + assert len(step_ops) == 1 + # Should use function name when no name provided + assert step_ops[0].name is None or step_ops[0].name == "" + + +def test_step_with_name(): + """Test step with explicit name.""" + with DurableFunctionTestRunner(handler=step_with_name.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Result: Step with explicit name" + + step_ops = [op for op in result.operations if op.operation_type.value == "STEP"] + assert len(step_ops) == 1 + assert step_ops[0].name == "custom_step" + + +def test_step_with_exponential_backoff(): + """Test step with exponential backoff retry strategy.""" + with DurableFunctionTestRunner( + handler=step_with_exponential_backoff.handler + ) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Result: Step with exponential backoff" + + step_ops = [op for op in result.operations if op.operation_type.value == "STEP"] + assert len(step_ops) == 1 + assert step_ops[0].name == "retry_step" diff --git a/examples/test/test_wait.py b/examples/test/test_wait.py new file mode 100644 index 0000000..e9331b2 --- /dev/null +++ b/examples/test/test_wait.py @@ -0,0 +1,24 @@ +"""Tests for wait example.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import wait + + +def test_wait(): + """Test wait example.""" + with DurableFunctionTestRunner(handler=wait.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Wait completed" + + # Find the wait operation (it should be the only non-execution operation) + wait_ops = [op for op in result.operations if op.operation_type.value == "WAIT"] + assert len(wait_ops) == 1 + wait_op = wait_ops[0] + assert wait_op.scheduled_timestamp is not None diff --git a/examples/test/test_wait_permutations.py b/examples/test/test_wait_permutations.py new file mode 100644 index 0000000..e2d1965 --- /dev/null +++ b/examples/test/test_wait_permutations.py @@ -0,0 +1,22 @@ +"""Tests for wait operation permutations.""" + +from aws_durable_execution_sdk_python.execution import InvocationStatus + +from aws_durable_execution_sdk_python_testing.runner import ( + DurableFunctionTestResult, + DurableFunctionTestRunner, +) +from src import wait_with_name + + +def test_wait_with_name(): + """Test wait with explicit name.""" + with DurableFunctionTestRunner(handler=wait_with_name.handler) as runner: + result: DurableFunctionTestResult = runner.run(input="test", timeout=10) + + assert result.status is InvocationStatus.SUCCEEDED + assert result.result == "Wait with name completed" + + wait_ops = [op for op in result.operations if op.operation_type.value == "WAIT"] + assert len(wait_ops) == 1 + assert wait_ops[0].name == "custom_wait" diff --git a/pyproject.toml b/pyproject.toml index 516f7a4..ca865b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,13 +67,20 @@ cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src/aw [tool.hatch.envs.examples] dependencies = [ "boto3", + "PyYAML", "aws_durable_execution_sdk_python @ {env:AWS_DURABLE_SDK_URL:git+ssh://git@github.com/aws/aws-durable-execution-sdk-python.git}", ] [tool.hatch.envs.examples.scripts] -build = "python examples/build.py" -deploy = "cd examples && python deploy.py {args}" -sam-build = "build && sam build --template examples/template.yaml" -sam-invoke = "sam local invoke HelloWorldFunction --template examples/template.yaml" +cli = "python examples/cli.py {args}" +bootstrap = "python examples/cli.py bootstrap" +generate-sam = "python examples/cli.py sam {args}" +build = "python examples/cli.py build" +deploy = "python examples/cli.py deploy {args}" +invoke = "python examples/cli.py invoke {args}" +get = "python examples/cli.py get {args}" +history = "python examples/cli.py history {args}" +policy = "python examples/cli.py policy {args}" +list = "python examples/cli.py list" clean = "rm -rf examples/build examples/.aws-sam examples/*.zip" [tool.hatch.envs.types] @@ -133,10 +140,6 @@ lines-after-imports = 2 "SIM117", "TRY301", ] -"examples/*.py" = [ - "T201", # Allow print statements in deployment scripts - "PLR2004", # Allow magic values in deployment scripts -] "src/aws_durable_execution_sdk_python_testing/invoker.py" = [ "A002", # Argument `input` is shadowing a Python builtin ]