├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── documentation ├── choice_state.png ├── hello_world.png └── statemachine_example.png ├── requirements.txt ├── setup.py ├── stairstep ├── __init__.py ├── base.py ├── statetypes.py ├── statetypes_choices.py └── validations.py ├── test.json └── tests ├── __init__.py ├── test_01singlestep.py ├── test_02stateoutput.py ├── test_03fieldValidation.py ├── test_04choicestate.py ├── test_05parameters.py └── test_stairstep.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 11 | - image: circleci/python:3.6.4 12 | 13 | # Specify service dependencies here if necessary 14 | # CircleCI maintains a library of pre-built images 15 | # documented at https://circleci.com/docs/2.0/circleci-images/ 16 | # - image: circleci/postgres:9.4 17 | 18 | working_directory: ~/rep 19 | 20 | steps: 21 | - checkout 22 | 23 | # Download and cache dependencies 24 | - restore_cache: 25 | keys: 26 | - v1-dependencies-{{ checksum "requirements.txt" }} 27 | # fallback to using the latest cache if no exact match is found 28 | - v1-dependencies- 29 | 30 | - run: 31 | name: install dependencies 32 | command: | 33 | python3 -m venv venv 34 | . venv/bin/activate 35 | pip install -r requirements.txt 36 | 37 | - save_cache: 38 | paths: 39 | - ./venv 40 | key: v1-dependencies-{{ checksum "requirements.txt" }} 41 | 42 | # run tests! 43 | # this example uses Django's built-in test-runner 44 | # other common Python testing frameworks include pytest and nose 45 | # https://pytest.org 46 | # https://nose.readthedocs.io 47 | - run: 48 | name: run tests 49 | command: | 50 | . venv/bin/activate 51 | nosetests 52 | 53 | - store_artifacts: 54 | path: test-reports 55 | destination: test-reports 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .DS_Store 6 | 7 | # C extensions 8 | *.so 9 | bin/* 10 | include/* 11 | lib/* 12 | man/* 13 | stairstep.code-workspace 14 | pip-selfcheck.json 15 | .vscode/* 16 | stairstep/.vscode/* 17 | .vscode/settings.json 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .DS_Store 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Adam Gilman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stairstep 2 | 3 | StairStep is a Pythonic API for designing [AWS Step Functions](https://aws.amazon.com/step-functions/) using [Amazon's State Language](https://states-language.net/spec.html) 4 | 5 | Instead of hand crafting JSON, StairStep allows you define step functions using Python code which allows you to easily import information from outside sources and dynamically create step functions on the fly. 6 | 7 | # Development Progress / Coverage 8 | | Lanuage Feature | Type | Progress | 9 | |---|---|---| 10 | | State | Pass | ✅ | 11 | | State | Task | ✅ | 12 | | State | Succeed | ✅ | 13 | | State | Fail | ✅ | 14 | | State | Choice | ✅ | 15 | | Field | Common Validations | ✅ | 16 | | State | Wait | ✅ | 17 | | State | Parallel | Next 🛣 | 18 | 19 | 20 | # Examples 21 | * [Hello World](#helloworld) 22 | * [Complex Choice State](#choicestate) 23 | 24 | ## Hello World 25 | Using the example from the [Amazon's State Language](https://states-language.net/spec.html#example) page 26 | 27 | ``` 28 | { 29 | "Comment": "A simple minimal example of the States language", 30 | "StartAt": "Hello World", 31 | "States": { 32 | "Hello World": { 33 | "Type": "Task", 34 | "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 35 | "End": true 36 | } 37 | } 38 | } 39 | ``` 40 | 41 | We can easily craft that in StairStep as follows 42 | 43 | ``` 44 | # Create a parent StairStep object 45 | ss = StairStep(comment="A simple minimal example of the States language", startAt="Hello World") 46 | 47 | # Create the HelloWorld step 48 | hello = StateTask(name="Hello World", resource="arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", end=True) 49 | 50 | # Add the step into the StairStep object 51 | ss.addState(hello) 52 | 53 | # Create the Amazon State Language Export 54 | ss.json() 55 | 56 | { 57 | "Comment":"A simple minimal example of the States language", 58 | "StartAt":"Hello World", 59 | "States":{ 60 | "Hello World":{ 61 | "Type":"Task", 62 | "Resource":"arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 63 | "End":true 64 | } 65 | } 66 | } 67 | ``` 68 | ![hello_world](documentation/hello_world.png) 69 | 70 | ## Complex Choice State 71 | 72 | ``` 73 | ss = StairStep( 74 | comment = "Example Choice State", 75 | startAt = "ChoiceStateX" 76 | ) 77 | # Create a ChoiceRule, which is composed of choice expression(s) 78 | # This checks to see if the variable $.type is not "Private" 79 | typeNotPrivate = ChoiceRule(operator="Not", snext="Public", conditions= 80 | ChoiceExpression(operator="StringEquals", variable="$.type", value="Private") 81 | ) 82 | 83 | # This checks to see if the value of $.value is >=20 or <30 84 | valueInTwenties = ChoiceRule(operator="And", snext="ValueInTwenties", conditions= 85 | [ 86 | ChoiceExpression(operator="NumericGreaterThanEquals", variable="$.value", value=20), 87 | ChoiceExpression(operator="NumericLessThan", variable="$.value", value=30) 88 | ] 89 | ) 90 | state = StateChoice(name="ChoiceStateX", choices=[typeNotPrivate,valueInTwenties],default="DefaultState") 91 | 92 | default = StatePass(name="DefaultState", end=True) 93 | in_twenties = StatePass(name="ValueInTwenties", end=True) 94 | public = StatePass(name="Public", end=True) 95 | 96 | ss.addState(state) 97 | ss.addState(in_twenties) 98 | ss.addState(public) 99 | ss.addState(default) 100 | ss.json() 101 | ``` 102 | 103 | ``` 104 | { 105 | "Comment": "Example Choice State", 106 | "StartAt": "ChoiceStateX", 107 | "States": { 108 | "ChoiceStateX": { 109 | "Type": "Choice", 110 | "Default": "DefaultState", 111 | "Choices": [{ 112 | "Next": "Public", 113 | "Not": { 114 | "Variable": "$.type", 115 | "StringEquals": "Private" 116 | } 117 | }, { 118 | "Next": "ValueInTwenties", 119 | "And": [{ 120 | "Variable": "$.value", 121 | "NumericGreaterThanEquals": 20 122 | }, { 123 | "Variable": "$.value", 124 | "NumericLessThan": 30 125 | }] 126 | }] 127 | }, 128 | "ValueInTwenties": { 129 | "Type": "Pass", 130 | "End": true 131 | }, 132 | "Public": { 133 | "Type": "Pass", 134 | "End": true 135 | }, 136 | "DefaultState": { 137 | "Type": "Pass", 138 | "End": true 139 | } 140 | } 141 | } 142 | ``` 143 | ![choice_state](documentation/choice_state.png) 144 | -------------------------------------------------------------------------------- /documentation/choice_state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamgilman/stairstep/e1c64149feb75d2f192a5bf42c6287ca2905fdee/documentation/choice_state.png -------------------------------------------------------------------------------- /documentation/hello_world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamgilman/stairstep/e1c64149feb75d2f192a5bf42c6287ca2905fdee/documentation/hello_world.png -------------------------------------------------------------------------------- /documentation/statemachine_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamgilman/stairstep/e1c64149feb75d2f192a5bf42c6287ca2905fdee/documentation/statemachine_example.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nose==1.3.7 2 | simplejson==3.16.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="stairstep", 8 | version="0.1", 9 | author="Adam Gilman", 10 | author_email="oss+stairstep@adamgilman.com", 11 | description="A Pythonic API for Amazon's States Language for defining AWS Step Functions", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/adamgilman/stairstep", 15 | packages=setuptools.find_packages(), 16 | classifiers=( 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ), 21 | ) -------------------------------------------------------------------------------- /stairstep/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import StairStep 2 | from .statetypes import StateTask, StateSucceed, StateFail, StatePass, StateWait 3 | from .statetypes_choices import StateChoice, ChoiceRule, ChoiceExpression -------------------------------------------------------------------------------- /stairstep/base.py: -------------------------------------------------------------------------------- 1 | import json, copy 2 | from .validations import * 3 | 4 | class SSBase(object): 5 | prop_map = {} 6 | base_validations = [] 7 | 8 | def validate(self): 9 | all_validations = self.base_validations + self.validations 10 | for v in all_validations: 11 | v(self) 12 | 13 | def export(self): 14 | self.validate() 15 | 16 | ret = {} 17 | self_vars = copy.deepcopy( vars(self) ) 18 | exclude_fields = [ 19 | 'prop_map', 20 | 'name', 21 | 'validations', 22 | 'base_validations' 23 | ] 24 | for key in exclude_fields: 25 | self_vars.pop(key, None) 26 | 27 | for prop_key in self_vars.keys(): 28 | if self_vars[prop_key] is not None: 29 | key_map = self.prop_map[prop_key] 30 | ret[key_map] = self_vars[prop_key] 31 | return ret 32 | 33 | class StateBase(SSBase): 34 | prop_map = { 35 | 'name' : "Name", 36 | "comment" : "Comment", 37 | "stype" : "Type", 38 | "resource" : "Resource", 39 | "next" : "Next", 40 | "inputpath" : "InputPath", 41 | "resultpath" : "ResultPath", 42 | "outputpath" : "OutputPath", 43 | "retry" : "Retry", 44 | "catch" : "Catch", 45 | "end" : "End", 46 | "seconds" : "Seconds", 47 | "timestamp" : "Timestamp", 48 | "secondspath" : "SecondsPath", 49 | "timestamppath" : "TimestampPath", 50 | "parameters" : "Parameters" 51 | } 52 | base_validations = [ 53 | validation_states_cant_have_both_end_and_next, 54 | validation_name_cannot_be_longer_than_128, 55 | validation_all_states_must_have_type 56 | ] 57 | 58 | def __init__(self, 59 | name = None, 60 | comment = None, 61 | stype = None, 62 | resource = None, 63 | snext = None, 64 | seconds = None, 65 | timestamp = None, 66 | timestamppath = None, 67 | secondspath = None, 68 | end = None, 69 | parameters = None, 70 | inputpath = None, 71 | outputpath = None, 72 | resultpath = None 73 | ): 74 | #TODO - Refactor to unpack via **kwargs and map against prop_map 75 | self.name = name 76 | self.comment = comment 77 | self.stype = stype 78 | self.resource = resource 79 | self.next = snext 80 | self.seconds = seconds 81 | self.end = end 82 | self.timestamppath = timestamppath 83 | self.secondspath = secondspath 84 | self.parameters = parameters 85 | self.inputpath = inputpath 86 | self.outputpath = outputpath 87 | self.resultpath = resultpath 88 | 89 | if timestamp is not None: 90 | self.timestamp = timestamp.isoformat() #compliant ISO-8601 export 91 | else: 92 | self.timestamp = None 93 | 94 | self.validations = [] 95 | 96 | 97 | class StairStep(object): 98 | def __init__(self, 99 | comment = None, 100 | startAt = None 101 | ): 102 | self.states = {} 103 | self.comment = comment 104 | self.startAt = startAt 105 | 106 | def addState(self, state): 107 | self.states[state.name] = state 108 | 109 | def export(self): 110 | states = {} 111 | for k in self.states.keys(): 112 | states[k] = self.states[k].export() 113 | if self.comment is not None: 114 | ret = { 115 | "Comment" : self.comment, 116 | "StartAt" : self.startAt, 117 | "States" : states 118 | } 119 | else: 120 | ret = { 121 | "StartAt" : self.startAt, 122 | "States" : states 123 | } 124 | return ret 125 | 126 | def json(self): 127 | return json.dumps( self.export() ) -------------------------------------------------------------------------------- /stairstep/statetypes.py: -------------------------------------------------------------------------------- 1 | from .base import StateBase 2 | from .validations import * 3 | 4 | class StatePass(StateBase): 5 | def __init__(self, **kwargs): 6 | kwargs['stype'] = "Pass" 7 | super().__init__(**kwargs) 8 | 9 | self.validations += [ 10 | validation_none_terminal_must_have_next, 11 | validation_states_must_have_next_or_end 12 | ] 13 | 14 | class StateTask(StateBase): 15 | def __init__(self, **kwargs): 16 | kwargs['stype'] = "Task" 17 | super().__init__(**kwargs) 18 | 19 | self.validations += [ 20 | validation_states_must_have_next_or_end 21 | ] 22 | 23 | class StateWait(StateBase): 24 | def __init__(self, **kwargs): 25 | kwargs['stype'] = "Wait" 26 | super().__init__(**kwargs) 27 | 28 | self.validations += [ 29 | validation_must_contain_only_one_time_field, 30 | ] 31 | 32 | class StateSucceed(StateBase): 33 | def __init__(self, **kwargs): 34 | kwargs['stype'] = "Succeed" 35 | super().__init__(**kwargs) 36 | 37 | self.validations += [ 38 | validation_end_cannot_be_true 39 | ] 40 | 41 | 42 | class StateFail(StateBase): 43 | def __init__(self, **kwargs): 44 | kwargs['stype'] = "Fail" 45 | super().__init__(**kwargs) 46 | 47 | self.validations += [ 48 | validation_end_cannot_be_true, 49 | validation_cannot_have_io_path_fields 50 | ] -------------------------------------------------------------------------------- /stairstep/statetypes_choices.py: -------------------------------------------------------------------------------- 1 | import json 2 | from .base import StateBase 3 | from .validations import * 4 | 5 | class StateChoice(StateBase): 6 | def __init__(self, **kwargs): 7 | kwargs['stype'] = "Choice" 8 | 9 | self.choices = kwargs.pop("choices", None) 10 | self.default = kwargs.pop("default", None) 11 | 12 | super().__init__(**kwargs) 13 | self.validations += [ 14 | validation_end_cannot_be_true 15 | ] 16 | 17 | def export(self): 18 | #generate choices export 19 | return { 20 | 'Type' : "Choice", 21 | 'Default' : self.default, 22 | 'Choices' : [c.export() for c in self.choices] 23 | } 24 | 25 | class ChoiceRule: 26 | def __init__(self, operator=None, snext=None, conditions=None): 27 | self.operator = operator 28 | self.snext = snext 29 | self.conditions = conditions 30 | 31 | def export(self): 32 | if type(self.conditions) is not list: 33 | return { 34 | 'Next' : self.snext, 35 | self.operator : self.conditions.export() 36 | } 37 | else: 38 | multiple_conditions = [] 39 | for c in self.conditions: 40 | multiple_conditions.append( c.export() ) 41 | return { 42 | 'Next' : self.snext, 43 | self.operator : multiple_conditions 44 | } 45 | 46 | def json(self): 47 | return json.dumps( self.export() ) 48 | 49 | class ChoiceExpression: 50 | def __init__(self, operator=None, variable=None, value=None): 51 | self.operator = operator 52 | self.variable = variable 53 | self.value = value 54 | 55 | def export(self): 56 | return { 57 | "Variable" : self.variable, 58 | self.operator : self.value 59 | } 60 | 61 | def json(self): 62 | return json.dumps( self.export() ) -------------------------------------------------------------------------------- /stairstep/validations.py: -------------------------------------------------------------------------------- 1 | def validation_none_terminal_must_have_next(self): 2 | if (self.next is None) and (self.end is not True): 3 | raise AttributeError("All non-terminal states MUST have a Next field") 4 | 5 | def validation_states_must_have_next_or_end(self): 6 | if (self.next is None) and (self.end is None): 7 | raise AttributeError("State must have either an End or Next field") 8 | 9 | def validation_states_cant_have_both_end_and_next(self): 10 | if (self.next is not None) and (self.end is not None): 11 | raise AttributeError("State cannot have both an End and Next field") 12 | 13 | def validation_name_cannot_be_longer_than_128(self): 14 | if len(self.name) > 128: 15 | raise AttributeError("State name cannot be longer than 128 charecters") 16 | 17 | def validation_all_states_must_have_type(self): 18 | if self.stype is None: 19 | raise AttributeError("State must have Type") 20 | 21 | def validation_end_cannot_be_true(self): 22 | if self.end is True: 23 | raise AttributeError("End cannot be True for this State Type") 24 | 25 | def validation_cannot_have_io_path_fields(self): 26 | if self.inputpath is not None: 27 | raise AttributeError("Fail State cannot have InputPath, OutputPath") 28 | 29 | if self.output is not None: 30 | raise AttributeError("Fail State cannot have InputPath, OutputPath") 31 | 32 | def validation_must_contain_only_one_time_field(self): 33 | fields = [self.seconds, self.secondspath, self.timestamp, self.timestamppath] 34 | 35 | fields_count = 0 36 | for f in fields: 37 | if f is not None: 38 | fields_count = fields_count + 1 39 | 40 | if fields_count != 1: 41 | raise AttributeError("Wait state must contain exactly one of Seconds, SecondsPath, Timestamp, or TimestampPath") -------------------------------------------------------------------------------- /test.json: -------------------------------------------------------------------------------- 1 | {"Comment": "A simple minimal example of the States language", "StartAt": "Hello World", "States": {"Hello World": {"Type": "Task", "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", "End": true}}} -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamgilman/stairstep/e1c64149feb75d2f192a5bf42c6287ca2905fdee/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_01singlestep.py: -------------------------------------------------------------------------------- 1 | import unittest, json 2 | from stairstep import StairStep, StateTask, StateSucceed 3 | 4 | class TestStepFunctionWithSingleStep(unittest.TestCase): 5 | def test_single_state(self): 6 | self.maxDiff = None 7 | output = ''' 8 | { 9 | "Comment": "A simple minimal example of the States language", 10 | "StartAt": "Hello World", 11 | "States": { 12 | "Hello World": { 13 | "Type": "Task", 14 | "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 15 | "Next": "nextResource" 16 | } 17 | } 18 | } 19 | ''' 20 | #compress and remove whitespace 21 | output = json.dumps( json.loads(output) ) 22 | ss = StairStep( 23 | comment = "A simple minimal example of the States language", 24 | startAt = "Hello World", 25 | ) 26 | hello_step = StateTask( 27 | name = "Hello World", 28 | resource = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 29 | snext = "nextResource" 30 | ) 31 | 32 | ss.addState(hello_step) 33 | self.assertEqual(output, ss.json()) 34 | 35 | def test_single_state_comment_optional(self): 36 | output = '''{ 37 | "StartAt": "Hello World", 38 | "States": { 39 | "Hello World": { 40 | "Type": "Succeed", 41 | "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 42 | "Next" : "nextResource" 43 | } 44 | } 45 | }''' 46 | #compress and remove whitespace 47 | output = json.dumps( json.loads(output) ) 48 | ss = StairStep( 49 | startAt = "Hello World", 50 | ) 51 | hello_step = StateSucceed( 52 | name = "Hello World", 53 | resource = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 54 | snext = "nextResource" 55 | ) 56 | 57 | ss.addState(hello_step) 58 | self.assertEqual(output, ss.json()) 59 | 60 | class TestStepFunctionWithoutSteps(unittest.TestCase): 61 | def setUp(self): 62 | self.output = { 63 | "Type": "Succeed", 64 | "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 65 | "Next" : "nextResource" 66 | } 67 | 68 | def test_no_states(self): 69 | hello_step = StateSucceed( 70 | name = "Hello World", 71 | resource = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 72 | snext = "nextResource" 73 | ) 74 | self.assertEqual( hello_step.export(), self.output ) 75 | -------------------------------------------------------------------------------- /tests/test_02stateoutput.py: -------------------------------------------------------------------------------- 1 | import unittest, json 2 | from stairstep import StairStep, StateTask, StatePass, StateChoice, StateSucceed, StateWait 3 | 4 | class TestStateOutputPass(unittest.TestCase): 5 | def setUp(self): 6 | self.ss = StairStep( 7 | comment = "A simple minimal example of the States language", 8 | startAt = "HelloWorld", 9 | ) 10 | self.state = StatePass( 11 | name = "HelloWorld", 12 | comment = "Pass State example", 13 | end = True 14 | ) 15 | self.ss.addState(self.state) 16 | 17 | def test_output(self): 18 | self.maxDiff = None 19 | #validated by statelint 20 | output = ''' 21 | { 22 | "Comment": "A simple minimal example of the States language", 23 | "StartAt": "HelloWorld", 24 | "States": { 25 | "HelloWorld": { 26 | "Type": "Pass", 27 | "Comment": "Pass State example", 28 | "End": true 29 | } 30 | } 31 | } 32 | ''' 33 | 34 | output = json.loads(output) 35 | result = json.loads( self.ss.json() ) 36 | self.assertDictEqual(output, result) 37 | 38 | class TestStateOutputTask(unittest.TestCase): 39 | def setUp(self): 40 | self.ss = StairStep( 41 | comment = "A simple minimal example of the States language", 42 | startAt = "HelloWorld", 43 | ) 44 | self.state = StateTask( 45 | name = "HelloWorld", 46 | comment = "Task State example", 47 | resource = "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 48 | end = True 49 | ) 50 | self.ss.addState(self.state) 51 | 52 | def test_output(self): 53 | self.maxDiff = None 54 | #validated by statelint 55 | output = ''' 56 | { 57 | "Comment": "A simple minimal example of the States language", 58 | "StartAt": "HelloWorld", 59 | "States": { 60 | "HelloWorld": { 61 | "Type": "Task", 62 | "Comment": "Task State example", 63 | "Resource": "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 64 | "End": true 65 | } 66 | } 67 | } 68 | ''' 69 | 70 | output = json.loads(output) 71 | result = json.loads( self.ss.json() ) 72 | self.assertDictEqual(output, result) 73 | 74 | class TestStateOutputWait(unittest.TestCase): 75 | def setUp(self): 76 | self.ss = StairStep( 77 | comment = "A simple minimal example of the States language", 78 | startAt = "HelloWorld", 79 | ) 80 | 81 | def test_output_delay(self): 82 | self.state = StateWait( 83 | name = "wait_ten_seconds", 84 | seconds = 10, 85 | snext = "NextState" 86 | ) 87 | self.ss.addState(self.state) 88 | 89 | self.maxDiff = None 90 | output = ''' 91 | { 92 | "Comment": "A simple minimal example of the States language", 93 | "StartAt": "HelloWorld", 94 | "States": { 95 | "wait_ten_seconds" : { 96 | "Type" : "Wait", 97 | "Seconds" : 10, 98 | "Next": "NextState" 99 | } 100 | } 101 | } 102 | ''' 103 | 104 | output = json.loads(output) 105 | result = json.loads( self.ss.json() ) 106 | self.assertDictEqual(output, result) 107 | 108 | def test_output_absolute(self): 109 | from datetime import datetime, timezone 110 | 111 | wait_until = datetime(year=2016, month=3, day=14, hour=1, minute=59, second=0, tzinfo=timezone.utc) 112 | self.state = StateWait( 113 | name = "wait_until", 114 | timestamp = wait_until, 115 | snext = "NextState" 116 | ) 117 | self.ss.addState(self.state) 118 | 119 | self.maxDiff = None 120 | #python datetime ISO export doesn't suppport Zulu, changed to +00 121 | output = ''' 122 | { 123 | "Comment": "A simple minimal example of the States language", 124 | "StartAt": "HelloWorld", 125 | "States": { 126 | "wait_until" : { 127 | "Type": "Wait", 128 | "Timestamp": "2016-03-14T01:59:00+00:00", 129 | "Next": "NextState" 130 | } 131 | } 132 | } 133 | ''' 134 | 135 | output = json.loads(output) 136 | result = json.loads( self.ss.json() ) 137 | self.assertDictEqual(output, result) 138 | 139 | def test_output_reference_time(self): 140 | from datetime import datetime, timezone 141 | 142 | self.state = StateWait( 143 | name = "wait_until", 144 | timestamppath = "$.expirydate", 145 | snext = "NextState" 146 | ) 147 | self.ss.addState(self.state) 148 | 149 | self.maxDiff = None 150 | #python datetime ISO export doesn't suppport Zulu, changed to +00 151 | output = ''' 152 | { 153 | "Comment": "A simple minimal example of the States language", 154 | "StartAt": "HelloWorld", 155 | "States": { 156 | "wait_until" : { 157 | "Type": "Wait", 158 | "TimestampPath": "$.expirydate", 159 | "Next": "NextState" 160 | } 161 | } 162 | } 163 | ''' 164 | 165 | output = json.loads(output) 166 | result = json.loads( self.ss.json() ) 167 | self.assertDictEqual(output, result) 168 | 169 | def test_output_reference_seconds(self): 170 | from datetime import datetime, timezone 171 | 172 | self.state = StateWait( 173 | name = "wait_until", 174 | secondspath = "$.seconds", 175 | snext = "NextState" 176 | ) 177 | self.ss.addState(self.state) 178 | 179 | self.maxDiff = None 180 | #python datetime ISO export doesn't suppport Zulu, changed to +00 181 | output = ''' 182 | { 183 | "Comment": "A simple minimal example of the States language", 184 | "StartAt": "HelloWorld", 185 | "States": { 186 | "wait_until" : { 187 | "Type": "Wait", 188 | "SecondsPath": "$.seconds", 189 | "Next": "NextState" 190 | } 191 | } 192 | } 193 | ''' 194 | 195 | output = json.loads(output) 196 | result = json.loads( self.ss.json() ) 197 | self.assertDictEqual(output, result) 198 | 199 | class TestStateOutputSucceed(unittest.TestCase): 200 | def setUp(self): 201 | self.ss = StairStep( 202 | comment = "A simple minimal example of the States language", 203 | startAt = "HelloWorld", 204 | ) 205 | self.state = StateSucceed( 206 | name = "HelloWorld", 207 | comment = "Succeed State example", 208 | ) 209 | self.ss.addState(self.state) 210 | 211 | def test_output(self): 212 | self.maxDiff = None 213 | #validated by statelint 214 | output = ''' 215 | { 216 | "Comment": "A simple minimal example of the States language", 217 | "StartAt": "HelloWorld", 218 | "States": { 219 | "HelloWorld": { 220 | "Type": "Succeed", 221 | "Comment": "Succeed State example" 222 | } 223 | } 224 | } 225 | ''' 226 | 227 | output = json.loads(output) 228 | result = json.loads( self.ss.json() ) 229 | self.assertDictEqual(output, result) 230 | 231 | class TestStateMultiTaskOutput(unittest.TestCase): 232 | def setUp(self): 233 | self.ss = StairStep( 234 | comment = "A simple minimal example of the States language", 235 | startAt = "HelloWorld", 236 | ) 237 | self.first = StateTask( 238 | name = "HelloWorld", 239 | comment = "Task State example", 240 | resource = "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 241 | snext = "SecondState" 242 | ) 243 | self.ss.addState(self.first) 244 | 245 | self.second = StateTask( 246 | name = "SecondState", 247 | comment = "Task State example", 248 | resource = "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 249 | end = True 250 | ) 251 | self.ss.addState(self.second) 252 | 253 | 254 | def test_output(self): 255 | self.maxDiff = None 256 | #validated by statelint 257 | output = ''' 258 | { 259 | "Comment": "A simple minimal example of the States language", 260 | "StartAt": "HelloWorld", 261 | "States": { 262 | "HelloWorld": { 263 | "Type": "Task", 264 | "Comment": "Task State example", 265 | "Resource": "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 266 | "Next": "SecondState" 267 | }, 268 | "SecondState": { 269 | "Type": "Task", 270 | "Comment": "Task State example", 271 | "Resource": "arn:aws:swf:us-east-1:123456789012:task:HelloWorld", 272 | "End": true 273 | } 274 | } 275 | } 276 | ''' 277 | 278 | output = json.loads(output) 279 | result = json.loads( self.ss.json() ) 280 | self.assertDictEqual(output, result) -------------------------------------------------------------------------------- /tests/test_03fieldValidation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import TestCase 3 | from stairstep import StatePass, StateTask, StateChoice, StateSucceed, StateFail, StateWait 4 | from stairstep.base import StateBase 5 | 6 | from stairstep.validations import * 7 | 8 | class FieldValidationsTests(unittest.TestCase): 9 | def setUp(self): 10 | self.state = StateBase() 11 | 12 | def test_non_terminal_must_have_next(self): 13 | self.state.next = None 14 | with self.assertRaises(AttributeError): 15 | validation_none_terminal_must_have_next(self.state) 16 | 17 | self.state.end = True 18 | validation_none_terminal_must_have_next(self.state) 19 | 20 | def test_states_must_have_next_or_end(self): 21 | self.state.next = None 22 | self.state.end = None 23 | with self.assertRaises(AttributeError): 24 | validation_states_must_have_next_or_end(self.state) 25 | 26 | def test_states_cant_have_both_end_and_next(self): 27 | self.state.next = "NextResource" 28 | self.state.end = True 29 | with self.assertRaises(AttributeError): 30 | validation_states_cant_have_both_end_and_next(self.state) 31 | 32 | def test_name_longer_than_128(self): 33 | self.state.name = "a" * 129 34 | with self.assertRaises(AttributeError): 35 | validation_name_cannot_be_longer_than_128(self.state) 36 | 37 | def test_states_must_have_type(self): 38 | self.state.stype = None 39 | with self.assertRaises(AttributeError): 40 | validation_all_states_must_have_type(self.state) 41 | 42 | def test_end_cannot_be_true(self): 43 | self.state.end = True 44 | with self.assertRaises(AttributeError): 45 | validation_end_cannot_be_true(self.state) 46 | 47 | def test_not_allowed_to_have_path_fields(self): 48 | self.state.inputpath = "$.ipath" 49 | with self.assertRaises(AttributeError): 50 | validation_cannot_have_io_path_fields(self.state) 51 | 52 | self.state.inputpath = None 53 | 54 | self.state.outputpath = "$.opath" 55 | with self.assertRaises(AttributeError): 56 | validation_cannot_have_io_path_fields(self.state) 57 | 58 | class StateFieldValidationsTests(unittest.TestCase): 59 | def setUp(self): 60 | self.state = StateWait( 61 | snext = "NextField" 62 | ) 63 | #A Wait state MUST contain exactly one of Seconds, SecondsPath, Timestamp, or TimestampPath. 64 | 65 | def test_wait_must_contain_time_field(self): 66 | #has no fields 67 | with self.assertRaises(AttributeError): 68 | validation_must_contain_only_one_time_field(self.state) 69 | 70 | def test_wait_must_contain_has_at_least_one(self): 71 | self.state.secondspath = "$.seconds" 72 | validation_must_contain_only_one_time_field(self.state) 73 | 74 | def test_wait_must_contain_only_one(self): 75 | self.state.seconds = 11 76 | self.state.secondspath = "$.seconds" 77 | with self.assertRaises(AttributeError): 78 | validation_must_contain_only_one_time_field(self.state) 79 | 80 | 81 | class StateTestCases(unittest.TestCase): 82 | class CommonTests(unittest.TestCase): 83 | def test_all_state_validations(self): 84 | all_validations = [ 85 | validation_states_cant_have_both_end_and_next, 86 | validation_name_cannot_be_longer_than_128, 87 | validation_all_states_must_have_type 88 | ] 89 | 90 | for v in all_validations: 91 | self.assertIn(v, self.state.base_validations) 92 | 93 | #TODO allowed_operators = ["StringEquals","StringLessThan","StringGreaterThan","StringLessThanEquals","StringGreaterThanEquals","NumericEquals","NumericLessThan","NumericGreaterThan","NumericLessThanEquals","NumericGreaterThanEquals","BooleanEquals","TimestampEquals","TimestampLessThan","TimestampGreaterThan","TimestampLessThanEquals","TimestampGreaterThanEquals","And","Or","Not"] 94 | 95 | class TestPassStateValidations(StateTestCases.CommonTests): 96 | def setUp(self): 97 | self.state = StatePass() 98 | def test_required_validations(self): 99 | required = [ 100 | validation_none_terminal_must_have_next, 101 | validation_states_must_have_next_or_end 102 | ] 103 | self.assertCountEqual(required, self.state.validations) 104 | 105 | class TestChoiceStateValidations(StateTestCases.CommonTests): 106 | def setUp(self): 107 | self.state = StateChoice() 108 | def test_required_validations(self): 109 | required = [ 110 | validation_end_cannot_be_true 111 | ] 112 | self.assertCountEqual(required, self.state.validations) 113 | 114 | class TestSucceedStateValidations(StateTestCases.CommonTests): 115 | def setUp(self): 116 | self.state = StateSucceed() 117 | def test_required_validations(self): 118 | required = [ 119 | validation_end_cannot_be_true 120 | ] 121 | self.assertCountEqual(required, self.state.validations) 122 | 123 | class TestFailStateValidations(StateTestCases.CommonTests): 124 | def setUp(self): 125 | self.state = StateFail() 126 | def test_required_validations(self): 127 | required = [ 128 | validation_end_cannot_be_true, 129 | validation_cannot_have_io_path_fields 130 | ] 131 | self.assertCountEqual(required, self.state.validations) 132 | 133 | class TestWaitStateValidations(StateTestCases.CommonTests): 134 | def setUp(self): 135 | self.state = StateWait() 136 | def test_required_validations(self): 137 | required = [ 138 | validation_must_contain_only_one_time_field 139 | ] 140 | self.assertCountEqual(required, self.state.validations) -------------------------------------------------------------------------------- /tests/test_04choicestate.py: -------------------------------------------------------------------------------- 1 | import unittest, json 2 | from stairstep import StairStep 3 | from stairstep import StateChoice, ChoiceRule, ChoiceExpression 4 | 5 | #each element of a choice is a choice rule 6 | #containing a comparasion and a next 7 | 8 | class TestChoiceStateRule(unittest.TestCase): 9 | def setUp(self): 10 | self.maxDiff = None 11 | self.ss = StairStep( 12 | comment = "Example Choice State", 13 | startAt = "ChoiceStateX" 14 | ) 15 | typeNotPrivate = ChoiceRule(operator="Not", snext="Public", conditions= 16 | ChoiceExpression(operator="StringEquals", variable="$.type", value="Private") 17 | ) 18 | 19 | valueInTwenties = ChoiceRule(operator="And", snext="ValueInTwenties", conditions=[ 20 | ChoiceExpression(operator="NumericGreaterThanEquals", variable="$.value", value=20), 21 | ChoiceExpression(operator="NumericLessThan", variable="$.value", value=30)] 22 | ) 23 | 24 | self.state = StateChoice( 25 | name = "ChoiceStateX", 26 | choices = [typeNotPrivate, valueInTwenties], 27 | default = "DefaultState" 28 | ) 29 | self.ss.addState(self.state) 30 | 31 | def test_choice_state(self): 32 | output = ''' 33 | { 34 | "Comment": "Example Choice State", 35 | "StartAt": "ChoiceStateX", 36 | "States": { 37 | "ChoiceStateX": { 38 | "Type" : "Choice", 39 | "Choices": [ 40 | { 41 | "Not": { 42 | "Variable": "$.type", 43 | "StringEquals": "Private" 44 | }, 45 | "Next": "Public" 46 | }, 47 | { 48 | "And": [ 49 | { 50 | "Variable": "$.value", 51 | "NumericGreaterThanEquals": 20 52 | }, 53 | { 54 | "Variable": "$.value", 55 | "NumericLessThan": 30 56 | } 57 | ], 58 | "Next": "ValueInTwenties" 59 | } 60 | ], 61 | "Default": "DefaultState" 62 | } 63 | 64 | } 65 | } 66 | ''' 67 | output = json.loads(output) 68 | result = json.loads( self.ss.json() ) 69 | self.assertDictEqual(output, result) 70 | 71 | class TestChoiceExpressionSubset(unittest.TestCase): 72 | def setUp(self): 73 | self.choice_expression = ChoiceExpression(operator="StringEquals", variable="$.type", value="Private") 74 | 75 | def test_choice_expression_output(self): 76 | output = ''' 77 | { 78 | "Variable": "$.type", 79 | "StringEquals": "Private" 80 | } 81 | ''' 82 | output = json.loads(output) 83 | result = json.loads( self.choice_expression.json() ) 84 | self.assertDictEqual(output, result) 85 | 86 | class TestChoiceRuleSubset(unittest.TestCase): 87 | def setUp(self): 88 | pass 89 | 90 | def test_choice_rule_output_single(self): 91 | choice_rule = ChoiceRule(operator="Not", snext="Public", conditions= 92 | ChoiceExpression(operator="StringEquals", variable="$.type", value="Private") 93 | ) 94 | output = ''' 95 | { 96 | "Not": { 97 | "Variable": "$.type", 98 | "StringEquals": "Private" 99 | }, 100 | "Next": "Public" 101 | }''' 102 | output = json.loads(output) 103 | result = json.loads( choice_rule.json() ) 104 | self.assertDictEqual(output, result) 105 | 106 | def test_choice_rule_output_multiple_conditions(self): 107 | choice_rule = ChoiceRule(operator="And", snext="ValueInTwenties", conditions=[ 108 | ChoiceExpression(operator="NumericGreaterThanEquals", variable="$.value", value=20), 109 | ChoiceExpression(operator="NumericLessThan", variable="$.value", value=30) 110 | ] 111 | ) 112 | 113 | output = ''' 114 | { 115 | "And": [ 116 | { 117 | "Variable": "$.value", 118 | "NumericGreaterThanEquals": 20 119 | }, 120 | { 121 | "Variable": "$.value", 122 | "NumericLessThan": 30 123 | } 124 | ], 125 | "Next": "ValueInTwenties" 126 | }''' 127 | output = json.loads(output) 128 | result = json.loads( choice_rule.json() ) 129 | self.assertDictEqual(output, result) -------------------------------------------------------------------------------- /tests/test_05parameters.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from stairstep import StairStep, StatePass, StateTask 3 | import json 4 | 5 | class TestStepFunctionWithParameters(unittest.TestCase): 6 | def test_single_state_with_inputs(self): 7 | self.maxDiff = None 8 | output = ''' 9 | { 10 | "Comment": "A simple minimal example of the States language", 11 | "StartAt": "Hello World", 12 | "States": { 13 | "Hello World": { 14 | "Type": "Task", 15 | "Resource": "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 16 | "Next": "nextResource", 17 | "InputPath": "$.lambda", 18 | "OutputPath": "$.data", 19 | "ResultPath": "$.data.lambdaresult" 20 | } 21 | } 22 | } 23 | ''' 24 | #compress and remove whitespace 25 | output = json.dumps( json.loads(output) ) 26 | ss = StairStep( 27 | comment = "A simple minimal example of the States language", 28 | startAt = "Hello World", 29 | ) 30 | hello_step = StateTask( 31 | name = "Hello World", 32 | resource = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld", 33 | snext = "nextResource", 34 | inputpath = "$.lambda", 35 | outputpath = "$.data", 36 | resultpath = "$.data.lambdaresult" 37 | ) 38 | 39 | ss.addState(hello_step) 40 | self.assertEqual(output, ss.json()) 41 | -------------------------------------------------------------------------------- /tests/test_stairstep.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from stairstep import StairStep, StatePass 3 | 4 | class TestStairStepObject(unittest.TestCase): 5 | def setUp(self): 6 | pass 7 | 8 | def test_base_object(self): 9 | ss = StairStep() 10 | self.assertIsInstance(ss, StairStep) 11 | 12 | class TestStairStepIndempodent(unittest.TestCase): 13 | def setUp(self): 14 | self.ss = StairStep( 15 | comment = "A simple minimal example of the States language", 16 | startAt = "HelloWorld", 17 | ) 18 | self.state = StatePass( 19 | name = "HelloWorld", 20 | comment = "Pass State example", 21 | end = True 22 | ) 23 | self.ss.addState(self.state) 24 | 25 | def test_export_is_indempodent(self): 26 | self.ss.export() 27 | self.ss.export() 28 | --------------------------------------------------------------------------------