From 28fe39916a6a30045b12bd31ecb67ac8d865ec7c Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:01:31 -0400 Subject: [PATCH 1/6] Start of scheduler workflow --- .../eventbridge/cfn_template.yaml | 50 +++++ .../eventbridge/eventbridge_scheduler.py | 130 +++++++++++++ .../eventbridge_scheduler_workflow.py | 171 ++++++++++++++++++ .../example_code/eventbridge/requirements.txt | 2 + 4 files changed, 353 insertions(+) create mode 100644 python/example_code/eventbridge/cfn_template.yaml create mode 100644 python/example_code/eventbridge/eventbridge_scheduler.py create mode 100644 python/example_code/eventbridge/eventbridge_scheduler_workflow.py create mode 100644 python/example_code/eventbridge/requirements.txt diff --git a/python/example_code/eventbridge/cfn_template.yaml b/python/example_code/eventbridge/cfn_template.yaml new file mode 100644 index 00000000000..7cd86232891 --- /dev/null +++ b/python/example_code/eventbridge/cfn_template.yaml @@ -0,0 +1,50 @@ +Parameters: + email: + Type: String + Default: 'scheduler_test@example.com' + +Resources: + SchedulerSnsTopic: + Type: AWS::SNS::Topic + Properties: + KmsMasterKeyId: alias/aws/sns + + MySubscription: + Type: AWS::SNS::Subscription + Properties: + Endpoint: !Ref email + Protocol: email + TopicArn: !Ref SchedulerSnsTopic + + SchedulerRole: + Type: AWS::IAM::Role + Properties: + RoleName: example_scheduler_role + Path: / + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: 'Allow' + Principal: + Service: 'scheduler.amazonaws.com' + Action: + - 'sts:AssumeRole' + Policies: + - PolicyName: 'Scheduler_SNS_policy' + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: 'Allow' + Action: + - 'sns:Publish' + Resource: !Ref SchedulerSnsTopic + +Outputs: + + SNStopicARN: + Description: SNS topic ARN + Value: !Ref SchedulerSnsTopic + + RoleARN: + Description: Scheduler role ARN + Value: !GetAtt SchedulerRole.Arn diff --git a/python/example_code/eventbridge/eventbridge_scheduler.py b/python/example_code/eventbridge/eventbridge_scheduler.py new file mode 100644 index 00000000000..9432e2e1f2a --- /dev/null +++ b/python/example_code/eventbridge/eventbridge_scheduler.py @@ -0,0 +1,130 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Purpose + +Shows how to use the AWS SDK for Python (Boto3) with Amazon EventBridge Scheduler API to schedule +and receive events. +""" + +import logging +import datetime +import boto3 +from boto3 import client +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.class] +# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.decl] +class EventBridgeSchedulerWrapper: + def __init__(self, eventbridge_scheduler_client: client): + self.eventbridge_scheduler_client = eventbridge_scheduler_client + + @classmethod + def from_client(cls) -> "EventBridgeSchedulerWrapper": + """ + Creates a EventBridgeSchedulerWrapper instance with a default EventBridge client. + + :return: An instance of EventBridgeSchedulerWrapper initialized with the default EventBridge client. + """ + eventbridge_scheduler_client = boto3.client("scheduler") + return cls(eventbridge_scheduler_client) + + # snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.decl] + + # snippet-start:[python.example_code.eventbridge.CreateSchedule] + def create_schedule( + self, + name: str, + schedule_expression: str, + schedule_group_name: str, + target_arn: str, + role_arn: str, + input: str, + delete_after_completion: bool = False, + use_flexible_time_window: bool = false, + ): + """ + Creates a new schedule with the specified parameters. + + :param name: The name of the schedule. + :param schedule_expression: The expression that defines when the schedule runs. + :param schedule_group_name: The name of the schedule group. + :param target_arn: The Amazon Resource Name (ARN) of the target. + :param role_arn: The Amazon Resource Name (ARN) of the execution IAM role. + :param input: The input for the target. + :param delete_after_completion: Whether to delete the schedule after it completes. + :param use_flexible_time_window: Whether to use a flexible time window. + """ + try: + hours_to_run = 1 + flexible_time_window_minutes = 10 + self.eventbridge_scheduler_client.create_schedule( + Name=name, + ScheduleExpression=schedule_expression, + GroupName=schedule_group_name, + Target={"Arn": target_arn, "RoleArn": role_arn, "Input": input}, + ActionAfterCompletion="DELETE" if delete_after_completion else "None", + FlexibleTimeWindow={ + "Mode": "FLEXIBLE" if use_flexible_time_window else "OFF", + "MaximumWindowInMinutes": flexible_time_window_minutes + if use_flexible_time_window + else None, + }, + StartDate=datetime.today(), + EndDate=datetime.today() + datetime.timedelta(hours=hours_to_run), + ) + except ClientError as e: + logger.error("Error creating schedule: %s", e.response["Error"]["Message"]) + raise + + # snippet-start:[python.example_code.eventbridge.CreateScheduleGroup] + def create_schedule_group(self, name: str): + """ + Creates a new schedule group with the specified name and description. + + :param name: The name of the schedule group. + :param description: The description of the schedule group. + """ + try: + self.eventbridge_scheduler_client.create_schedule_group(Name=name) + logger.info("Schedule group %s created successfully.", name) + except ClientError as e: + logger.error( + "Error creating schedule group: %s", e.response["Error"]["Message"] + ) + raise + + # snippet-end:[python.example_code.eventbridge.CreateScheduleGroup] + + # snippet-start:[python.example_code.eventbridge.DeleteScheduleGroup] + def delete_schedule_group(self, name: str): + """ + Deletes the schedule group with the specified name. + + :param name: The name of the schedule group. + """ + try: + self.eventbridge_scheduler_client.delete_schedule_group(Name=name) + logger.info("Schedule group %s deleted successfully.", name) + except ClientError as e: + logger.error( + "Error deleting schedule group: %s", e.response["Error"]["Message"] + ) + raise + # snippet-end:[python.example_code.eventbridge.DeleteScheduleGroup] + + +# snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.class] + +if __name__ == "__main__": + try: + eventbridge = EventBridgeSchedulerWrapper.from_client() + except Exception: + logging.exception("Something went wrong with the demo!") diff --git a/python/example_code/eventbridge/eventbridge_scheduler_workflow.py b/python/example_code/eventbridge/eventbridge_scheduler_workflow.py new file mode 100644 index 00000000000..85ef87df59b --- /dev/null +++ b/python/example_code/eventbridge/eventbridge_scheduler_workflow.py @@ -0,0 +1,171 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Purpose + +Shows how to use the AWS SDK for Python (Boto3) with the Amazon EventBridge Scheduler to schedule +and receive events. +""" + +import logging +import sys +from eventbridge_scheduler import EventBridgeSchedulerWrapper +from boto3 import client +from botocore.exceptions import ClientError +from boto3.resources.base import ServiceResource +import os + +import boto3 + +# Add relative path to include demo_tools in this code example without need for setup. +sys.path.append("../..") +import demo_tools.question as q + +DASHES = "-" * 80 + +logger = logging.getLogger(__name__) + +class EventBridgeSchedulerWorkflow: + """ + A scenario that demonstrates how to use Boto3 to schedule and receive events using + the Amazon EventBridge Scheduler. + """ + def __init__(self, eventbridge_scheduler : EventBridgeSchedulerWrapper, cloud_formation_client : ServiceResource): + self.eventbridge_scheduler = eventbridge_scheduler + self.cloud_formation_resource = cloud_formation_client + self.stack : ServiceResource = None + self.schedule_group_name = "workflow-schedules-group" + self.schedule_group_created = False + + def run(self) -> None: + """ + Runs the scenario. + """ + print(DASHES) + + print("Welcome to the Amazon EventBridge Scheduler Workflow."); + print(DASHES) + print(DASHES) + + self.prepare_application() + + print(DASHES) + print(DASHES) + + self.cleanup() + + def prepare_application(self): + """ + Prepares the application by prompting the user setup information, deploying a CloudFormation stack and + creating a schedule group. + """ + print("Preparing the application..."); + print("\nThis example creates resources in a CloudFormation stack, including an SNS topic" + + "\nthat will be subscribed to the EventBridge Scheduler events. " + + "\n\nYou will need to confirm the subscription in order to receive event emails. ") + + email_address = "meyertst@amazon.com" # q.ask("Enter an email address to use for event subscriptions: ") + stack_name = "python-test" # q.ask("Enter a name for the AWS Cloud Formation Stack: ") + + script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) + template_file = os.path.join(script_directory, "cfn_template.yaml") + + parameters = [ + { + 'ParameterKey': 'email', + 'ParameterValue': email_address + } + ] + + self.stack = self.deploy_cloudformation_stack(stack_name, template_file, parameters) + + self.eventbridge_scheduler.create_schedule_group(self.schedule_group_name) + self.schedule_group_created = True + + def create_one_time_schedule(self): + """ + Creates a one-time schedule to send an initial event. + """ + schedule_name = q.ask("Enter a name for the one-time schedule:"); + + print(f"Creating a one-time schedule named '{schedule_name}' " + + f"\nto send an initial event in 1 minute with a flexible time window..."); + + var + createSuccess = await _schedulerWrapper.CreateScheduleAsync( + + print(f"Subscription email will receive an email from this event."); + print(f"You must confirm your subscription to receive event emails."); + print(f"One-time schedule '{schedule_name}' created successfully."); + + +def deploy_cloudformation_stack(self, stack_name :str, template_file : str, parameters :[dict[str, str]]) -> ServiceResource: + """ + Deploys prerequisite resources used by the scenario. The resources are + defined in the associated `setup.yaml` AWS CloudFormation script and are deployed + as a CloudFormation stack, so they can be easily managed and destroyed. + """ + + + with open( + template_file + ) as setup_file: + setup_template = setup_file.read() + print(f"Creating stack {stack_name}.") + stack = self.cloud_formation_resource.create_stack( + StackName=stack_name, + TemplateBody=setup_template, + Capabilities=["CAPABILITY_NAMED_IAM"], + Parameters=parameters, + ) + print("\t\tWaiting for stack to deploy. This typically takes a minute or two.") + waiter = self.cloud_formation_resource.meta.client.get_waiter("stack_create_complete") + waiter.wait(StackName=stack.name) + stack.load() + print(f"\t\tStack status: {stack.stack_status}") + + return stack + + def destroy_cloudformation_stack(self, stack : ServiceResource) -> None: + """ + Destroys the resources managed by the CloudFormation stack, and the CloudFormation + stack itself. + + :param stack: The CloudFormation stack that manages the example resources. + """ + print(f"\t\tDeleting {stack.name}.") + stack.delete() + print("\t\tWaiting for stack removal. This may take a few minutes.") + waiter = self.cloud_formation_resource.meta.client.get_waiter("stack_delete_complete") + waiter.wait(StackName=stack.name) + print("\t\tStack delete complete.") + + def cleanup(self): + """ + Deletes the CloudFormation stack and the resources created for the demo. + """ + print("\nCleaning up resources...") + if self.schedule_group_created: + self.schedule_group_created = False + self.eventbridge_scheduler.delete_schedule_group(self.schedule_group_name) + + + if self.stack is not None: + stack = self.stack + self.stack = None + self.destroy_cloudformation_stack(stack) + print("\t\tStack deleted, demo complete.") + +if __name__ == "__main__": + eventbridge_wrapper : EventBridgeSchedulerWrapper = None + try: + eventbridge_wrapper = EventBridgeSchedulerWrapper.from_client() + cloud_formation_client = boto3.resource("cloudformation") + demo = EventBridgeSchedulerWorkflow(eventbridge_wrapper, cloud_formation_client) + demo.run() + + except Exception: + if eventbridge_wrapper is not None: + eventbridge_wrapper.cleanup() + logging.exception("Something went wrong with the demo!") diff --git a/python/example_code/eventbridge/requirements.txt b/python/example_code/eventbridge/requirements.txt new file mode 100644 index 00000000000..621e276912d --- /dev/null +++ b/python/example_code/eventbridge/requirements.txt @@ -0,0 +1,2 @@ +boto3>=1.26.79 +pytest>=7.2.1 From 4f484b107d6947a7f325b240def65a46feb508d9 Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:28:59 -0400 Subject: [PATCH 2/6] implementation complete --- .../eventbridge/eventbridge_scheduler.py | 130 --------- .../eventbridge_scheduler_workflow.py | 171 ------------ .../example_code/eventbridge/requirements.txt | 2 - .../cfn_template.yaml | 0 .../example_code/scheduler/hello_scheduler.py | 0 .../example_code/scheduler/requirements.txt | 3 + .../scheduler/scheduler_scenario.py | 255 ++++++++++++++++++ .../scheduler/scheduler_wrapper.py | 183 +++++++++++++ 8 files changed, 441 insertions(+), 303 deletions(-) delete mode 100644 python/example_code/eventbridge/eventbridge_scheduler.py delete mode 100644 python/example_code/eventbridge/eventbridge_scheduler_workflow.py delete mode 100644 python/example_code/eventbridge/requirements.txt rename python/example_code/{eventbridge => scheduler}/cfn_template.yaml (100%) create mode 100644 python/example_code/scheduler/hello_scheduler.py create mode 100644 python/example_code/scheduler/requirements.txt create mode 100644 python/example_code/scheduler/scheduler_scenario.py create mode 100644 python/example_code/scheduler/scheduler_wrapper.py diff --git a/python/example_code/eventbridge/eventbridge_scheduler.py b/python/example_code/eventbridge/eventbridge_scheduler.py deleted file mode 100644 index 9432e2e1f2a..00000000000 --- a/python/example_code/eventbridge/eventbridge_scheduler.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - -""" -Purpose - -Shows how to use the AWS SDK for Python (Boto3) with Amazon EventBridge Scheduler API to schedule -and receive events. -""" - -import logging -import datetime -import boto3 -from boto3 import client -from botocore.exceptions import ClientError - -logger = logging.getLogger(__name__) - - -# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.class] -# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.decl] -class EventBridgeSchedulerWrapper: - def __init__(self, eventbridge_scheduler_client: client): - self.eventbridge_scheduler_client = eventbridge_scheduler_client - - @classmethod - def from_client(cls) -> "EventBridgeSchedulerWrapper": - """ - Creates a EventBridgeSchedulerWrapper instance with a default EventBridge client. - - :return: An instance of EventBridgeSchedulerWrapper initialized with the default EventBridge client. - """ - eventbridge_scheduler_client = boto3.client("scheduler") - return cls(eventbridge_scheduler_client) - - # snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.decl] - - # snippet-start:[python.example_code.eventbridge.CreateSchedule] - def create_schedule( - self, - name: str, - schedule_expression: str, - schedule_group_name: str, - target_arn: str, - role_arn: str, - input: str, - delete_after_completion: bool = False, - use_flexible_time_window: bool = false, - ): - """ - Creates a new schedule with the specified parameters. - - :param name: The name of the schedule. - :param schedule_expression: The expression that defines when the schedule runs. - :param schedule_group_name: The name of the schedule group. - :param target_arn: The Amazon Resource Name (ARN) of the target. - :param role_arn: The Amazon Resource Name (ARN) of the execution IAM role. - :param input: The input for the target. - :param delete_after_completion: Whether to delete the schedule after it completes. - :param use_flexible_time_window: Whether to use a flexible time window. - """ - try: - hours_to_run = 1 - flexible_time_window_minutes = 10 - self.eventbridge_scheduler_client.create_schedule( - Name=name, - ScheduleExpression=schedule_expression, - GroupName=schedule_group_name, - Target={"Arn": target_arn, "RoleArn": role_arn, "Input": input}, - ActionAfterCompletion="DELETE" if delete_after_completion else "None", - FlexibleTimeWindow={ - "Mode": "FLEXIBLE" if use_flexible_time_window else "OFF", - "MaximumWindowInMinutes": flexible_time_window_minutes - if use_flexible_time_window - else None, - }, - StartDate=datetime.today(), - EndDate=datetime.today() + datetime.timedelta(hours=hours_to_run), - ) - except ClientError as e: - logger.error("Error creating schedule: %s", e.response["Error"]["Message"]) - raise - - # snippet-start:[python.example_code.eventbridge.CreateScheduleGroup] - def create_schedule_group(self, name: str): - """ - Creates a new schedule group with the specified name and description. - - :param name: The name of the schedule group. - :param description: The description of the schedule group. - """ - try: - self.eventbridge_scheduler_client.create_schedule_group(Name=name) - logger.info("Schedule group %s created successfully.", name) - except ClientError as e: - logger.error( - "Error creating schedule group: %s", e.response["Error"]["Message"] - ) - raise - - # snippet-end:[python.example_code.eventbridge.CreateScheduleGroup] - - # snippet-start:[python.example_code.eventbridge.DeleteScheduleGroup] - def delete_schedule_group(self, name: str): - """ - Deletes the schedule group with the specified name. - - :param name: The name of the schedule group. - """ - try: - self.eventbridge_scheduler_client.delete_schedule_group(Name=name) - logger.info("Schedule group %s deleted successfully.", name) - except ClientError as e: - logger.error( - "Error deleting schedule group: %s", e.response["Error"]["Message"] - ) - raise - # snippet-end:[python.example_code.eventbridge.DeleteScheduleGroup] - - -# snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.class] - -if __name__ == "__main__": - try: - eventbridge = EventBridgeSchedulerWrapper.from_client() - except Exception: - logging.exception("Something went wrong with the demo!") diff --git a/python/example_code/eventbridge/eventbridge_scheduler_workflow.py b/python/example_code/eventbridge/eventbridge_scheduler_workflow.py deleted file mode 100644 index 85ef87df59b..00000000000 --- a/python/example_code/eventbridge/eventbridge_scheduler_workflow.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - -""" -Purpose - -Shows how to use the AWS SDK for Python (Boto3) with the Amazon EventBridge Scheduler to schedule -and receive events. -""" - -import logging -import sys -from eventbridge_scheduler import EventBridgeSchedulerWrapper -from boto3 import client -from botocore.exceptions import ClientError -from boto3.resources.base import ServiceResource -import os - -import boto3 - -# Add relative path to include demo_tools in this code example without need for setup. -sys.path.append("../..") -import demo_tools.question as q - -DASHES = "-" * 80 - -logger = logging.getLogger(__name__) - -class EventBridgeSchedulerWorkflow: - """ - A scenario that demonstrates how to use Boto3 to schedule and receive events using - the Amazon EventBridge Scheduler. - """ - def __init__(self, eventbridge_scheduler : EventBridgeSchedulerWrapper, cloud_formation_client : ServiceResource): - self.eventbridge_scheduler = eventbridge_scheduler - self.cloud_formation_resource = cloud_formation_client - self.stack : ServiceResource = None - self.schedule_group_name = "workflow-schedules-group" - self.schedule_group_created = False - - def run(self) -> None: - """ - Runs the scenario. - """ - print(DASHES) - - print("Welcome to the Amazon EventBridge Scheduler Workflow."); - print(DASHES) - print(DASHES) - - self.prepare_application() - - print(DASHES) - print(DASHES) - - self.cleanup() - - def prepare_application(self): - """ - Prepares the application by prompting the user setup information, deploying a CloudFormation stack and - creating a schedule group. - """ - print("Preparing the application..."); - print("\nThis example creates resources in a CloudFormation stack, including an SNS topic" + - "\nthat will be subscribed to the EventBridge Scheduler events. " + - "\n\nYou will need to confirm the subscription in order to receive event emails. ") - - email_address = "meyertst@amazon.com" # q.ask("Enter an email address to use for event subscriptions: ") - stack_name = "python-test" # q.ask("Enter a name for the AWS Cloud Formation Stack: ") - - script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) - template_file = os.path.join(script_directory, "cfn_template.yaml") - - parameters = [ - { - 'ParameterKey': 'email', - 'ParameterValue': email_address - } - ] - - self.stack = self.deploy_cloudformation_stack(stack_name, template_file, parameters) - - self.eventbridge_scheduler.create_schedule_group(self.schedule_group_name) - self.schedule_group_created = True - - def create_one_time_schedule(self): - """ - Creates a one-time schedule to send an initial event. - """ - schedule_name = q.ask("Enter a name for the one-time schedule:"); - - print(f"Creating a one-time schedule named '{schedule_name}' " + - f"\nto send an initial event in 1 minute with a flexible time window..."); - - var - createSuccess = await _schedulerWrapper.CreateScheduleAsync( - - print(f"Subscription email will receive an email from this event."); - print(f"You must confirm your subscription to receive event emails."); - print(f"One-time schedule '{schedule_name}' created successfully."); - - -def deploy_cloudformation_stack(self, stack_name :str, template_file : str, parameters :[dict[str, str]]) -> ServiceResource: - """ - Deploys prerequisite resources used by the scenario. The resources are - defined in the associated `setup.yaml` AWS CloudFormation script and are deployed - as a CloudFormation stack, so they can be easily managed and destroyed. - """ - - - with open( - template_file - ) as setup_file: - setup_template = setup_file.read() - print(f"Creating stack {stack_name}.") - stack = self.cloud_formation_resource.create_stack( - StackName=stack_name, - TemplateBody=setup_template, - Capabilities=["CAPABILITY_NAMED_IAM"], - Parameters=parameters, - ) - print("\t\tWaiting for stack to deploy. This typically takes a minute or two.") - waiter = self.cloud_formation_resource.meta.client.get_waiter("stack_create_complete") - waiter.wait(StackName=stack.name) - stack.load() - print(f"\t\tStack status: {stack.stack_status}") - - return stack - - def destroy_cloudformation_stack(self, stack : ServiceResource) -> None: - """ - Destroys the resources managed by the CloudFormation stack, and the CloudFormation - stack itself. - - :param stack: The CloudFormation stack that manages the example resources. - """ - print(f"\t\tDeleting {stack.name}.") - stack.delete() - print("\t\tWaiting for stack removal. This may take a few minutes.") - waiter = self.cloud_formation_resource.meta.client.get_waiter("stack_delete_complete") - waiter.wait(StackName=stack.name) - print("\t\tStack delete complete.") - - def cleanup(self): - """ - Deletes the CloudFormation stack and the resources created for the demo. - """ - print("\nCleaning up resources...") - if self.schedule_group_created: - self.schedule_group_created = False - self.eventbridge_scheduler.delete_schedule_group(self.schedule_group_name) - - - if self.stack is not None: - stack = self.stack - self.stack = None - self.destroy_cloudformation_stack(stack) - print("\t\tStack deleted, demo complete.") - -if __name__ == "__main__": - eventbridge_wrapper : EventBridgeSchedulerWrapper = None - try: - eventbridge_wrapper = EventBridgeSchedulerWrapper.from_client() - cloud_formation_client = boto3.resource("cloudformation") - demo = EventBridgeSchedulerWorkflow(eventbridge_wrapper, cloud_formation_client) - demo.run() - - except Exception: - if eventbridge_wrapper is not None: - eventbridge_wrapper.cleanup() - logging.exception("Something went wrong with the demo!") diff --git a/python/example_code/eventbridge/requirements.txt b/python/example_code/eventbridge/requirements.txt deleted file mode 100644 index 621e276912d..00000000000 --- a/python/example_code/eventbridge/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -boto3>=1.26.79 -pytest>=7.2.1 diff --git a/python/example_code/eventbridge/cfn_template.yaml b/python/example_code/scheduler/cfn_template.yaml similarity index 100% rename from python/example_code/eventbridge/cfn_template.yaml rename to python/example_code/scheduler/cfn_template.yaml diff --git a/python/example_code/scheduler/hello_scheduler.py b/python/example_code/scheduler/hello_scheduler.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/example_code/scheduler/requirements.txt b/python/example_code/scheduler/requirements.txt new file mode 100644 index 00000000000..b2f85e5bc24 --- /dev/null +++ b/python/example_code/scheduler/requirements.txt @@ -0,0 +1,3 @@ +boto3>=1.35.38 +pytest>=8.3.3 +botocore>=1.35.38 \ No newline at end of file diff --git a/python/example_code/scheduler/scheduler_scenario.py b/python/example_code/scheduler/scheduler_scenario.py new file mode 100644 index 00000000000..668ce2ecb56 --- /dev/null +++ b/python/example_code/scheduler/scheduler_scenario.py @@ -0,0 +1,255 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Purpose + +Shows how to use the AWS SDK for Python (Boto3) with the Amazon EventBridge Scheduler to schedule +and receive events. +""" + +import logging +import sys +from datetime import datetime, timedelta, timezone +from scheduler_wrapper import SchedulerWrapper +from boto3 import client +from botocore.exceptions import ClientError +from boto3.resources.base import ServiceResource +import os + +import boto3 + +# Add relative path to include demo_tools in this code example without need for setup. +sys.path.append("../..") +import demo_tools.question as q + +DASHES = "-" * 80 + +logger = logging.getLogger(__name__) + + +class SchedulerScenario: + """ + A scenario that demonstrates how to use Boto3 to schedule and receive events using + the Amazon EventBridge Scheduler. + """ + + def __init__( + self, + scheduler_wrapper: SchedulerWrapper, + cloud_formation_resource: ServiceResource, + ): + self.eventbridge_scheduler = scheduler_wrapper + self.cloud_formation_resource = cloud_formation_resource + self.stack: ServiceResource = None + self.schedule_group_name = "workflow-schedules-group" + self.sns_topic_arn = None + self.role_arn = None + + def run(self) -> None: + """ + Runs the scenario. + """ + + print(DASHES) + print("Welcome to the Amazon EventBridge Scheduler Workflow.") + print(DASHES) + + print(DASHES) + self.prepare_application() + print(DASHES) + + print(DASHES) + self.create_one_time_schedule() + print(DASHES) + + print(DASHES) + self.create_recurring_schedule() + print(DASHES) + + print(DASHES) + if q.ask("Do you want to delete all resources created by this workflow? (y/n) ", q.is_yesno): + self.cleanup() + print(DASHES) + + print("Amazon EventBridge Scheduler workflow completed.") + + def prepare_application(self) -> None: + """ + Prepares the application by prompting the user setup information, deploying a CloudFormation stack and + creating a schedule group. + """ + print("Preparing the application...") + print( + "\nThis example creates resources in a CloudFormation stack, including an SNS topic" + + "\nthat will be subscribed to the EventBridge Scheduler events. " + + "\n\nYou will need to confirm the subscription in order to receive event emails. " + ) + + email_address = "meyertst@amazon.com" # q.ask("Enter an email address to use for event subscriptions: ") + stack_name = ( + "python-test" # q.ask("Enter a name for the AWS Cloud Formation Stack: ") + ) + + script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) + template_file = os.path.join(script_directory, "cfn_template.yaml") + + parameters = [{"ParameterKey": "email", "ParameterValue": email_address}] + + self.stack = self.deploy_cloudformation_stack( + stack_name, template_file, parameters + ) + outputs = self.stack.outputs + for output in outputs: + if output.get("OutputKey") == "RoleARN": + self.role_arn = output.get("OutputValue") + elif output.get("OutputKey") == "SNStopicARN": + self.sns_topic_arn = output.get("OutputValue") + + if not self.sns_topic_arn or not self.role_arn: + error_string = f""" + Failed to retrieve required outputs from CloudFormation stack. + 'sns_topic_arn'={self.sns_topic_arn}, 'role_arn'={self.role_arn} + """ + logger.error(error_string) + raise ValueError(error_string) + + print(f"Stack output RoleARN: {self.role_arn}") + print(f"Stack output SNStopicARN: a") + schedule_group_name = "workflow-schedules-group" + schedule_group_arn = self.eventbridge_scheduler.create_schedule_group(schedule_group_name) + print(f"Successfully created schedule group '{self.schedule_group_name}': {schedule_group_arn}.") + self.schedule_group_name = schedule_group_name + print("Application preparation complete.") + + def create_one_time_schedule(self) -> None: + """ + Creates a one-time schedule to send an initial event. + """ + + scheduled_time = datetime.now(timezone.utc) + timedelta(minutes=1) + formatted_scheduled_time = scheduled_time.strftime("%Y-%m-%dT%H:%M:%S") + + schedule_name = q.ask("Enter a name for the one-time schedule:") + + print( + f"Creating a one-time schedule named '{schedule_name}' " + + f"\nto send an initial event in 1 minute with a flexible time window..." + ) + + schedule_arn = self.eventbridge_scheduler.create_schedule( + schedule_name, + f"at({formatted_scheduled_time})", + self.schedule_group_name, + self.sns_topic_arn, + self.role_arn, + f"One time scheduled event test from schedule {schedule_name}.", + delete_after_completion=True, + use_flexible_time_window=True, + ) + print(f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}.") + print(f"Subscription email will receive an email from this event.") + print(f"You must confirm your subscription to receive event emails.") + print(f"One-time schedule '{schedule_name}' created successfully.") + + + def create_recurring_schedule(self) -> None: + """ + Create a recurring schedule to send events at a specified rate in minutes. + """ + + print("Creating a recurring schedule to send events for one hour..."); + schedule_name = q.ask("Enter a name for the recurring schedule: "); + schedule_rate_in_minutes = q.ask("Enter the desired schedule rate (in minutes): ", q.is_int); + + schedule_arn = self.eventbridge_scheduler.create_schedule( + schedule_name, + f"rate({schedule_rate_in_minutes} minutes)", + self.schedule_group_name, + self.sns_topic_arn, + self.role_arn, + f"Recurrent event test from schedule {schedule_name}.", + ) + + print(f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}.") + print(f"Subscription email will receive an email from this event."); + print(f"You must confirm your subscription to receive event emails."); + + if q.ask(f"Are you ready to delete the '{schedule_name}' schedule? (y/n)", q.is_yesno) : + self.eventbridge_scheduler.delete_schedule(schedule_name, self.schedule_group_name) + + def deploy_cloudformation_stack( + self, stack_name: str, template_file: str, parameters: [dict[str, str]] + ) -> ServiceResource: + """ + Deploys prerequisite resources used by the scenario. The resources are + defined in the associated `cfn_template.yaml` AWS CloudFormation script and are deployed + as a CloudFormation stack, so they can be easily managed and destroyed. + """ + + with open(template_file) as setup_file: + setup_template = setup_file.read() + print(f"Deploying CloudFormation stack: {stack_name}.") + stack = self.cloud_formation_resource.create_stack( + StackName=stack_name, + TemplateBody=setup_template, + Capabilities=["CAPABILITY_NAMED_IAM"], + Parameters=parameters, + ) + print(f"CloudFormation stack creation started: {stack_name}") + print("Waiting for CloudFormation stack creation to complete...") + waiter = self.cloud_formation_resource.meta.client.get_waiter( + "stack_create_complete" + ) + waiter.wait(StackName=stack.name) + stack.load() + print("CloudFormation stack creation complete.") + + return stack + + def destroy_cloudformation_stack(self, stack: ServiceResource) -> None: + """ + Destroys the resources managed by the CloudFormation stack, and the CloudFormation + stack itself. + + :param stack: The CloudFormation stack that manages the example resources. + """ + print(f"CloudFormation stack '{stack.name}' is being deleted. This may take a few minutes.") + stack.delete() + waiter = self.cloud_formation_resource.meta.client.get_waiter( + "stack_delete_complete" + ) + waiter.wait(StackName=stack.name) + print(f"CloudFormation stack '{stack.name}' has been deleted.") + + def cleanup(self) -> None: + """ + Deletes the CloudFormation stack and the resources created for the demo. + """ + + if self.schedule_group_name: + schedule_group_name = self.schedule_group_name + self.schedule_group_name = None + self.eventbridge_scheduler.delete_schedule_group(schedule_group_name) + print(f"Successfully deleted schedule group '{schedule_group_name}'.") + + if self.stack is not None: + stack = self.stack + self.stack = None + self.destroy_cloudformation_stack(stack) + print("Stack deleted, demo complete.") + + +if __name__ == "__main__": + demo: SchedulerScenario = None + try: + scheduler_wrapper = SchedulerWrapper.from_client() + cloud_formation_resource = boto3.resource("cloudformation") + demo = SchedulerScenario(scheduler_wrapper, cloud_formation_resource) + demo.run() + + except Exception as exception: + logging.exception("Something went wrong with the demo!") + if demo is not None: + demo.cleanup() + diff --git a/python/example_code/scheduler/scheduler_wrapper.py b/python/example_code/scheduler/scheduler_wrapper.py new file mode 100644 index 00000000000..fe42b2478b4 --- /dev/null +++ b/python/example_code/scheduler/scheduler_wrapper.py @@ -0,0 +1,183 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Purpose + +Shows how to use the AWS SDK for Python (Boto3) with Amazon EventBridge Scheduler API to schedule +and receive events. +""" + +import logging +from datetime import datetime, timedelta, timezone +import boto3 +from boto3 import client +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.class] +# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.decl] +class SchedulerWrapper: + def __init__(self, eventbridge_scheduler_client: client): + self.scheduler_client = eventbridge_scheduler_client + + @classmethod + def from_client(cls) -> "SchedulerWrapper": + """ + Creates a SchedulerWrapper instance with a default EventBridge client. + + :return: An instance of SchedulerWrapper initialized with the default EventBridge client. + """ + eventbridge_scheduler_client = boto3.client("scheduler") + return cls(eventbridge_scheduler_client) + + # snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.decl] + + # snippet-start:[python.example_code.eventbridge.CreateSchedule] + def create_schedule( + self, + name: str, + schedule_expression: str, + schedule_group_name: str, + target_arn: str, + role_arn: str, + input: str, + delete_after_completion: bool = False, + use_flexible_time_window: bool = False, + ) -> str: + """ + Creates a new schedule with the specified parameters. + + :param name: The name of the schedule. + :param schedule_expression: The expression that defines when the schedule runs. + :param schedule_group_name: The name of the schedule group. + :param target_arn: The Amazon Resource Name (ARN) of the target. + :param role_arn: The Amazon Resource Name (ARN) of the execution IAM role. + :param input: The input for the target. + :param delete_after_completion: Whether to delete the schedule after it completes. + :param use_flexible_time_window: Whether to use a flexible time window. + + :return The ARN of the created schedule. + """ + try: + hours_to_run = 1 + flexible_time_window_minutes = 10 + parameters = { + "Name": name, + "ScheduleExpression": schedule_expression, + "GroupName": schedule_group_name, + "Target": {"Arn": target_arn, "RoleArn": role_arn, "Input": input}, + "StartDate": datetime.now(timezone.utc), + "EndDate": datetime.now(timezone.utc) + timedelta(hours=hours_to_run), + } + + if delete_after_completion: + parameters["ActionAfterCompletion"] = "DELETE" + + if use_flexible_time_window: + parameters["FlexibleTimeWindow"] = { + "Mode": "FLEXIBLE", + "MaximumWindowInMinutes": flexible_time_window_minutes, + } + else: + parameters["FlexibleTimeWindow"] = {"Mode": "OFF"} + + response = self.scheduler_client.create_schedule(**parameters) + return response["ScheduleArn"] + except ClientError as err: + if err.response["Error"]["Code"] == "ConflictException": + logger.error( + "Failed to create schedule '%s' due to a conflict. %s", + name, + err.response["Error"]["Message"], + ) + else: + logger.error("Error creating schedule: %s", err.response["Error"]["Message"]) + raise + + # snippet-start:[python.example_code.eventbridge.DeleteSchedule] + def delete_schedule(self, name: str, schedule_group_name: str) -> None: + """ + Deletes the schedule with the specified name and schedule group. + + :param name: The name of the schedule. + :param schedule_group_name: The name of the schedule group. + """ + try: + self.scheduler_client.delete_schedule( + Name=name, GroupName=schedule_group_name + ) + except ClientError as err: + if err.response["Error"]["Code"] == "ResourceNotFoundException": + logger.error( + "Failed to delete schedule with ID '%s' because the resource was not found: %s", + name, + err.response["Error"]["Message"], + ) + else: + logger.error("Error deleting schedule: %s", err.response["Error"]["Message"]) + raise + + # snippet-end:[python.example_code.eventbridge.DeleteSchedule] + + # snippet-start:[python.example_code.eventbridge.CreateScheduleGroup] + def create_schedule_group(self, name: str) -> str: + """ + Creates a new schedule group with the specified name and description. + + :param name: The name of the schedule group. + :param description: The description of the schedule group. + + :return: The ARN of the created schedule group. + """ + try: + response = self.scheduler_client.create_schedule_group(Name=name) + return response["ScheduleGroupArn"] + except ClientError as err: + if err.response["Error"]["Code"] == "ConflictException": + logger.error( + "Failed to create schedule group '%s' due to a conflict. %s", + name, + err.response["Error"]["Message"], + ) + else: + logger.error("Error creating schedule group: %s", err.response["Error"]["Message"]) + raise + + # snippet-end:[python.example_code.eventbridge.CreateScheduleGroup] + + # snippet-start:[python.example_code.eventbridge.DeleteScheduleGroup] + def delete_schedule_group(self, name: str) -> None: + """ + Deletes the schedule group with the specified name. + + :param name: The name of the schedule group. + """ + try: + self.scheduler_client.delete_schedule_group(Name=name) + logger.info("Schedule group %s deleted successfully.", name) + except ClientError as err: + if err.response["Error"]["Code"] == "ResourceNotFoundException": + logger.error( + "Failed to delete schedule group with ID '%s' because the resource was not found: %s", + name, + err.response["Error"]["Message"], + ) + else: + logger.error("Error deleting schedule group: %s", err.response["Error"]["Message"]) + raise + # snippet-end:[python.example_code.eventbridge.DeleteScheduleGroup] + + +# snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.class] + +if __name__ == "__main__": + try: + eventbridge = SchedulerWrapper.from_client() + except Exception: + logging.exception("Something went wrong with the demo!") From f1aebbdec814223532923fb4087eb8f90d8aa442 Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:09:17 -0400 Subject: [PATCH 3/6] tests working --- .../scheduler/scheduler_scenario.py | 35 +++-- .../example_code/scheduler/test/conftest.py | 43 ++++++ .../scheduler/test/test_scenario_cleanup.py | 59 ++++++++ .../test_scenario_create_one_time_schedule.py | 64 ++++++++ ...test_scenario_create_recurring_schedule.py | 66 +++++++++ .../test/test_scenario_prepare_application.py | 80 ++++++++++ python/test_tools/cloudformation_stubber.py | 4 +- python/test_tools/scheduler_stubber.py | 137 ++++++++++++++++++ python/test_tools/stubber_factory.py | 3 + 9 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 python/example_code/scheduler/test/conftest.py create mode 100644 python/example_code/scheduler/test/test_scenario_cleanup.py create mode 100644 python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py create mode 100644 python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py create mode 100644 python/example_code/scheduler/test/test_scenario_prepare_application.py create mode 100644 python/test_tools/scheduler_stubber.py diff --git a/python/example_code/scheduler/scheduler_scenario.py b/python/example_code/scheduler/scheduler_scenario.py index 668ce2ecb56..02116c5f813 100644 --- a/python/example_code/scheduler/scheduler_scenario.py +++ b/python/example_code/scheduler/scheduler_scenario.py @@ -42,7 +42,7 @@ def __init__( self.eventbridge_scheduler = scheduler_wrapper self.cloud_formation_resource = cloud_formation_resource self.stack: ServiceResource = None - self.schedule_group_name = "workflow-schedules-group" + self.schedule_group_name = None self.sns_topic_arn = None self.role_arn = None @@ -86,13 +86,11 @@ def prepare_application(self) -> None: + "\n\nYou will need to confirm the subscription in order to receive event emails. " ) - email_address = "meyertst@amazon.com" # q.ask("Enter an email address to use for event subscriptions: ") - stack_name = ( - "python-test" # q.ask("Enter a name for the AWS Cloud Formation Stack: ") - ) + email_address = q.ask("Enter an email address to use for event subscriptions: ") + stack_name = q.ask("Enter a name for the AWS Cloud Formation Stack: ") script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) - template_file = os.path.join(script_directory, "cfn_template.yaml") + template_file = SchedulerScenario.get_template_as_string() parameters = [{"ParameterKey": "email", "ParameterValue": email_address}] @@ -126,12 +124,11 @@ def create_one_time_schedule(self) -> None: """ Creates a one-time schedule to send an initial event. """ + schedule_name = q.ask("Enter a name for the one-time schedule:") scheduled_time = datetime.now(timezone.utc) + timedelta(minutes=1) formatted_scheduled_time = scheduled_time.strftime("%Y-%m-%dT%H:%M:%S") - schedule_name = q.ask("Enter a name for the one-time schedule:") - print( f"Creating a one-time schedule named '{schedule_name}' " + f"\nto send an initial event in 1 minute with a flexible time window..." @@ -179,20 +176,22 @@ def create_recurring_schedule(self) -> None: self.eventbridge_scheduler.delete_schedule(schedule_name, self.schedule_group_name) def deploy_cloudformation_stack( - self, stack_name: str, template_file: str, parameters: [dict[str, str]] + self, stack_name: str, cfn_template: str, parameters: [dict[str, str]] ) -> ServiceResource: """ Deploys prerequisite resources used by the scenario. The resources are defined in the associated `cfn_template.yaml` AWS CloudFormation script and are deployed as a CloudFormation stack, so they can be easily managed and destroyed. - """ - with open(template_file) as setup_file: - setup_template = setup_file.read() + :param stack_name: The name of the CloudFormation stack. + :param cfn_template: The CloudFormation template as a string. + :param parameters: The parameters for the CloudFormation stack. + :return: The CloudFormation stack resource. + """ print(f"Deploying CloudFormation stack: {stack_name}.") stack = self.cloud_formation_resource.create_stack( StackName=stack_name, - TemplateBody=setup_template, + TemplateBody=cfn_template, Capabilities=["CAPABILITY_NAMED_IAM"], Parameters=parameters, ) @@ -239,6 +238,16 @@ def cleanup(self) -> None: self.destroy_cloudformation_stack(stack) print("Stack deleted, demo complete.") + @staticmethod + def get_template_as_string() -> str: + """ + Returns a string containing this scenario's CloudFormation template. + """ + script_directory = os.path.dirname(os.path.abspath(__file__)) + template_file_path = os.path.join(script_directory, "cfn_template.yaml") + file = open(template_file_path, "r") + return file.read() + if __name__ == "__main__": demo: SchedulerScenario = None diff --git a/python/example_code/scheduler/test/conftest.py b/python/example_code/scheduler/test/conftest.py new file mode 100644 index 00000000000..7be9da58856 --- /dev/null +++ b/python/example_code/scheduler/test/conftest.py @@ -0,0 +1,43 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Contains common test fixtures used to run unit tests. +""" + +import sys + +import boto3 +import pytest + +import scheduler_scenario +from scheduler_wrapper import SchedulerWrapper + +# This is needed so Python can find test_tools on the path. +sys.path.append("../..") +from test_tools.fixtures.common import * + + +class ScenarioData: + def __init__(self, scheduler_client, cloud_formation_resource, scheduler_stubber, cloud_formation_stubber): + self.scheduler_client = scheduler_client + self.cloud_formation_resource= cloud_formation_resource + self.scheduler_stubber = scheduler_stubber + self.cloud_formation_stubber = cloud_formation_stubber + self.scenario = scheduler_scenario.SchedulerScenario( + scheduler_wrapper=SchedulerWrapper(self.scheduler_client), + cloud_formation_resource=self.cloud_formation_resource, + ) + + +@pytest.fixture +def scenario_data(make_stubber): + scheduler_client = boto3.client("scheduler") + scheduler_stubber = make_stubber(scheduler_client) + cloud_formation_resource = boto3.resource("cloudformation") + cloud_formation_stubber = make_stubber(cloud_formation_resource.meta.client) + return ScenarioData(scheduler_client, cloud_formation_resource, scheduler_stubber, cloud_formation_stubber) + +@pytest.fixture +def mock_wait(monkeypatch): + return \ No newline at end of file diff --git a/python/example_code/scheduler/test/test_scenario_cleanup.py b/python/example_code/scheduler/test/test_scenario_cleanup.py new file mode 100644 index 00000000000..fc81433ac28 --- /dev/null +++ b/python/example_code/scheduler/test/test_scenario_cleanup.py @@ -0,0 +1,59 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for cleanup in scheduler_scenario.py. +""" + +import pytest +from botocore.exceptions import ClientError +from scheduler_scenario import SchedulerScenario +from botocore import waiter + +class MockManager: + def __init__(self, stub_runner, scenario_data, input_mocker): + self.scenario_data = scenario_data + self.stack_name = "python-tests" + self.schedule_group_name = "workflow-schedules-group" + scenario_data.scenario.schedule_group_name = self.schedule_group_name + scenario_data.scenario.stack = scenario_data.cloud_formation_resource.Stack(self.stack_name) + self.stub_runner = stub_runner + + def setup_stubs(self, error, stop_on, scheduler_stubber, cloud_formation_stubber, monkeypatch): + with self.stub_runner(error, stop_on) as runner: + runner.add(scheduler_stubber.stub_delete_schedule_group, self.schedule_group_name) + runner.add(cloud_formation_stubber.stub_delete_stack, self.stack_name) + + def mock_wait(self, **kwargs): + return + + monkeypatch.setattr(waiter.Waiter, "wait", mock_wait) + + + +@pytest.fixture +def mock_mgr(stub_runner, scenario_data, input_mocker): + return MockManager(stub_runner, scenario_data, input_mocker) + +@pytest.mark.integ +def test_scenario_cleanup(mock_mgr, capsys, monkeypatch): + mock_mgr.setup_stubs(None, None, mock_mgr.scenario_data.scheduler_stubber, + mock_mgr.scenario_data.cloud_formation_stubber, monkeypatch) + + mock_mgr.scenario_data.scenario.cleanup() + + +@pytest.mark.parametrize( + "error, stop_on_index", + [ + ("TESTERROR-stub_delete_schedule_group", 0), + ("TESTERROR-stub_delete_stack", 1), + ], +) + +@pytest.mark.integ +def test_scenario_cleanup_error(mock_mgr, caplog, error, stop_on_index, monkeypatch): + mock_mgr.setup_stubs(error, stop_on_index, mock_mgr.scenario_data.scheduler_stubber, mock_mgr.scenario_data.cloud_formation_stubber, monkeypatch) + + with pytest.raises(ClientError) as exc_info: + mock_mgr.scenario_data.scenario.cleanup() diff --git a/python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py b/python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py new file mode 100644 index 00000000000..823a96b6428 --- /dev/null +++ b/python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py @@ -0,0 +1,64 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for create_one_time_schedule in scheduler_scenario.py. +""" + +import pytest +from botocore.exceptions import ClientError +from scheduler_scenario import SchedulerScenario +from botocore import waiter + +class MockManager: + def __init__(self, stub_runner, scenario_data, input_mocker): + self.scenario_data = scenario_data + self.schedule_name = "python-test" + self.schedule_group_name = "workflow-schedules-group" + self.role_arn = "arn:aws:iam::123456789012:role/Test-Role" + self.sns_topic_arn = "arn:aws:sns:us-west-2:123456789012:my-topic" + self.schedule_expression = "at('2024-10-16T15:03:00')" + self.schedule_input = f"One time scheduled event test from schedule {self.schedule_name}." + self.schedule_arn = f"arn:aws:scheduler:us-east-1:123456789012:schedule/{self.schedule_group_name}/{self.schedule_name}" + scenario_data.scenario.sns_topic_arn = self.sns_topic_arn + scenario_data.scenario.role_arn = self.role_arn + scenario_data.scenario.schedule_group_name = "workflow-schedules-group" + answers = [ + self.schedule_name, + ] + input_mocker.mock_answers(answers) + self.stub_runner = stub_runner + + def setup_stubs(self, error, stop_on, scheduler_stubber): + with self.stub_runner(error, stop_on) as runner: + runner.add(scheduler_stubber.stub_create_schedule, self.schedule_arn, self.schedule_name, + self.schedule_expression, self.schedule_group_name, self.sns_topic_arn, self.role_arn, + self.schedule_input, + delete_after_completion=True, + use_flexible_time_window=True,) + + +@pytest.fixture +def mock_mgr(stub_runner, scenario_data, input_mocker): + return MockManager(stub_runner, scenario_data, input_mocker) + + +@pytest.mark.integ +def test_scenario_create_one_time_schedule(mock_mgr, capsys): + mock_mgr.setup_stubs(None, None, mock_mgr.scenario_data.scheduler_stubber) + + mock_mgr.scenario_data.scenario.create_one_time_schedule() + + +@pytest.mark.parametrize( + "error, stop_on_index", + [ + ("TESTERROR-stub_create_schedule", 0), + ], +) +@pytest.mark.integ +def test_scenario_create_one_time_schedule_error(mock_mgr, caplog, error, stop_on_index): + mock_mgr.setup_stubs(error, stop_on_index, mock_mgr.scenario_data.scheduler_stubber) + + with pytest.raises(ClientError) as exc_info: + mock_mgr.scenario_data.scenario.create_one_time_schedule() diff --git a/python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py b/python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py new file mode 100644 index 00000000000..9945dbdf749 --- /dev/null +++ b/python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py @@ -0,0 +1,66 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for create_recurring_schedule in scheduler_scenario.py. +""" + +import pytest +from botocore.exceptions import ClientError +from scheduler_scenario import SchedulerScenario +from botocore import waiter + +class MockManager: + def __init__(self, stub_runner, scenario_data, input_mocker): + self.scenario_data = scenario_data + self.schedule_name = "python-test2" + self.schedule_group_name = "workflow-schedules-group" + self.role_arn = "arn:aws:iam::123456789012:role/Test-Role" + self.sns_topic_arn = "arn:aws:sns:us-west-2:123456789012:my-topic" + self.schedule_rate_in_minutes = 5 + self.schedule_expression = f"rate({self.schedule_rate_in_minutes} minutes)" + self.schedule_input = f"Recurrent event test from schedule {self.schedule_name}." + self.schedule_arn = f"arn:aws:scheduler:us-east-1:123456789012:schedule/{self.schedule_group_name}/{self.schedule_name}" + scenario_data.scenario.sns_topic_arn = self.sns_topic_arn + scenario_data.scenario.role_arn = self.role_arn + scenario_data.scenario.schedule_group_name = "workflow-schedules-group" + answers = [ + self.schedule_name, + str(self.schedule_rate_in_minutes), + "y" + ] + input_mocker.mock_answers(answers) + self.stub_runner = stub_runner + + def setup_stubs(self, error, stop_on, scheduler_stubber): + with self.stub_runner(error, stop_on) as runner: + runner.add(scheduler_stubber.stub_create_schedule, self.schedule_arn, self.schedule_name, + self.schedule_expression, self.schedule_group_name, self.sns_topic_arn, self.role_arn, + self.schedule_input) + runner.add(scheduler_stubber.stub_delete_schedule, self.schedule_name, self.schedule_group_name) + + +@pytest.fixture +def mock_mgr(stub_runner, scenario_data, input_mocker): + return MockManager(stub_runner, scenario_data, input_mocker) + +@pytest.mark.integ +def test_scenario_create_recurring_schedule(mock_mgr, capsys): + mock_mgr.setup_stubs(None, None, mock_mgr.scenario_data.scheduler_stubber) + + mock_mgr.scenario_data.scenario.create_recurring_schedule() + + +@pytest.mark.parametrize( + "error, stop_on_index", + [ + ("TESTERROR-stub_create_schedule", 0), +("TESTERROR-stub_delete_schedule", 1), + ], +) +@pytest.mark.integ +def test_scenario_create_recurring_schedule_error(mock_mgr, caplog, error, stop_on_index): + mock_mgr.setup_stubs(error, stop_on_index, mock_mgr.scenario_data.scheduler_stubber) + + with pytest.raises(ClientError) as exc_info: + mock_mgr.scenario_data.scenario.create_recurring_schedule() diff --git a/python/example_code/scheduler/test/test_scenario_prepare_application.py b/python/example_code/scheduler/test/test_scenario_prepare_application.py new file mode 100644 index 00000000000..0040a289eae --- /dev/null +++ b/python/example_code/scheduler/test/test_scenario_prepare_application.py @@ -0,0 +1,80 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for prepare_application in scheduler_scenario.py. +""" + +import pytest +from botocore.exceptions import ClientError +from scheduler_scenario import SchedulerScenario +from botocore import waiter + +class MockManager: + def __init__(self, stub_runner, scenario_data, input_mocker): + self.scenario_data = scenario_data + self.email_address = "carlos@example.com" + self.stack_name = "python-tests" + self.parameters = [{"ParameterKey": "email", "ParameterValue": self.email_address}] + self.capabilities = ['CAPABILITY_NAMED_IAM'] + self.cfn_template = SchedulerScenario.get_template_as_string() + self.stack_id = "arn:aws:cloudformation:us-east-1:123456789012:stack/myteststack/466df9e0-0dff-08e3-8e2f-5088487c4896" + self.outputs = [ + { + 'OutputKey': 'RoleARN', + 'OutputValue': 'arn:aws:iam::123456789012:role/Test-Role' + }, + { + 'OutputKey': 'SNStopicARN', + 'OutputValue': 'arn:aws:sns:us-west-2:123456789012:my-topic' + } + ] + self.schedule_group_name = "workflow-schedules-group" + self.schedule_group_arn = "arn:aws:scheduler:us-east-1:123456789012:schedule-group/tests" + answers = [ + self.email_address, + self.stack_name + ] + input_mocker.mock_answers(answers) + self.stub_runner = stub_runner + + def setup_stubs(self, error, stop_on, scheduler_stubber, cloud_formation_stubber, monkeypatch): + with self.stub_runner(error, stop_on) as runner: + runner.add(cloud_formation_stubber.stub_create_stack, self.stack_name, self.cfn_template, + self.capabilities, self.stack_id, self.parameters) + runner.add(cloud_formation_stubber.stub_describe_stacks, self.stack_name, "CREATE_COMPLETE", self.outputs) + runner.add(scheduler_stubber.stub_create_schedule_group, self.schedule_group_name, self.schedule_group_arn) + + def mock_wait(self, **kwargs): + return + + monkeypatch.setattr(waiter.Waiter, "wait", mock_wait) + + + +@pytest.fixture +def mock_mgr(stub_runner, scenario_data, input_mocker): + return MockManager(stub_runner, scenario_data, input_mocker) + +@pytest.mark.integ +def test_scenario_prepare_application(mock_mgr, capsys, monkeypatch): + mock_mgr.setup_stubs(None, None, mock_mgr.scenario_data.scheduler_stubber, + mock_mgr.scenario_data.cloud_formation_stubber, monkeypatch) + + mock_mgr.scenario_data.scenario.prepare_application() + + +@pytest.mark.parametrize( + "error, stop_on_index", + [ + ("TESTERROR-stub_create_stack", 0), + ("TESTERROR-stub_describe_stacks", 1), + ("TESTERROR-stub_create_schedule_group", 2), + ], +) +@pytest.mark.integ +def test_scenario_prepare_application_error(mock_mgr, caplog, error, stop_on_index, monkeypatch): + mock_mgr.setup_stubs(error, stop_on_index, mock_mgr.scenario_data.scheduler_stubber, mock_mgr.scenario_data.cloud_formation_stubber, monkeypatch) + + with pytest.raises(ClientError) as exc_info: + mock_mgr.scenario_data.scenario.prepare_application() diff --git a/python/test_tools/cloudformation_stubber.py b/python/test_tools/cloudformation_stubber.py index 35f76dabdcb..d179aee760e 100644 --- a/python/test_tools/cloudformation_stubber.py +++ b/python/test_tools/cloudformation_stubber.py @@ -25,13 +25,15 @@ def __init__(self, client, use_stubs=True): super().__init__(client, use_stubs) def stub_create_stack( - self, stack_name, setup_template, capabilities, stack_id, error_code=None + self, stack_name, setup_template, capabilities, stack_id, parameters= None, error_code=None ): expected_params = { "StackName": stack_name, "TemplateBody": setup_template, "Capabilities": capabilities, } + if parameters is not None: + expected_params["Parameters"] = parameters response = {"StackId": stack_id} self._stub_bifurcator( "create_stack", expected_params, response, error_code=error_code diff --git a/python/test_tools/scheduler_stubber.py b/python/test_tools/scheduler_stubber.py new file mode 100644 index 00000000000..8784835f75a --- /dev/null +++ b/python/test_tools/scheduler_stubber.py @@ -0,0 +1,137 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Stub functions that are used by the Amazon EventBridge Scheduler unit tests. + +When tests are run against an actual AWS account, the stubber class does not +set up stubs and passes all calls through to the Boto 3 client. +""" + +import io +import json +from botocore.stub import ANY +from boto3 import client + +from test_tools.example_stubber import ExampleStubber + +from datetime import timedelta, timezone, datetime + + + +class SchedulerStubber(ExampleStubber): + """ + A class that implements a variety of stub functions that are used by the + Amazon EventBridge Scheduler unit tests. + + The stubbed functions all expect certain parameters to be passed to them as + part of the tests, and will raise errors when the actual parameters differ from + the expected. + """ + + def __init__(self, scheduler_client: client, use_stubs=True) -> None: + """ + Initializes the object with a specific client and configures it for + stubbing or AWS passthrough. + + :param scheduler_client: A Boto 3 Amazon EventBridge Scheduler client. + :param use_stubs: When True, use stubs to intercept requests. Otherwise, + pass requests through to AWS. + """ + super().__init__(scheduler_client, use_stubs) + + def stub_create_schedule(self, + schedule_arn: str, + name: str, + schedule_expression: str, + schedule_group_name: str, + target_arn: str, + role_arn: str, + input: str, + delete_after_completion: bool = False, + use_flexible_time_window: bool = False, + error_code: str=None) -> None: + """ + Stub the create_schedule function. + + :param schedule_arn: The ARN of the created schedule. + :param name: The name of the schedule. + :param schedule_expression: The expression that defines when the schedule runs. + :param schedule_group_name: The name of the schedule group. + :param target_arn: The Amazon Resource Name (ARN) of the target. + :param role_arn: The Amazon Resource Name (ARN) of the execution IAM role. + :param input: The input for the target. + :param delete_after_completion: Whether to delete the schedule after it completes. + :param use_flexible_time_window: Whether to use a flexible time window. + :param error_code: Simulated error code to raise. + :return: None + """ + flexible_time_window_minutes = 10 + expected_params = {"Name": name, + "ScheduleExpression": ANY, + "GroupName": schedule_group_name, + "Target": {"Arn": target_arn, "RoleArn": role_arn, "Input": input}, + "StartDate":ANY, + "EndDate": ANY, + } + if delete_after_completion: + expected_params["ActionAfterCompletion"] = "DELETE" + + if use_flexible_time_window: + expected_params["FlexibleTimeWindow"] = { + "Mode": "FLEXIBLE", + "MaximumWindowInMinutes": flexible_time_window_minutes, + } + else: + expected_params["FlexibleTimeWindow"] = {"Mode": "OFF"} + + response = {"ScheduleArn": schedule_arn} + self._stub_bifurcator( + "create_schedule", expected_params, response, error_code=error_code + ) + + def stub_create_schedule_group(self, group_name: str, schedule_group_arn: str, error_code: str =None) -> None: + """ + Stub the create_schedule_group function. + + :param group_name: The name of the schedule group. + :param schedule_group_arn: The ARN of the created schedule group. + :param error_code: Simulated error code to raise. + :return: None + """ + expected_params = {"Name": group_name} + response = {"ScheduleGroupArn": schedule_group_arn} + self._stub_bifurcator( + "create_schedule_group", expected_params, response, error_code=error_code + ) + + def stub_delete_schedule(self, name: str, schedule_group_name: str, error_code: str =None) -> None: + """ + Stub the delete_schedule function. + + :param name: The name of the schedule. + :param schedule_group_name: The name of the schedule group. + :param error_code: Simulated error code to raise. + :return: None + """ + expected_params = {"Name": name, + "GroupName": schedule_group_name} + response = {} + self._stub_bifurcator( + "delete_schedule", expected_params, response, error_code=error_code + ) + + def stub_delete_schedule_group(self, schedule_group_name: str, error_code: str =None) -> None: + """ + Stub the delete_schedule_group function. + + :param schedule_group_name: The name of the schedule group. + :param error_code: Simulated error code to raise. + :return: None + """ + expected_params = {"Name": schedule_group_name} + response = {} + self._stub_bifurcator( + "delete_schedule_group", expected_params, response, error_code=error_code + ) + diff --git a/python/test_tools/stubber_factory.py b/python/test_tools/stubber_factory.py index 5283ac6a240..e0164656a80 100644 --- a/python/test_tools/stubber_factory.py +++ b/python/test_tools/stubber_factory.py @@ -63,6 +63,7 @@ from test_tools.medical_imaging_stubber import MedicalImagingStubber from test_tools.redshift_stubber import RedshiftStubber from test_tools.redshift_data_stubber import RedshiftDataStubber +from test_tools.scheduler_stubber import SchedulerStubber class StubberFactoryNotImplemented(Exception): @@ -160,6 +161,8 @@ def stubber_factory(service_name): return S3Stubber elif service_name == "s3control": return S3ControlStubber + elif service_name == "scheduler": + return SchedulerStubber elif service_name == "secretsmanager": return SecretsManagerStubber elif service_name == "ses": From 14ceb5095ff6197dee50ab603f0ba588a57c1856 Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:42:01 -0400 Subject: [PATCH 4/6] Pretty much done --- .doc_gen/metadata/scheduler_metadata.yaml | 63 +++++++++ python/example_code/scheduler/README.md | 129 ++++++++++++++++++ .../scheduler/hello/hello_scheduler.py | 33 +++++ .../scheduler/hello/requirements.txt | 3 + .../example_code/scheduler/hello_scheduler.py | 0 .../example_code/scheduler/scenario/README.md | 79 +++++++++++ .../{ => scenario}/cfn_template.yaml | 0 .../scheduler/scenario/requirements.txt | 3 + .../{ => scenario}/scheduler_scenario.py | 19 ++- .../scheduler/{ => scenario}/test/conftest.py | 0 .../test/test_scenario_cleanup.py | 1 - .../test_scenario_create_one_time_schedule.py | 0 ...test_scenario_create_recurring_schedule.py | 0 .../test/test_scenario_prepare_application.py | 0 .../scheduler/scheduler_wrapper.py | 27 ++-- 15 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 python/example_code/scheduler/README.md create mode 100644 python/example_code/scheduler/hello/hello_scheduler.py create mode 100644 python/example_code/scheduler/hello/requirements.txt delete mode 100644 python/example_code/scheduler/hello_scheduler.py create mode 100644 python/example_code/scheduler/scenario/README.md rename python/example_code/scheduler/{ => scenario}/cfn_template.yaml (100%) create mode 100644 python/example_code/scheduler/scenario/requirements.txt rename python/example_code/scheduler/{ => scenario}/scheduler_scenario.py (96%) rename python/example_code/scheduler/{ => scenario}/test/conftest.py (100%) rename python/example_code/scheduler/{ => scenario}/test/test_scenario_cleanup.py (97%) rename python/example_code/scheduler/{ => scenario}/test/test_scenario_create_one_time_schedule.py (100%) rename python/example_code/scheduler/{ => scenario}/test/test_scenario_create_recurring_schedule.py (100%) rename python/example_code/scheduler/{ => scenario}/test/test_scenario_prepare_application.py (100%) diff --git a/.doc_gen/metadata/scheduler_metadata.yaml b/.doc_gen/metadata/scheduler_metadata.yaml index 655a6d1c41b..1a9db1f8778 100644 --- a/.doc_gen/metadata/scheduler_metadata.yaml +++ b/.doc_gen/metadata/scheduler_metadata.yaml @@ -13,6 +13,15 @@ scheduler_hello: genai: some snippet_tags: - Scheduler.dotnetv3.HelloScheduler + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: + snippet_tags: + - python.example_code.scheduler.Hello services: scheduler: {ListSchedules} scheduler_CreateSchedule: @@ -26,6 +35,16 @@ scheduler_CreateSchedule: genai: most snippet_tags: - Scheduler.dotnetv3.CreateSchedule + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: + snippet_tags: + - python.example_code.scheduler.EventSchedulerWrapper.decl + - python.example_code.scheduler.CreateSchedule services: scheduler: {CreateSchedule} scheduler_CreateScheduleGroup: @@ -39,6 +58,16 @@ scheduler_CreateScheduleGroup: genai: most snippet_tags: - Scheduler.dotnetv3.CreateScheduleGroup + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: + snippet_tags: + - python.example_code.scheduler.EventSchedulerWrapper.decl + - python.example_code.scheduler.CreateScheduleGroup services: scheduler: {CreateScheduleGroup} scheduler_DeleteSchedule: @@ -52,6 +81,16 @@ scheduler_DeleteSchedule: genai: most snippet_tags: - Scheduler.dotnetv3.DeleteSchedule + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: + snippet_tags: + - python.example_code.scheduler.EventSchedulerWrapper.decl + - python.example_code.scheduler.DeleteSchedule services: scheduler: {DeleteSchedule} scheduler_DeleteScheduleGroup: @@ -65,6 +104,16 @@ scheduler_DeleteScheduleGroup: genai: most snippet_tags: - Scheduler.dotnetv3.DeleteScheduleGroup + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: + snippet_tags: + - python.example_code.scheduler.EventSchedulerWrapper.decl + - python.example_code.scheduler.DeleteScheduleGroup services: scheduler: {DeleteScheduleGroup} scheduler_ScheduledEventsWorkflow: @@ -92,5 +141,19 @@ scheduler_ScheduledEventsWorkflow: genai: most snippet_tags: - Scheduler.dotnetv3.SchedulerWrapper + Python: + versions: + - sdk_version: 3 + github: python/example_code/scheduler + sdkguide: + excerpts: + - description: Run an interactive scenario at a command prompt. + genai: some + snippet_tags: + - python.example_code.scheduler.FeatureScenario + - description: Define a class that wraps &EVlong; Scheduler actions. + genai: some + snippet_tags: + - python.example_code.scheduler.EventSchedulerWrapper.class services: scheduler: {CreateSchedule, CreateScheduleGroup, DeleteSchedule, DeleteScheduleGroups} diff --git a/python/example_code/scheduler/README.md b/python/example_code/scheduler/README.md new file mode 100644 index 00000000000..d4717e7af38 --- /dev/null +++ b/python/example_code/scheduler/README.md @@ -0,0 +1,129 @@ +# EventBridge Scheduler code examples for the SDK for Python + +## Overview + +Shows how to use the AWS SDK for Python (Boto3) to work with Amazon EventBridge Scheduler. + + + + +_EventBridge Scheduler allows you to create, run, and manage tasks on a schedule from one central, managed service._ + +## âš  Important + +* Running this code might result in charges to your AWS account. For more details, see [AWS Pricing](https://aws.amazon.com/pricing/) and [Free Tier](https://aws.amazon.com/free/). +* Running the tests might result in charges to your AWS account. +* We recommend that you grant your code least privilege. At most, grant only the minimum permissions required to perform the task. For more information, see [Grant least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege). +* This code is not tested in every AWS Region. For more information, see [AWS Regional Services](https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services). + + + + +## Code examples + +### Prerequisites + +For prerequisites, see the [README](../../README.md#Prerequisites) in the `python` folder. + +Install the packages required by these examples by running the following in a virtual environment: + +``` +python -m pip install -r requirements.txt +``` + + + + +### Get started + +- [Hello EventBridge Scheduler](hello/hello_scheduler.py#L4) (`ListSchedules`) + + +### Single actions + +Code excerpts that show you how to call individual service functions. + +- [CreateSchedule](scheduler_wrapper.py#L38) +- [CreateScheduleGroup](scheduler_wrapper.py#L127) +- [DeleteSchedule](scheduler_wrapper.py#L102) +- [DeleteScheduleGroup](scheduler_wrapper.py#L153) + +### Scenarios + +Code examples that show you how to accomplish a specific task by calling multiple +functions within the same service. + +- [Scheduled Events workflow](scenario/scheduler_scenario.py) + + + + + +## Run the examples + +### Instructions + + + + + +#### Hello EventBridge Scheduler + +This example shows you how to get started using EventBridge Scheduler. + +``` +python hello/hello_scheduler.py +``` + + +#### Scheduled Events workflow + +This example shows you how to do the following: + +- Deploy a CloudFormation stack with required resources. +- Create a EventBridge Scheduler schedule group. +- Create a one-time EventBridge Scheduler schedule with a flexible time window. +- Create a recurring EventBridge Scheduler schedule with a specified rate. +- Delete EventBridge Scheduler the schedule and schedule group. +- Clean up resources and delete the stack. + + + + +Start the example by running the following at a command prompt: + +``` +python scenario/scheduler_scenario.py +``` + + + + + +### Tests + +âš  Running tests might result in charges to your AWS account. + + +To find instructions for running these tests, see the [README](../../README.md#Tests) +in the `python` folder. + + + + + + +## Additional resources + +- [EventBridge Scheduler User Guide](https://docs.aws.amazon.com/scheduler/latest/userguide/intro.html) +- [EventBridge Scheduler API Reference](https://docs.aws.amazon.com/scheduler/latest/apireference/Welcome.html) +- [SDK for Python EventBridge Scheduler reference](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/scheduler.html) + + + + +--- + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 \ No newline at end of file diff --git a/python/example_code/scheduler/hello/hello_scheduler.py b/python/example_code/scheduler/hello/hello_scheduler.py new file mode 100644 index 00000000000..e0ed251ac4f --- /dev/null +++ b/python/example_code/scheduler/hello/hello_scheduler.py @@ -0,0 +1,33 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# snippet-start:[python.example_code.scheduler.Hello] +import boto3 + + +def hello_scheduler(scheduler_client): + """ + Use the AWS SDK for Python (Boto3) to create an Amazon EventBridge Scheduler + client and list the schedules in your account. + This example uses the default settings specified in your shared credentials + and config files. + + :param scheduler_client: A Boto3 Amazon EventBridge Scheduler Client object. This object wraps + the low-level Amazon EventBridge Scheduler service API. + """ + print("Hello, Amazon EventBridge Scheduler! Let's list some of your schedules:\n") + paginator = scheduler_client.get_paginator('list_schedules') + page_iterator = paginator.paginate(PaginationConfig={'MaxItems':10}) + + schedule_names: [str] = [] + for page in page_iterator: + for schedule in page['Schedules']: + schedule_names.append(schedule['Name']) + + print(f"{len(schedule_names)} schedule(s) retrieved.") + for schedule_name in schedule_names: + print(f"\t{schedule_name}") + +if __name__ == "__main__": + hello_scheduler(boto3.client("scheduler")) +# snippet-end:[python.example_code.scheduler.Hello] \ No newline at end of file diff --git a/python/example_code/scheduler/hello/requirements.txt b/python/example_code/scheduler/hello/requirements.txt new file mode 100644 index 00000000000..b2f85e5bc24 --- /dev/null +++ b/python/example_code/scheduler/hello/requirements.txt @@ -0,0 +1,3 @@ +boto3>=1.35.38 +pytest>=8.3.3 +botocore>=1.35.38 \ No newline at end of file diff --git a/python/example_code/scheduler/hello_scheduler.py b/python/example_code/scheduler/hello_scheduler.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/python/example_code/scheduler/scenario/README.md b/python/example_code/scheduler/scenario/README.md new file mode 100644 index 00000000000..9a78906596e --- /dev/null +++ b/python/example_code/scheduler/scenario/README.md @@ -0,0 +1,79 @@ +# Amazon EventBridge Scheduler Workflow + +## Overview +This example shows how to use AWS SDK for Python (Boto3) to work with Amazon EventBridge Scheduler with schedules and schedule groups. The workflow demonstrates how to create and delete one-time and recurring schedules within a schedule group to generate events on a specified target, such as an Amazon Simple Notification Service (Amazon SNS) Topic. + +The target SNS topic and the AWS Identity and Access Management (IAM) role used with the schedules are created as part of an AWS CloudFormation stack that is deployed at the start of the workflow, and deleted when the workflow is complete. + +![Scheduler scenario diagram](resources/scheduler-workflow.png) + +This workflow demonstrates the following steps and tasks: + +1. **Prepare the Application** + + - Prompts the user for an email address to use for the subscription for the SNS topic. + - Prompts the user for a name for the Cloud Formation stack. + - The user must confirm the email subscription to receive event emails. + - Deploys the Cloud Formation template in resources/cfn_template.yaml for resource creation. + - Stores the outputs of the stack into variables for use in the workflow. + - Creates a schedule group for all workflow schedules. + +2. **Create a one-time Schedule** + + - Creates a one-time schedule to send an initial event. + - Prompts the user for a name for the one-time schedule. + - The user must confirm the email subscription to receive an event email. + - The content of the email should include the name of the newly created schedule. + - Use a Flexible Time Window of 10 minutes and set the schedule to delete after completion. + +3. **Create a time-based schedule** + + - Prompts the user for a rate per minutes (example: every 2 minutes) for a scheduled recurring event. + - Creates the scheduled event for X times per hour for 1 hour. + - Deletes the schedule when the user is finished. + - Prompts the user to confirm when they are ready to delete the schedule. + +4. **Clean up** + + - Prompts the user to confirm they want to destroy the stack and clean up all resources. + - Deletes the schedule group. + - Destroys the Cloud Formation stack and wait until the stack has been removed. + +## Prerequisites + +Before running this workflow, ensure you have: + +- An AWS account with proper permissions to use Amazon EventBridge Scheduler and Amazon EventBridge. + +## AWS Services Used + +This workflow uses the following AWS services: + +- Amazon EventBridge Scheduler +- Amazon EventBridge +- Amazon Simple Notification Service (SNS) +- AWS CloudFormation + +### Resources + +The workflow scenario deploys the AWS CloudFormation stack with the required resources. + +## Amazon EventBridge Scheduler Actions + +The workflow covers the following EventBridge Scheduler API actions: + +- [`CreateSchedule`](https://docs.aws.amazon.com/scheduler/latest/APIReference/API_CreateSchedule.html) +- [`CreateScheduleGroup`](https://docs.aws.amazon.com/scheduler/latest/APIReference/API_CreateScheduleGroup.html) +- [`DeleteSchedule`](https://docs.aws.amazon.com/scheduler/latest/APIReference/API_DeleteSchedule.html) +- [`DeleteScheduleGroup`](https://docs.aws.amazon.com/scheduler/latest/APIReference/API_DeleteScheduleGroup.html) + + +## Additional resources + +* [EventBridge Scheduler User Guide](https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html) +* [EventBridge Scheduler API Reference](https://docs.aws.amazon.com/scheduler/latest/APIReference/Welcome.html) + +--- + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 diff --git a/python/example_code/scheduler/cfn_template.yaml b/python/example_code/scheduler/scenario/cfn_template.yaml similarity index 100% rename from python/example_code/scheduler/cfn_template.yaml rename to python/example_code/scheduler/scenario/cfn_template.yaml diff --git a/python/example_code/scheduler/scenario/requirements.txt b/python/example_code/scheduler/scenario/requirements.txt new file mode 100644 index 00000000000..b2f85e5bc24 --- /dev/null +++ b/python/example_code/scheduler/scenario/requirements.txt @@ -0,0 +1,3 @@ +boto3>=1.35.38 +pytest>=8.3.3 +botocore>=1.35.38 \ No newline at end of file diff --git a/python/example_code/scheduler/scheduler_scenario.py b/python/example_code/scheduler/scenario/scheduler_scenario.py similarity index 96% rename from python/example_code/scheduler/scheduler_scenario.py rename to python/example_code/scheduler/scenario/scheduler_scenario.py index 02116c5f813..8dac957ad11 100644 --- a/python/example_code/scheduler/scheduler_scenario.py +++ b/python/example_code/scheduler/scenario/scheduler_scenario.py @@ -11,23 +11,27 @@ import logging import sys from datetime import datetime, timedelta, timezone -from scheduler_wrapper import SchedulerWrapper -from boto3 import client -from botocore.exceptions import ClientError -from boto3.resources.base import ServiceResource import os +from boto3.resources.base import ServiceResource +from boto3 import resource -import boto3 +# Add relative path to include SchedulerWrapper. +sys.path.append('..') +from scheduler_wrapper import SchedulerWrapper # Add relative path to include demo_tools in this code example without need for setup. -sys.path.append("../..") +sys.path.append("../../..") import demo_tools.question as q + DASHES = "-" * 80 +sys.path + logger = logging.getLogger(__name__) +# snippet-start:[python.example_code.scheduler.FeatureScenario] class SchedulerScenario: """ A scenario that demonstrates how to use Boto3 to schedule and receive events using @@ -253,7 +257,7 @@ def get_template_as_string() -> str: demo: SchedulerScenario = None try: scheduler_wrapper = SchedulerWrapper.from_client() - cloud_formation_resource = boto3.resource("cloudformation") + cloud_formation_resource = resource("cloudformation") demo = SchedulerScenario(scheduler_wrapper, cloud_formation_resource) demo.run() @@ -262,3 +266,4 @@ def get_template_as_string() -> str: if demo is not None: demo.cleanup() +# snippet-end:[python.example_code.scheduler.FeatureScenario] \ No newline at end of file diff --git a/python/example_code/scheduler/test/conftest.py b/python/example_code/scheduler/scenario/test/conftest.py similarity index 100% rename from python/example_code/scheduler/test/conftest.py rename to python/example_code/scheduler/scenario/test/conftest.py diff --git a/python/example_code/scheduler/test/test_scenario_cleanup.py b/python/example_code/scheduler/scenario/test/test_scenario_cleanup.py similarity index 97% rename from python/example_code/scheduler/test/test_scenario_cleanup.py rename to python/example_code/scheduler/scenario/test/test_scenario_cleanup.py index fc81433ac28..bfc40f5c2fb 100644 --- a/python/example_code/scheduler/test/test_scenario_cleanup.py +++ b/python/example_code/scheduler/scenario/test/test_scenario_cleanup.py @@ -7,7 +7,6 @@ import pytest from botocore.exceptions import ClientError -from scheduler_scenario import SchedulerScenario from botocore import waiter class MockManager: diff --git a/python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py b/python/example_code/scheduler/scenario/test/test_scenario_create_one_time_schedule.py similarity index 100% rename from python/example_code/scheduler/test/test_scenario_create_one_time_schedule.py rename to python/example_code/scheduler/scenario/test/test_scenario_create_one_time_schedule.py diff --git a/python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py b/python/example_code/scheduler/scenario/test/test_scenario_create_recurring_schedule.py similarity index 100% rename from python/example_code/scheduler/test/test_scenario_create_recurring_schedule.py rename to python/example_code/scheduler/scenario/test/test_scenario_create_recurring_schedule.py diff --git a/python/example_code/scheduler/test/test_scenario_prepare_application.py b/python/example_code/scheduler/scenario/test/test_scenario_prepare_application.py similarity index 100% rename from python/example_code/scheduler/test/test_scenario_prepare_application.py rename to python/example_code/scheduler/scenario/test/test_scenario_prepare_application.py diff --git a/python/example_code/scheduler/scheduler_wrapper.py b/python/example_code/scheduler/scheduler_wrapper.py index fe42b2478b4..cb07f5e1583 100644 --- a/python/example_code/scheduler/scheduler_wrapper.py +++ b/python/example_code/scheduler/scheduler_wrapper.py @@ -1,9 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - """ Purpose @@ -20,8 +17,8 @@ logger = logging.getLogger(__name__) -# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.class] -# snippet-start:[python.example_code.eventbridge.EventSchedulerWrapper.decl] +# snippet-start:[python.example_code.scheduler.EventSchedulerWrapper.class] +# snippet-start:[python.example_code.scheduler.EventSchedulerWrapper.decl] class SchedulerWrapper: def __init__(self, eventbridge_scheduler_client: client): self.scheduler_client = eventbridge_scheduler_client @@ -36,9 +33,9 @@ def from_client(cls) -> "SchedulerWrapper": eventbridge_scheduler_client = boto3.client("scheduler") return cls(eventbridge_scheduler_client) - # snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.decl] + # snippet-end:[python.example_code.scheduler.EventSchedulerWrapper.decl] - # snippet-start:[python.example_code.eventbridge.CreateSchedule] + # snippet-start:[python.example_code.scheduler.CreateSchedule] def create_schedule( self, name: str, @@ -100,7 +97,9 @@ def create_schedule( logger.error("Error creating schedule: %s", err.response["Error"]["Message"]) raise - # snippet-start:[python.example_code.eventbridge.DeleteSchedule] + # snippet-end:[python.example_code.scheduler.CreateSchedule] + + # snippet-start:[python.example_code.scheduler.DeleteSchedule] def delete_schedule(self, name: str, schedule_group_name: str) -> None: """ Deletes the schedule with the specified name and schedule group. @@ -123,9 +122,9 @@ def delete_schedule(self, name: str, schedule_group_name: str) -> None: logger.error("Error deleting schedule: %s", err.response["Error"]["Message"]) raise - # snippet-end:[python.example_code.eventbridge.DeleteSchedule] + # snippet-end:[python.example_code.scheduler.DeleteSchedule] - # snippet-start:[python.example_code.eventbridge.CreateScheduleGroup] + # snippet-start:[python.example_code.scheduler.CreateScheduleGroup] def create_schedule_group(self, name: str) -> str: """ Creates a new schedule group with the specified name and description. @@ -149,9 +148,9 @@ def create_schedule_group(self, name: str) -> str: logger.error("Error creating schedule group: %s", err.response["Error"]["Message"]) raise - # snippet-end:[python.example_code.eventbridge.CreateScheduleGroup] + # snippet-end:[python.example_code.scheduler.CreateScheduleGroup] - # snippet-start:[python.example_code.eventbridge.DeleteScheduleGroup] + # snippet-start:[python.example_code.scheduler.DeleteScheduleGroup] def delete_schedule_group(self, name: str) -> None: """ Deletes the schedule group with the specified name. @@ -171,10 +170,10 @@ def delete_schedule_group(self, name: str) -> None: else: logger.error("Error deleting schedule group: %s", err.response["Error"]["Message"]) raise - # snippet-end:[python.example_code.eventbridge.DeleteScheduleGroup] + # snippet-end:[python.example_code.scheduler.DeleteScheduleGroup] -# snippet-end:[python.example_code.eventbridge.EventSchedulerWrapper.class] +# snippet-end:[python.example_code.scheduler.EventSchedulerWrapper.class] if __name__ == "__main__": try: From c5e6d1a510dc38260c584c925d3daf8151bb8879 Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:38:04 -0400 Subject: [PATCH 5/6] Final stuff --- .doc_gen/metadata/scheduler_metadata.yaml | 2 +- python/example_code/scheduler/hello/requirements.txt | 4 +--- .../scheduler/scenario/scheduler_scenario.py | 9 ++++++--- .../example_code/scheduler/scenario/test/conftest.py | 12 ++++++++++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.doc_gen/metadata/scheduler_metadata.yaml b/.doc_gen/metadata/scheduler_metadata.yaml index 1a9db1f8778..ed0a3ac6364 100644 --- a/.doc_gen/metadata/scheduler_metadata.yaml +++ b/.doc_gen/metadata/scheduler_metadata.yaml @@ -151,7 +151,7 @@ scheduler_ScheduledEventsWorkflow: genai: some snippet_tags: - python.example_code.scheduler.FeatureScenario - - description: Define a class that wraps &EVlong; Scheduler actions. + - description: SchedulerWrapper class that wraps &EVlong; Scheduler actions. genai: some snippet_tags: - python.example_code.scheduler.EventSchedulerWrapper.class diff --git a/python/example_code/scheduler/hello/requirements.txt b/python/example_code/scheduler/hello/requirements.txt index b2f85e5bc24..e32a2fb0fa9 100644 --- a/python/example_code/scheduler/hello/requirements.txt +++ b/python/example_code/scheduler/hello/requirements.txt @@ -1,3 +1 @@ -boto3>=1.35.38 -pytest>=8.3.3 -botocore>=1.35.38 \ No newline at end of file +boto3>=1.35.38 \ No newline at end of file diff --git a/python/example_code/scheduler/scenario/scheduler_scenario.py b/python/example_code/scheduler/scenario/scheduler_scenario.py index 8dac957ad11..5d7361cea74 100644 --- a/python/example_code/scheduler/scenario/scheduler_scenario.py +++ b/python/example_code/scheduler/scenario/scheduler_scenario.py @@ -15,15 +15,19 @@ from boto3.resources.base import ServiceResource from boto3 import resource +script_dir = os.path.dirname(os.path.abspath(__file__)) + # Add relative path to include SchedulerWrapper. -sys.path.append('..') +sys.path.append(os.path.dirname(script_dir)) from scheduler_wrapper import SchedulerWrapper # Add relative path to include demo_tools in this code example without need for setup. -sys.path.append("../../..") +sys.path.append(os.path.join(script_dir, "../../..")) import demo_tools.question as q + + DASHES = "-" * 80 sys.path @@ -93,7 +97,6 @@ def prepare_application(self) -> None: email_address = q.ask("Enter an email address to use for event subscriptions: ") stack_name = q.ask("Enter a name for the AWS Cloud Formation Stack: ") - script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) template_file = SchedulerScenario.get_template_as_string() parameters = [{"ParameterKey": "email", "ParameterValue": email_address}] diff --git a/python/example_code/scheduler/scenario/test/conftest.py b/python/example_code/scheduler/scenario/test/conftest.py index 7be9da58856..5c272fc660b 100644 --- a/python/example_code/scheduler/scenario/test/conftest.py +++ b/python/example_code/scheduler/scenario/test/conftest.py @@ -9,12 +9,20 @@ import boto3 import pytest +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) + +# Add relative path to include SchedulerWrapper. +sys.path.append(script_dir) +sys.path.append(os.path.dirname(script_dir)) import scheduler_scenario from scheduler_wrapper import SchedulerWrapper -# This is needed so Python can find test_tools on the path. -sys.path.append("../..") +# Add relative path to include demo_tools in this code example without need for setup. +sys.path.append(os.path.join(script_dir, "../../..")) + from test_tools.fixtures.common import * From 9069ecbe12c1b1437fec29bfefa2bc6631c2c0a2 Mon Sep 17 00:00:00 2001 From: Steven Meyer <108885656+meyertst-aws@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:06:49 -0400 Subject: [PATCH 6/6] changes for code review And reformatted with Black --- python/example_code/scheduler/README.md | 6 +-- .../scheduler/hello/hello_scheduler.py | 11 ++-- .../scenario/resources/scheduler-workflow.png | Bin 0 -> 56179 bytes .../scheduler/scenario/scheduler_scenario.py | 50 ++++++++++++------ .../scheduler/scenario/test/conftest.py | 22 ++++++-- .../scheduler/scheduler_wrapper.py | 22 +++++--- 6 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 python/example_code/scheduler/scenario/resources/scheduler-workflow.png diff --git a/python/example_code/scheduler/README.md b/python/example_code/scheduler/README.md index d4717e7af38..b449fffc42f 100644 --- a/python/example_code/scheduler/README.md +++ b/python/example_code/scheduler/README.md @@ -44,9 +44,9 @@ python -m pip install -r requirements.txt Code excerpts that show you how to call individual service functions. - [CreateSchedule](scheduler_wrapper.py#L38) -- [CreateScheduleGroup](scheduler_wrapper.py#L127) -- [DeleteSchedule](scheduler_wrapper.py#L102) -- [DeleteScheduleGroup](scheduler_wrapper.py#L153) +- [CreateScheduleGroup](scheduler_wrapper.py#L131) +- [DeleteSchedule](scheduler_wrapper.py#L104) +- [DeleteScheduleGroup](scheduler_wrapper.py#L160) ### Scenarios diff --git a/python/example_code/scheduler/hello/hello_scheduler.py b/python/example_code/scheduler/hello/hello_scheduler.py index e0ed251ac4f..fe6f70e2bf4 100644 --- a/python/example_code/scheduler/hello/hello_scheduler.py +++ b/python/example_code/scheduler/hello/hello_scheduler.py @@ -16,18 +16,19 @@ def hello_scheduler(scheduler_client): the low-level Amazon EventBridge Scheduler service API. """ print("Hello, Amazon EventBridge Scheduler! Let's list some of your schedules:\n") - paginator = scheduler_client.get_paginator('list_schedules') - page_iterator = paginator.paginate(PaginationConfig={'MaxItems':10}) + paginator = scheduler_client.get_paginator("list_schedules") + page_iterator = paginator.paginate(PaginationConfig={"MaxItems": 10}) schedule_names: [str] = [] for page in page_iterator: - for schedule in page['Schedules']: - schedule_names.append(schedule['Name']) + for schedule in page["Schedules"]: + schedule_names.append(schedule["Name"]) print(f"{len(schedule_names)} schedule(s) retrieved.") for schedule_name in schedule_names: print(f"\t{schedule_name}") + if __name__ == "__main__": hello_scheduler(boto3.client("scheduler")) -# snippet-end:[python.example_code.scheduler.Hello] \ No newline at end of file +# snippet-end:[python.example_code.scheduler.Hello] diff --git a/python/example_code/scheduler/scenario/resources/scheduler-workflow.png b/python/example_code/scheduler/scenario/resources/scheduler-workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..53fa99b5cd6150404387d6a2482c75842cba4085 GIT binary patch literal 56179 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 _pDG|*Abuu2leLxzGbo6=E7%Mca^#OO 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 z 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 F{$ER!0+|2+ literal 0 HcmV?d00001 diff --git a/python/example_code/scheduler/scenario/scheduler_scenario.py b/python/example_code/scheduler/scenario/scheduler_scenario.py index 5d7361cea74..30831a6ab0d 100644 --- a/python/example_code/scheduler/scenario/scheduler_scenario.py +++ b/python/example_code/scheduler/scenario/scheduler_scenario.py @@ -26,8 +26,6 @@ import demo_tools.question as q - - DASHES = "-" * 80 sys.path @@ -76,7 +74,10 @@ def run(self) -> None: print(DASHES) print(DASHES) - if q.ask("Do you want to delete all resources created by this workflow? (y/n) ", q.is_yesno): + if q.ask( + "Do you want to delete all resources created by this workflow? (y/n) ", + q.is_yesno, + ): self.cleanup() print(DASHES) @@ -122,8 +123,12 @@ def prepare_application(self) -> None: print(f"Stack output RoleARN: {self.role_arn}") print(f"Stack output SNStopicARN: a") schedule_group_name = "workflow-schedules-group" - schedule_group_arn = self.eventbridge_scheduler.create_schedule_group(schedule_group_name) - print(f"Successfully created schedule group '{self.schedule_group_name}': {schedule_group_arn}.") + schedule_group_arn = self.eventbridge_scheduler.create_schedule_group( + schedule_group_name + ) + print( + f"Successfully created schedule group '{self.schedule_group_name}': {schedule_group_arn}." + ) self.schedule_group_name = schedule_group_name print("Application preparation complete.") @@ -151,20 +156,23 @@ def create_one_time_schedule(self) -> None: delete_after_completion=True, use_flexible_time_window=True, ) - print(f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}.") + print( + f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}." + ) print(f"Subscription email will receive an email from this event.") print(f"You must confirm your subscription to receive event emails.") print(f"One-time schedule '{schedule_name}' created successfully.") - def create_recurring_schedule(self) -> None: """ Create a recurring schedule to send events at a specified rate in minutes. """ - print("Creating a recurring schedule to send events for one hour..."); - schedule_name = q.ask("Enter a name for the recurring schedule: "); - schedule_rate_in_minutes = q.ask("Enter the desired schedule rate (in minutes): ", q.is_int); + print("Creating a recurring schedule to send events for one hour...") + schedule_name = q.ask("Enter a name for the recurring schedule: ") + schedule_rate_in_minutes = q.ask( + "Enter the desired schedule rate (in minutes): ", q.is_int + ) schedule_arn = self.eventbridge_scheduler.create_schedule( schedule_name, @@ -175,12 +183,18 @@ def create_recurring_schedule(self) -> None: f"Recurrent event test from schedule {schedule_name}.", ) - print(f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}.") - print(f"Subscription email will receive an email from this event."); - print(f"You must confirm your subscription to receive event emails."); + print( + f"Successfully created schedule '{schedule_name}' in schedule group 'workflow-schedules-group': {schedule_arn}." + ) + print(f"Subscription email will receive an email from this event.") + print(f"You must confirm your subscription to receive event emails.") - if q.ask(f"Are you ready to delete the '{schedule_name}' schedule? (y/n)", q.is_yesno) : - self.eventbridge_scheduler.delete_schedule(schedule_name, self.schedule_group_name) + if q.ask( + f"Are you ready to delete the '{schedule_name}' schedule? (y/n)", q.is_yesno + ): + self.eventbridge_scheduler.delete_schedule( + schedule_name, self.schedule_group_name + ) def deploy_cloudformation_stack( self, stack_name: str, cfn_template: str, parameters: [dict[str, str]] @@ -220,7 +234,9 @@ def destroy_cloudformation_stack(self, stack: ServiceResource) -> None: :param stack: The CloudFormation stack that manages the example resources. """ - print(f"CloudFormation stack '{stack.name}' is being deleted. This may take a few minutes.") + print( + f"CloudFormation stack '{stack.name}' is being deleted. This may take a few minutes." + ) stack.delete() waiter = self.cloud_formation_resource.meta.client.get_waiter( "stack_delete_complete" @@ -269,4 +285,4 @@ def get_template_as_string() -> str: if demo is not None: demo.cleanup() -# snippet-end:[python.example_code.scheduler.FeatureScenario] \ No newline at end of file +# snippet-end:[python.example_code.scheduler.FeatureScenario] diff --git a/python/example_code/scheduler/scenario/test/conftest.py b/python/example_code/scheduler/scenario/test/conftest.py index 5c272fc660b..84c4303f625 100644 --- a/python/example_code/scheduler/scenario/test/conftest.py +++ b/python/example_code/scheduler/scenario/test/conftest.py @@ -27,14 +27,20 @@ class ScenarioData: - def __init__(self, scheduler_client, cloud_formation_resource, scheduler_stubber, cloud_formation_stubber): + def __init__( + self, + scheduler_client, + cloud_formation_resource, + scheduler_stubber, + cloud_formation_stubber, + ): self.scheduler_client = scheduler_client - self.cloud_formation_resource= cloud_formation_resource + self.cloud_formation_resource = cloud_formation_resource self.scheduler_stubber = scheduler_stubber self.cloud_formation_stubber = cloud_formation_stubber self.scenario = scheduler_scenario.SchedulerScenario( scheduler_wrapper=SchedulerWrapper(self.scheduler_client), - cloud_formation_resource=self.cloud_formation_resource, + cloud_formation_resource=self.cloud_formation_resource, ) @@ -44,8 +50,14 @@ def scenario_data(make_stubber): scheduler_stubber = make_stubber(scheduler_client) cloud_formation_resource = boto3.resource("cloudformation") cloud_formation_stubber = make_stubber(cloud_formation_resource.meta.client) - return ScenarioData(scheduler_client, cloud_formation_resource, scheduler_stubber, cloud_formation_stubber) + return ScenarioData( + scheduler_client, + cloud_formation_resource, + scheduler_stubber, + cloud_formation_stubber, + ) + @pytest.fixture def mock_wait(monkeypatch): - return \ No newline at end of file + return diff --git a/python/example_code/scheduler/scheduler_wrapper.py b/python/example_code/scheduler/scheduler_wrapper.py index cb07f5e1583..e7cced989c8 100644 --- a/python/example_code/scheduler/scheduler_wrapper.py +++ b/python/example_code/scheduler/scheduler_wrapper.py @@ -26,9 +26,9 @@ def __init__(self, eventbridge_scheduler_client: client): @classmethod def from_client(cls) -> "SchedulerWrapper": """ - Creates a SchedulerWrapper instance with a default EventBridge client. + Creates a SchedulerWrapper instance with a default EventBridge Scheduler client. - :return: An instance of SchedulerWrapper initialized with the default EventBridge client. + :return: An instance of SchedulerWrapper initialized with the default EventBridge Scheduler client. """ eventbridge_scheduler_client = boto3.client("scheduler") return cls(eventbridge_scheduler_client) @@ -94,7 +94,9 @@ def create_schedule( err.response["Error"]["Message"], ) else: - logger.error("Error creating schedule: %s", err.response["Error"]["Message"]) + logger.error( + "Error creating schedule: %s", err.response["Error"]["Message"] + ) raise # snippet-end:[python.example_code.scheduler.CreateSchedule] @@ -119,7 +121,9 @@ def delete_schedule(self, name: str, schedule_group_name: str) -> None: err.response["Error"]["Message"], ) else: - logger.error("Error deleting schedule: %s", err.response["Error"]["Message"]) + logger.error( + "Error deleting schedule: %s", err.response["Error"]["Message"] + ) raise # snippet-end:[python.example_code.scheduler.DeleteSchedule] @@ -145,7 +149,10 @@ def create_schedule_group(self, name: str) -> str: err.response["Error"]["Message"], ) else: - logger.error("Error creating schedule group: %s", err.response["Error"]["Message"]) + logger.error( + "Error creating schedule group: %s", + err.response["Error"]["Message"], + ) raise # snippet-end:[python.example_code.scheduler.CreateScheduleGroup] @@ -168,7 +175,10 @@ def delete_schedule_group(self, name: str) -> None: err.response["Error"]["Message"], ) else: - logger.error("Error deleting schedule group: %s", err.response["Error"]["Message"]) + logger.error( + "Error deleting schedule group: %s", + err.response["Error"]["Message"], + ) raise # snippet-end:[python.example_code.scheduler.DeleteScheduleGroup]