├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------