├── .gitignore ├── LICENSE.md ├── README.md ├── example_lambda.py ├── example_template.yaml ├── faas_form ├── __init__.py ├── _version ├── cli.py ├── faas.py ├── getch.py ├── payloads.py └── schema.py ├── setup.py └── tests ├── __init__.py └── test_schema.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | Pipfile* 61 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "{}" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright {yyyy} {name of copyright owner} 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # faas-form 2 | 3 | A command line tool invoking self-describing Lambda functions. 4 | It is intended to help developers and administrators provide interfaces to Lambdas that are designed to be invoked directly by users. 5 | This allows Lambda functions to replace client-side scripts for interactions with resources running on AWS. 6 | It does not aim to support arbitrarily complex input schemas, but it does support multi-step workflows. 7 | 8 | ## Quickstart 9 | ``` 10 | 11 | $ git clone https://github.com/benkehoe/faas-form.git faas-form 12 | $ cd faas-form 13 | 14 | $ aws cloudformation package --s3-bucket YOUR-BUCKET-NAME --template-file example_template.yaml --output-template-file example_template_packaged.yaml 15 | 16 | $ aws cloudformation deploy --template example_template_packaged.yaml --stack-name faas-form-example --capabilities CAPABILITY_IAM 17 | 18 | $ faas-form ls 19 | faas-form-example an example faas-form compatible lambda function 20 | 21 | $ faas-form invoke faas-form-example 22 | Hello! Thanks for trying faas-form. 23 | name [string] Enter your name. Try "Merlin" to see advanced features (required=True): 24 | ``` 25 | 26 | ## Creation 27 | 28 | See the `example_lambda.py` Lambda function handler (and correponding SAM template `example_template.yaml` to deploy it). 29 | 30 | A `faas-form`-compatible Lambda function is one that can report a simple schema for its input. When invoked with an event of the form: 31 | 32 | ``` 33 | { 34 | "x-faas-form-payload": "schema" 35 | } 36 | ``` 37 | 38 | This can be tested for in the handler with the `faas_form.is_schema_request(event)` function. 39 | 40 | The Lambda must return an object that looks like: 41 | 42 | ``` 43 | { 44 | "x-faas-form-schema": { 45 | "schema_version": "2018-04-01", 46 | "instructions": , 47 | "inputs": [ 48 | ... 49 | ] 50 | } 51 | } 52 | ``` 53 | 54 | Each input corresponds to a field in the event object that the Lambda expects. Each input has a name, corresponding to the field name, a input type, and an optional help field to display when prompting the user for a value. 55 | 56 | The client then prompts the user for values for the inputs, assembles them into an object and invokes the Lambda, including the field `"x-faas-form-payload": "invoke"` in the request. 57 | This can be tested for in the handler with the `faas_form.is_invoke_request(event)` function. 58 | 59 | The Lambda processes the event, and returns a result to the client. Normally, the client will print the result object, but if the Lambda wants to control this output, it can set the field `x-faas-form-result` in the result object, and this will be printed instead. 60 | This can also be set using `faas_form.set_result(response)`. 61 | 62 | ### Multi-step workflows 63 | 64 | After the first invocation, the Lambda can re-prompt the user for more input. In the result object it returns, it can set the field `"x-faas-form-payload": "reinvoke"` (or using `faas_form.set_reinvoke_response(response)`), and then must also include a schema (under `x-faas-form-schema`). 65 | The client will prompt the user with the new schema, and invoke the Lambda with the data. 66 | The `const` input type can be useful in the scenario for keeping state between requests or to track the steps in the process. 67 | 68 | ### Input types 69 | 70 | #### String inputs 71 | ``` 72 | { 73 | "type": "string", 74 | "name": , 75 | "pattern": , 76 | "help": , 77 | "default": , 78 | "required": 79 | } 80 | ``` 81 | The default value, if given, will be used when the user inputs an empty string. 82 | 83 | ### Secret inputs 84 | ``` 85 | { 86 | "type": "secret", 87 | "name": , 88 | "pattern": , 89 | "help": , 90 | "required": 91 | } 92 | ``` 93 | Like the string input, but will not echo when prompting the user, and does not accept a default. 94 | 95 | ### Number inputs 96 | ``` 97 | { 98 | "type": "number", 99 | "name": , 100 | "integer": , 101 | "help": , 102 | "default": , 103 | "required": 104 | } 105 | ``` 106 | 107 | ### Boolean inputs 108 | ``` 109 | { 110 | "type": "boolean", 111 | "name": , 112 | "help": , 113 | "required": 114 | } 115 | ``` 116 | 117 | ### String list inputs 118 | ``` 119 | { 120 | "type": "list", 121 | "name": , 122 | "size": , 123 | "pattern": , 124 | "help": , 125 | "required": 126 | } 127 | ``` 128 | 129 | ### Const inputs 130 | ``` 131 | { 132 | "type": "const", 133 | "value": 134 | } 135 | ``` 136 | 137 | ## Tagging 138 | 139 | `faas-form`-compatible Lambdas can be made discoverable through two mechanisms: a resource tag on the Lambda or an entry in the Lambda's environment variables. 140 | In either case, the key must be `faasform` and the value is an optional short description. 141 | The environment variable form is available for situations where tagging is not desired. 142 | 143 | ## Usage 144 | 145 | ### Discovery 146 | 147 | ```bash 148 | faas-form ls [--tags/--no-tags] [--env/--no-env] 149 | ``` 150 | 151 | Lists the available `faas-form`-compatible Lambdas and their descriptions (if any). 152 | By default, only checks tags. Use the flags to control whether it searches tags or environment variables. 153 | 154 | ### Invocation 155 | 156 | ```bash 157 | faas-form invoke FUNCTION_NAME 158 | ``` 159 | 160 | Request the schema from the given function, prompt for the inputs, invoke the function, and print the response. Optionally, a schema can be provided with the `--schema` flag, which will cause the schema query step to be skipped. 161 | 162 | ### Development 163 | 164 | ```bash 165 | faas-form prompt --schema SCHEMA [--output-file FILE] 166 | ``` 167 | 168 | Take the given schema, prompt for values, and print or store the resulting object. The schema object can have the top-level `x-faas-form-schema` key, or simply be the object that would be under that key. 169 | 170 | ```bash 171 | faas-form prompt --function FUNCTION_NAME [--output-file FILE] 172 | ``` 173 | 174 | Query the given function for its schema, prompt for values, and print or store the resulting object. 175 | 176 | ### Admin 177 | 178 | ```bash 179 | faas-form admin add FUNCTION_NAME [--description DESCRIPTION] 180 | ``` 181 | 182 | Tag the given function as a `faas-form`-compatible Lambda, optionally with a short description. 183 | 184 | 185 | ```bash 186 | faas-form admin rm FUNCTION_NAME 187 | ``` 188 | 189 | Remove the tag marking the given function as a `faas-form`-compatible Lambda. Note this does not work with Lambdas marked using environment variables. 190 | 191 | ```bash 192 | faas-form admin show FUNCTION_NAME 193 | ``` 194 | 195 | Query the given function for its schema and print it. 196 | 197 | ## Status 198 | 199 | Currently in a working state with Python 3. Tests for schema are done. Still to do: 200 | 201 | * Tests for `faas` module 202 | * Tests for `cli` module 203 | * Ensure Python 2 compatibility 204 | -------------------------------------------------------------------------------- /example_lambda.py: -------------------------------------------------------------------------------- 1 | import faas_form 2 | 3 | SIMPLE_SCHEMA = faas_form.Schema( 4 | [ 5 | faas_form.ConstInput('event_type', value='simple', help="The user doesn't see this"), 6 | faas_form.StringInput('name', required=True, 7 | help='Enter your name. Try "Merlin" to see advanced features'), 8 | ], 9 | instructions="Hello! Thanks for trying faas-form." 10 | ) 11 | 12 | ADVANCED_SCHEMA = faas_form.Schema( 13 | [ 14 | faas_form.ConstInput('event_type', value='advanced', help="The user doesn't see this"), 15 | faas_form.StringInput('required', required=True, help='Try entering an empty string, or ctrl-D'), 16 | faas_form.StringInput('not_required', required=False, help='Try entering an empty string, or ctrl-D'), 17 | faas_form.StringInput('lowercase_only', pattern=r'^[a-z]$', help='Try entering upcase letters'), 18 | faas_form.StringInput('with_default', default='DEFAULT_VALUE', help='If you enter an empty string or ctrl-D, a default value will be used'), 19 | faas_form.SecretInput('shhh', help="Tell me a secret. I won't tell!"), 20 | faas_form.NumberInput('num', help="Enter a float"), 21 | faas_form.NumberInput('num_int', integer=True, help="Try entering a non-integer value"), 22 | faas_form.StringListInput('strings', help='Enter an empty string or ctrl-D to terminate the list'), 23 | faas_form.StringListInput('strings_with_size', size=2, help='This list has to have two elements'), 24 | faas_form.BooleanInput('result', help="Hit y have the Lambda send a result string to display"), 25 | faas_form.BooleanInput('again', help="Would you like to go through this again?"), 26 | ], 27 | instructions="This is the advanced example." 28 | ) 29 | 30 | def handler(event, context): 31 | print('Event:') 32 | print(event) 33 | if faas_form.is_schema_request(event): 34 | print('Returning simple schema') 35 | response = {} 36 | faas_form.set_schema_reponse(response, SIMPLE_SCHEMA) 37 | elif 'event_type' not in event: 38 | raise ValueError("Input event is invalid!") 39 | elif event['event_type'] == 'simple': 40 | print('Handling simple schema') 41 | response = handle_simple(event, context) 42 | else: 43 | print('Handling advanced schema') 44 | response = handle_advanced(event, context) 45 | print('Response:') 46 | print(response) 47 | return response 48 | 49 | def handle_simple(event, context): 50 | name = event['name'] 51 | result = 'Hello, {}!'.format(name) 52 | response = {} 53 | if name == 'Merlin': 54 | faas_form.set_reinvoke_response(response, ADVANCED_SCHEMA, result) 55 | else: 56 | faas_form.set_result(response, result) 57 | return response 58 | 59 | def handle_advanced(event, context): 60 | response = { 61 | 'received_event': event, 62 | 'foo': 'bar', 63 | } 64 | if event['result']: 65 | faas_form.set_result(response, result='This is a short summary from the Lambda, instead of the response payload.') 66 | if event['again']: 67 | faas_form.set_reinvoke_response(response, ADVANCED_SCHEMA) 68 | return response 69 | -------------------------------------------------------------------------------- /example_template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | 4 | Resources: 5 | FaaSFormExampleFunction: 6 | Type: AWS::Serverless::Function 7 | Properties: 8 | FunctionName: faas-form-example 9 | CodeUri: . 10 | Runtime: python3.6 11 | Handler: example_lambda.handler 12 | Tags: 13 | faasform: "an example faas-form compatible lambda function" 14 | -------------------------------------------------------------------------------- /faas_form/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | 3 | def _get_version(): 4 | import pkg_resources, codecs 5 | if not pkg_resources.resource_exists(__name__, '_version'): 6 | return '0.0.0' 7 | return codecs.decode(pkg_resources.resource_string(__name__, '_version'),'utf-8').strip() 8 | __version__ = _get_version() 9 | 10 | from faas_form.payloads import (is_schema_request, 11 | set_schema_reponse, 12 | is_invoke_request, 13 | set_result, 14 | set_reinvoke_response) 15 | from .schema import * -------------------------------------------------------------------------------- /faas_form/_version: -------------------------------------------------------------------------------- 1 | 0.2.0 2 | -------------------------------------------------------------------------------- /faas_form/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mar 31, 2018 3 | 4 | @author: bkehoe 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | 9 | import six 10 | 11 | import argparse 12 | import json 13 | import sys 14 | 15 | from . import faas 16 | from .schema import Schema 17 | from . import payloads 18 | 19 | def main(args=None): 20 | parser = argparse.ArgumentParser() 21 | 22 | subparsers = parser.add_subparsers() 23 | 24 | kwargs = {'aliases':['list']} if six.PY3 else {} 25 | kwargs['help'] = 'Find faas-form compatible functions' 26 | list_parser = subparsers.add_parser('ls', **kwargs) 27 | 28 | tags_group = list_parser.add_mutually_exclusive_group() 29 | tags_group.add_argument('--tags', action='store_true', default=None, help='Search tags') 30 | tags_group.add_argument('--no-tags', action='store_false', dest='tags', help='Do not search tags') 31 | 32 | env_group = list_parser.add_mutually_exclusive_group() 33 | env_group.add_argument('--env', action='store_true', default=None, help='Search env vars') 34 | env_group.add_argument('--no-env', action='store_false', dest='env', help='Do not search env vars') 35 | 36 | list_parser.set_defaults(func=run_list_funcs) 37 | 38 | invoke_parser = subparsers.add_parser('invoke', help='Call a faas-form compatible function') 39 | invoke_parser.add_argument('name', help='The function to invoke') 40 | invoke_parser.add_argument('--no-reinvoke', action='store_true', default=False, help='Disable reinvoke functionality') 41 | invoke_parser.add_argument('--schema', type=json.loads, help='Use the given schema instead of querying the function') 42 | invoke_parser.set_defaults(func=run_invoke) 43 | 44 | prompt_parser = subparsers.add_parser('prompt', help='Generate an event from a schema') 45 | input_group = prompt_parser.add_mutually_exclusive_group(required=True) 46 | input_group.add_argument('--schema', type=json.loads) 47 | input_group.add_argument('--function') 48 | prompt_parser.add_argument('--output-file', '-o', type=argparse.FileType('w')) 49 | prompt_parser.set_defaults(func=run_prompt) 50 | 51 | admin_parser = subparsers.add_parser('admin', help='Tag functions as faas-form compatible') 52 | admin_subparsers = admin_parser.add_subparsers() 53 | 54 | add_parser = admin_subparsers.add_parser('add', help='Tag a function as faas-form compatible') 55 | add_parser.add_argument('name') 56 | add_parser.add_argument('--description') 57 | add_parser.set_defaults(func=run_admin_add) 58 | 59 | rm_parser = admin_subparsers.add_parser('rm', help='Untag a function as faas-form compatible') 60 | rm_parser.add_argument('name') 61 | rm_parser.set_defaults(func=run_admin_rm) 62 | 63 | show_parser = admin_subparsers.add_parser('show', help='Print the schema for a function') 64 | show_parser.add_argument('name') 65 | show_parser.set_defaults(func=run_admin_show) 66 | 67 | args = parser.parse_args(args=args) 68 | 69 | if not hasattr(args, 'func'): 70 | parser.print_usage() 71 | parser.exit(1) 72 | 73 | return args.func(parser, args) 74 | 75 | def run_list_funcs(parser, args): 76 | tags = args.tags 77 | env = args.env 78 | return list_funcs(tags=tags, env=env) 79 | 80 | def list_funcs(tags=None, env=None): 81 | if tags is None: 82 | tags = True 83 | if env is None: 84 | env = False 85 | 86 | funcs = faas.FaaSFunction.list(tags=tags, env=env) 87 | 88 | name_width = 0 89 | for func_name in six.iterkeys(funcs): 90 | name_width = max(name_width, len(func_name)) 91 | 92 | fmt = '{:' + str(name_width) + '}\t{}' 93 | for func_name, func in six.iteritems(funcs): 94 | print(fmt.format(func_name, func.description or '')) 95 | 96 | def run_invoke(parser, args): 97 | schema = None 98 | if args.schema is not None: 99 | schema = Schema.from_json(args.schema) 100 | 101 | return invoke(name=args.name, schema=schema, disable_reinvoke=args.no_reinvoke) 102 | 103 | def invoke(name, schema=None, disable_reinvoke=False): 104 | func = faas.FaaSFunction(name) 105 | 106 | if not schema: 107 | try: 108 | schema = func.get_schema() # :type schema: faas_form.schema.Schema 109 | except payloads.MissingSchemaError as e: 110 | err_msg = 'ERROR: No schema returned by the function' 111 | sys.exit(err_msg) 112 | 113 | while True: 114 | try: 115 | values = schema.get_values() 116 | except KeyboardInterrupt: 117 | print('') 118 | sys.exit(1) 119 | 120 | try: 121 | response = func.invoke(values) 122 | 123 | payload = json.load(response['Payload']) 124 | 125 | result = payloads.get_result(payload) 126 | if result is not None: 127 | print('Result:') 128 | print(result) 129 | else: 130 | payload_to_print = json.dumps(payloads._strip_payload(payload), indent=2) 131 | print('Response:') 132 | print(payload_to_print) 133 | 134 | if disable_reinvoke or not payloads.is_reinvoke_response(payload): 135 | break 136 | print('') 137 | 138 | schema = Schema.from_json(payloads.get_schema(payload)) 139 | except Exception as e: 140 | raise #TODO: only print stack trace if verbose requested in args 141 | err_msg = 'ERROR: {}'.format(e) 142 | sys.exit(err_msg) 143 | 144 | def run_prompt(parser, args): 145 | schema = None 146 | if args.schema is not None: 147 | schema = args.schema 148 | if payloads.SCHEMA_KEY in schema: 149 | schema = schema[payloads.SCHEMA_KEY] 150 | schema = Schema.from_json(schema) 151 | 152 | function = None 153 | if args.function: 154 | function = faas.FaaSFunction(args.function) 155 | 156 | return prompt(schema=schema, 157 | function=function, 158 | output_file=args.output_file) 159 | 160 | def prompt(schema=None, function=None, output_file=None): 161 | if schema and function: 162 | raise ValueError("Can't specify both schema and function") 163 | if not schema and not function: 164 | raise ValueError("Must specify either schema or function") 165 | 166 | if function: 167 | try: 168 | schema = function.get_schema() # :type schema: faas_form.schema.Schema 169 | except payloads.MissingSchemaError as e: 170 | err_msg = 'ERROR: No schema returned by the function' 171 | sys.exit(err_msg) 172 | 173 | try: 174 | values = schema.get_values() 175 | except KeyboardInterrupt: 176 | print('') 177 | sys.exit(1) 178 | 179 | if output_file: 180 | json.dump(values, output_file, indent=2) 181 | else: 182 | print(json.dumps(values, indent=2)) 183 | 184 | def run_admin_add(parser, args): 185 | return admin_add(args.name, description=args.description) 186 | 187 | def admin_add(name, description=None): 188 | return faas.FaaSFunction.add(name, description) 189 | 190 | def run_admin_rm(parser, args): 191 | return admin_rm(args.name) 192 | 193 | def admin_rm(name): 194 | faas.FaaSFunction.remove(name) 195 | 196 | def run_admin_show(parser, args): 197 | return admin_show(args.name) 198 | 199 | def admin_show(name): 200 | function = faas.FaaSFunction(name) 201 | schema = function.get_schema() 202 | 203 | print(json.dumps(schema.to_json(), indent=2)) 204 | 205 | if __name__ == '__main__': 206 | main() -------------------------------------------------------------------------------- /faas_form/faas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mar 31, 2018 3 | 4 | @author: bkehoe 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | 9 | import six 10 | import json 11 | 12 | import boto3 13 | from botocore.exceptions import ClientError 14 | 15 | from . import payloads 16 | from .schema import Schema 17 | 18 | class RequestError(Exception): 19 | pass 20 | 21 | class FaaSFunction(object): 22 | MARKER = 'faasform' 23 | 24 | @classmethod 25 | def list(cls, tags=True, env=True, session=None): 26 | session = session or boto3.session.Session() 27 | 28 | funcs = {} 29 | 30 | if tags: 31 | client = session.client('resourcegroupstaggingapi') 32 | paginator = client.get_paginator('get_resources') 33 | 34 | paginator_kwargs = { 35 | 'TagFilters': [ 36 | { 37 | 'Key': cls.MARKER, 38 | }, 39 | ], 40 | 'ResourceTypeFilters': [ 41 | 'lambda:function', 42 | ] 43 | } 44 | 45 | for response in paginator.paginate(**paginator_kwargs): 46 | for value in response['ResourceTagMappingList']: 47 | arn = value['ResourceARN'] 48 | name = arn.split(':', 6)[-1] 49 | for tag in value['Tags']: 50 | if tag['Key'] == cls.MARKER: 51 | description = tag.get('Value') 52 | break 53 | funcs[name] = cls(arn, name=name, description=description) 54 | 55 | if env: 56 | client = session.client('lambda') 57 | paginator = client.get_paginator('list_functions') 58 | 59 | for response in paginator.paginate(): 60 | for func in response['Functions']: 61 | arn = func['FunctionArn'] 62 | name = arn.split(':', 6)[-1] 63 | 64 | for var_name, var_value in six.iteritems(func.get('Environment', {}).get('Variables', {})): 65 | if var_name == cls.MARKER: 66 | description = var_value or None 67 | funcs[name] = cls(arn, name=name, description=description) 68 | break 69 | 70 | return funcs 71 | 72 | @classmethod 73 | def _get_arn(cls, name, session=None): 74 | if name.startswith('arn'): 75 | return name 76 | 77 | session = session or boto3.session.Session() 78 | client = session.client('lambda') 79 | response = client.get_function( 80 | FunctionName=name 81 | ) 82 | return response['Configuration']['FunctionArn'] 83 | 84 | @classmethod 85 | def add(cls, name, description=None, session=None): 86 | session = session or boto3.session.Session() 87 | 88 | arn = cls._get_arn(name, session=session) 89 | 90 | client = session.client('resourcegroupstaggingapi') 91 | 92 | client.tag_resources( 93 | ResourceARNList=[arn], 94 | Tags={ 95 | cls.MARKER: description or '' 96 | } 97 | ) 98 | 99 | @classmethod 100 | def remove(cls, name, session=None): 101 | session = session or boto3.session.Session() 102 | 103 | arn = cls._get_arn(name, session=session) 104 | 105 | client = session.client('resourcegroupstaggingapi') 106 | 107 | client.untag_resources( 108 | ResourceARNList=[arn], 109 | TagKeys=[cls.MARKER], 110 | ) 111 | 112 | def __init__(self, id, name=None, description=None, session=None): 113 | self.id = id 114 | self.name = name 115 | self.description = description 116 | self.session = session or boto3.session.Session() 117 | 118 | def get_schema(self): 119 | client = self.session.client('lambda') 120 | 121 | request_payload = {} 122 | payloads.set_schema_request(request_payload) 123 | 124 | response = client.invoke( 125 | FunctionName=self.id, 126 | InvocationType='RequestResponse', 127 | Payload=json.dumps(request_payload), 128 | ) 129 | 130 | response_payload = json.load(response['Payload']) 131 | 132 | schema = payloads.get_schema(response_payload) 133 | 134 | return Schema.from_json(schema) 135 | 136 | def invoke(self, values): 137 | client = self.session.client('lambda') 138 | 139 | request_payload = {} 140 | payloads.set_invoke_request(request_payload) 141 | 142 | request_payload.update(values) 143 | 144 | response = client.invoke( 145 | FunctionName=self.id, 146 | InvocationType='RequestResponse', 147 | LogType='Tail', 148 | Payload=json.dumps(request_payload), 149 | ) 150 | 151 | return response -------------------------------------------------------------------------------- /faas_form/getch.py: -------------------------------------------------------------------------------- 1 | def getch(prompt=None): 2 | """Gets a single character from standard input. Does not echo to the screen.""" 3 | import sys 4 | 5 | if not hasattr(getch, '_impl'): 6 | try: 7 | msvcrt = __import__('msvcrt') 8 | impl = msvcrt.getch 9 | except ImportError: 10 | def getch_unix(): 11 | termios = __import__('termios') 12 | tty = __import__('tty') 13 | fd = sys.stdin.fileno() 14 | old_settings = termios.tcgetattr(fd) 15 | try: 16 | tty.setraw(sys.stdin.fileno()) 17 | ch = sys.stdin.read(1) 18 | finally: 19 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 20 | return ch 21 | impl = getch_unix 22 | setattr(getch, '_impl', impl) 23 | 24 | if prompt: 25 | sys.stdout.write(prompt) 26 | sys.stdout.flush() 27 | ch = getch._impl() 28 | 29 | if ord(ch) == 3: 30 | raise KeyboardInterrupt 31 | 32 | if ord(ch) == 4: 33 | raise EOFError 34 | 35 | if prompt: 36 | sys.stdout.write('\n') 37 | sys.stdout.flush() 38 | 39 | return ch 40 | -------------------------------------------------------------------------------- /faas_form/payloads.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mar 31, 2018 3 | 4 | @author: bkehoe 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | 9 | class MissingSchemaError(Exception): 10 | pass 11 | 12 | PAYLOAD_TYPE_KEY = 'x-faas-form-payload' 13 | SCHEMA_PAYLOAD_TYPE = 'schema' 14 | INVOKE_PAYLOAD_TYPE = 'invoke' 15 | REINVOKE_PAYLOAD_TYPE = 'reinvoke' 16 | 17 | SCHEMA_KEY = 'x-faas-form-schema' 18 | 19 | RESULT_KEY = 'x-faas-form-result' 20 | 21 | def _set_payload_type(payload, type): 22 | payload[PAYLOAD_TYPE_KEY] = type 23 | 24 | def _is_payload_type(obj, type): 25 | return isinstance(obj, dict) and obj.get(PAYLOAD_TYPE_KEY) == type 26 | 27 | 28 | def _schema_to_json(schema): 29 | if isinstance(schema, dict): 30 | return schema 31 | else: 32 | return schema.to_json() 33 | 34 | def _set_schema(payload, schema): 35 | payload[SCHEMA_KEY] = _schema_to_json(schema) 36 | 37 | def set_schema_request(request): 38 | _set_payload_type(request, SCHEMA_PAYLOAD_TYPE) 39 | 40 | def is_schema_request(request): 41 | return _is_payload_type(request, SCHEMA_PAYLOAD_TYPE) 42 | 43 | def set_schema_reponse(response, schema): 44 | _set_schema(response, schema) 45 | 46 | def get_schema(response): 47 | if SCHEMA_KEY not in response: 48 | raise MissingSchemaError 49 | return response[SCHEMA_KEY] 50 | 51 | 52 | def set_invoke_request(request): 53 | _set_payload_type(request, INVOKE_PAYLOAD_TYPE) 54 | 55 | def is_invoke_request(obj): 56 | return _is_payload_type(obj, INVOKE_PAYLOAD_TYPE) 57 | 58 | 59 | def set_reinvoke_response(response, schema, result=None): 60 | _set_payload_type(response, REINVOKE_PAYLOAD_TYPE) 61 | _set_schema(response, schema) 62 | if result is not None: 63 | set_result(response, result) 64 | 65 | def is_reinvoke_response(obj): 66 | return _is_payload_type(obj, REINVOKE_PAYLOAD_TYPE) 67 | 68 | 69 | def set_result(response, result): 70 | response[RESULT_KEY] = result 71 | 72 | def get_result(response): 73 | return response.get(RESULT_KEY) 74 | 75 | def _strip_payload(payload): 76 | return dict((key, value) for key, value in payload.items() if not key.startswith('x-faas-form')) -------------------------------------------------------------------------------- /faas_form/schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mar 30, 2018 3 | 4 | @author: bkehoe 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | 9 | import six 10 | import getpass 11 | from abc import abstractmethod, ABCMeta 12 | import re 13 | import itertools 14 | 15 | from . import getch 16 | 17 | __all__ = [ 18 | 'Schema', 19 | 'StringInput', 20 | 'SecretInput', 21 | 'NumberInput', 22 | 'StringListInput', 23 | 'ConstInput', 24 | 'BooleanInput', 25 | ] 26 | 27 | class SchemaError(ValueError): 28 | pass 29 | 30 | class Schema(object): 31 | INPUT_REGISTRY = {} 32 | 33 | @classmethod 34 | def _input_from_json(cls, obj): 35 | input_type = obj.get('type') 36 | if input_type not in cls.INPUT_REGISTRY: 37 | raise SchemaError("Invalid input type: {}".format(input_type)) 38 | input_cls = cls.INPUT_REGISTRY[input_type] 39 | return input_cls.from_json(obj) 40 | 41 | @classmethod 42 | def from_json(cls, obj): 43 | if 'inputs' not in obj: 44 | raise SchemaError('Missing inputs') 45 | 46 | instructions = obj.get('instructions') 47 | 48 | inputs = [] 49 | for input_obj in obj['inputs']: 50 | inputs.append(cls._input_from_json(input_obj)) 51 | 52 | return cls(inputs, instructions=instructions) 53 | 54 | def __init__(self, inputs, instructions=None): 55 | self.instructions = instructions 56 | self.inputs = inputs 57 | 58 | def to_json(self): 59 | obj = { 60 | 'schema_version': '2018-04-01', 61 | 'inputs': [i.to_json() for i in self.inputs], 62 | } 63 | if self.instructions: 64 | obj['instructions'] = self.instructions 65 | return obj 66 | 67 | def get_values(self): 68 | if self.instructions: 69 | print(self.instructions) 70 | values = {} 71 | for input_obj in self.inputs: 72 | values[input_obj.name] = input_obj.get_value() 73 | return values 74 | 75 | def __repr__(self): 76 | instructions_str = '' 77 | if self.instructions: 78 | instructions_str = ',instructions={!r}'.format(self.instructions) 79 | return 'Schema({!r}{})'.format(self.inputs, instructions_str) 80 | 81 | @six.add_metaclass(ABCMeta) 82 | class Input(object): 83 | REQUIRED_DEFAULT = True 84 | 85 | @classmethod 86 | @abstractmethod 87 | def from_json(cls, obj): 88 | raise NotImplementedError 89 | 90 | @classmethod 91 | def _get_base_kwargs_from_json(cls, obj): 92 | if 'name' not in obj: 93 | raise SchemaError("Name is required") 94 | kwargs = { 95 | 'name': obj['name'], 96 | } 97 | for field in ['required', 'help']: 98 | if field in obj: 99 | kwargs[field] = obj[field] 100 | if cls.default_allowed(): 101 | kwargs['default'] = obj.get('default') 102 | elif 'default' in obj: 103 | raise SchemaError("Default is not allowed for type {}".format(cls.type())) 104 | return kwargs 105 | 106 | @classmethod 107 | @abstractmethod 108 | def type(cls): 109 | raise NotImplementedError 110 | 111 | @classmethod 112 | def default_allowed(cls): 113 | return True 114 | 115 | def __init__(self, name, 116 | required=None, 117 | default=None, 118 | help=None,): 119 | if not name: 120 | raise SchemaError("Name is required") 121 | self.name = name 122 | self._required = required 123 | self.help = help 124 | 125 | self.default = None 126 | if self.default_allowed(): 127 | self.default = default 128 | elif default is not None: 129 | raise SchemaError("Default is not allowed for type {}".format(self.type())) 130 | 131 | @property 132 | def required(self): 133 | return self._required if self._required is not None else self.REQUIRED_DEFAULT 134 | 135 | @required.setter 136 | def required(self, value): 137 | self._required = value 138 | 139 | def _base_to_json(self, *args): 140 | obj = { 141 | 'name': self.name, 142 | 'type': self.type(), 143 | } 144 | for field in ['required', 'default', 'help'] + list(args): 145 | value = getattr(self, field) 146 | if value is not None: 147 | obj[field] = value 148 | return obj 149 | 150 | @abstractmethod 151 | def to_json(self): 152 | raise NotImplementedError 153 | 154 | def _input(self, prompt): 155 | from six.moves import input 156 | return input(prompt) 157 | 158 | @abstractmethod 159 | def _get_value(self, prompt): 160 | raise NotImplementedError 161 | 162 | def _properties_for_prompt(self): 163 | properties = [] 164 | if self._required is not None or self.required: 165 | properties.append('required={}'.format(self.required)) 166 | if self.default_allowed() and self.default is not None: 167 | properties.append('default={}'.format(self.default)) 168 | return properties 169 | 170 | def prompt(self): 171 | parts = [ 172 | '{} [{}]'.format(self.name, self.type()) 173 | ] 174 | if self.help: 175 | parts.append(' {}'.format(self.help)) 176 | properties = self._properties_for_prompt() 177 | if properties: 178 | parts.append(' ({})'.format(', '.join(properties))) 179 | parts.append(': ') 180 | return ''.join(parts) 181 | 182 | def get_value(self): 183 | prompt = self.prompt() 184 | while True: 185 | try: 186 | value = self._get_value(prompt) 187 | except EOFError: 188 | print('') 189 | value = None 190 | if value is None and self.default is not None: 191 | value = self.default 192 | if value is None and self.required: 193 | print("Field is required!") 194 | continue 195 | break 196 | return value 197 | 198 | def __repr__(self): 199 | json_value = self.to_json() 200 | kwargs = [ 201 | 'name='+repr(json_value['name']), 202 | ] 203 | for key, value in json_value.items(): 204 | if key in ['name', 'type']: 205 | continue 206 | kwargs.append('{}={!r}'.format(key, value)) 207 | return '{}({})'.format(self.__class__.__name__, ','.join(kwargs)) 208 | 209 | class StringInput(Input): 210 | @classmethod 211 | def type(cls): 212 | return 'string' 213 | 214 | @classmethod 215 | def from_json(cls, obj): 216 | kwargs = cls._get_base_kwargs_from_json(obj) 217 | kwargs['pattern'] = obj.get('pattern') 218 | return cls(**kwargs) 219 | 220 | def __init__(self, name, 221 | required=None, 222 | default=None, 223 | help=None, 224 | pattern=None,): 225 | super(StringInput, self).__init__( 226 | name, 227 | required=required, 228 | default=default, 229 | help=help) 230 | self.pattern = pattern 231 | 232 | def to_json(self): 233 | return self._base_to_json('pattern') 234 | 235 | def _get_value(self, prompt): 236 | while True: 237 | value = self._input(prompt) 238 | if self.pattern and not re.search(self.pattern, value): 239 | print('Invalid input!') 240 | continue 241 | if not value: 242 | value = None 243 | return value 244 | 245 | class SecretInput(StringInput): 246 | @classmethod 247 | def type(cls): 248 | return 'secret' 249 | 250 | @classmethod 251 | def default_allowed(cls): 252 | return False 253 | 254 | def __init__(self, name, 255 | required=None, 256 | help=None, 257 | pattern=None,): 258 | super(SecretInput, self).__init__(name, required=required, help=help, pattern=pattern) 259 | 260 | def _input(self, prompt): 261 | return getpass.getpass(prompt) 262 | 263 | class NumberInput(Input): 264 | @classmethod 265 | def type(cls): 266 | return 'number' 267 | 268 | @classmethod 269 | def from_json(cls, obj): 270 | kwargs = cls._get_base_kwargs_from_json(obj) 271 | kwargs['integer'] = obj.get('integer') 272 | return cls(**kwargs) 273 | 274 | def __init__(self, name, 275 | required=None, 276 | default=None, 277 | help=None, 278 | integer=None): 279 | super(NumberInput, self).__init__( 280 | name, 281 | required=required, 282 | default=default, 283 | help=help) 284 | 285 | self.integer = integer 286 | 287 | def to_json(self): 288 | return self._base_to_json('integer') 289 | 290 | def _properties_for_prompt(self): 291 | properties = super(NumberInput, self)._properties_for_prompt() 292 | if self.integer is True: 293 | properties.append('integer={}'.format(self.integer)) 294 | return properties 295 | 296 | def _get_value(self, prompt): 297 | while True: 298 | value = self._input(prompt) 299 | try: 300 | value = float(value) 301 | if self.integer and not value.is_integer(): 302 | print('Value must be an integer!') 303 | continue 304 | return value 305 | except ValueError: 306 | print('Invalid input! Ctrl-D to enter no value') 307 | 308 | class StringListInput(Input): 309 | @classmethod 310 | def type(cls): 311 | return 'list' 312 | 313 | @classmethod 314 | def default_allowed(cls): 315 | return False 316 | 317 | @classmethod 318 | def from_json(cls, obj): 319 | kwargs = cls._get_base_kwargs_from_json(obj) 320 | kwargs['pattern'] = obj.get('pattern') 321 | kwargs['size'] = obj.get('size') 322 | return cls(**kwargs) 323 | 324 | def __init__(self, name, 325 | required=None, 326 | default=None, 327 | help=None, 328 | pattern=None, 329 | size=None,): 330 | super(StringListInput, self).__init__( 331 | name, 332 | required=required, 333 | default=default, 334 | help=help) 335 | 336 | self.pattern = pattern 337 | self.size = size 338 | 339 | def to_json(self): 340 | return self._base_to_json('pattern', 'size') 341 | 342 | @property 343 | def minimum_size(self): 344 | if self.size is None: 345 | return 0 346 | else: 347 | return self.size 348 | 349 | @property 350 | def maximum_size(self): 351 | if self.size is None: 352 | return float('inf') 353 | else: 354 | return self.size 355 | 356 | def _properties_for_prompt(self): 357 | properties = super(StringListInput, self)._properties_for_prompt() 358 | if self.size is not None: 359 | properties.append('size={}'.format(self.size)) 360 | return properties 361 | 362 | def _get_value(self, prompt): 363 | values = [] 364 | for _ in itertools.count(): 365 | try: 366 | value = self._input(prompt) 367 | if self.pattern and not re.search(self.pattern, value): 368 | print('Invalid input! Ctrl-D to enter no value') 369 | continue 370 | if not value: 371 | value = None 372 | except EOFError: 373 | print('') 374 | value = None 375 | 376 | if value is None: 377 | if len(values) >= self.minimum_size: 378 | return values 379 | else: 380 | print("Not enough values! Minimum size: {}".format(self.minimum_size)) 381 | continue 382 | 383 | values.append(value) 384 | 385 | if len(values) == self.maximum_size: 386 | return values 387 | 388 | class ConstInput(Input): 389 | @classmethod 390 | def type(cls): 391 | return 'const' 392 | 393 | @classmethod 394 | def default_allowed(cls): 395 | return False 396 | 397 | @classmethod 398 | def from_json(cls, obj): 399 | kwargs = cls._get_base_kwargs_from_json(obj) 400 | kwargs.pop('required', None) # Const is special 401 | if 'value' not in obj: 402 | raise SchemaError("value is required") 403 | kwargs['value'] = obj['value'] 404 | return cls(**kwargs) 405 | 406 | def __init__(self, name, value, 407 | help=None): 408 | super(ConstInput, self).__init__( 409 | name, 410 | help=help) 411 | self.value = value 412 | 413 | def to_json(self): 414 | return self._base_to_json('value') 415 | 416 | def _get_value(self, prompt): 417 | return self.value 418 | 419 | class BooleanInput(Input): 420 | @classmethod 421 | def type(cls): 422 | return 'boolean' 423 | 424 | @classmethod 425 | def default_allowed(cls): 426 | return False 427 | 428 | @classmethod 429 | def from_json(cls, obj): 430 | kwargs = cls._get_base_kwargs_from_json(obj) 431 | return cls(**kwargs) 432 | 433 | def __init__(self, name, 434 | required=None, 435 | help=None): 436 | super(BooleanInput, self).__init__( 437 | name, 438 | required=required, 439 | help=help) 440 | 441 | def to_json(self): 442 | return self._base_to_json() 443 | 444 | def _get_value(self, prompt): 445 | while True: 446 | value = getch.getch(prompt).lower() 447 | if value not in ['y', 'n']: 448 | print('Enter y/n') 449 | else: 450 | return value == 'y' 451 | 452 | for input_cls in [ 453 | StringInput, 454 | SecretInput, 455 | NumberInput, 456 | StringListInput, 457 | ConstInput, 458 | BooleanInput, 459 | ]: 460 | Schema.INPUT_REGISTRY[input_cls.type()] = input_cls 461 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os.path 4 | from setuptools import setup 5 | 6 | def get_version(name): 7 | import os.path 8 | path = os.path.join(name, '_version') 9 | if not os.path.exists(path): 10 | return "0.0.0" 11 | with open(path) as f: 12 | return f.read().strip() 13 | 14 | requires = [ 15 | 'boto3>=1.2.0', 16 | 'setuptools>=20.6.6', 17 | 'six>=1.0.0', 18 | ] 19 | 20 | setup( 21 | name='faas-form', 22 | version=get_version('faas_form'), 23 | description='', 24 | author='Ben Kehoe', 25 | author_email='bkehoe@irobot.com', 26 | url='https://github.com/iRobotCorporation/faas-form', 27 | packages=["faas_form"], 28 | package_data={ 29 | "faas_form": ["_version"] 30 | }, 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'faas-form = faas_form.cli:main', 34 | ], 35 | }, 36 | install_requires=requires, 37 | classifiers=( 38 | 'Development Status :: 2 - Beta', 39 | 'Intended Audience :: Developers', 40 | 'Intended Audience :: System Administrators', 41 | 'Natural Language :: English', 42 | 'Programming Language :: Python', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | ), 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benkehoe/faas-form/afcd6cba00d77d903969023492e1f56b3792a76f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mar 31, 2018 3 | 4 | @author: bkehoe 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | 9 | import six 10 | 11 | import unittest 12 | 13 | from unittest import mock 14 | 15 | from faas_form import schema 16 | 17 | DEFAULT_VALUE_STRING = 'default_string_value' 18 | 19 | INPUT_STRING_1 = { 20 | 'name': 'string_input_1', 21 | 'type': 'string', 22 | } 23 | 24 | INPUT_STRING_WITH_PATTERN = { 25 | 'name': 'string_input_with_pattern', 26 | 'type': 'string', 27 | 'pattern': r'^[^A-Z]+$', 28 | } 29 | 30 | INPUT_STRING_WITH_DEFAULT = { 31 | 'name': 'string_input_with_default', 32 | 'type': 'string', 33 | 'default': DEFAULT_VALUE_STRING, 34 | } 35 | 36 | INPUT_STRING_REQUIRED = { 37 | 'name': 'string_input_required', 38 | 'type': 'string', 39 | 'required': True, 40 | } 41 | 42 | INPUT_STRING_NOT_REQUIRED = { 43 | 'name': 'string_input_not_required', 44 | 'type': 'string', 45 | 'required': False, 46 | } 47 | 48 | INPUT_STRING_REQUIRED_WITH_DEFAULT = { 49 | 'name': 'string_input_required', 50 | 'type': 'string', 51 | 'required': True, 52 | 'default': DEFAULT_VALUE_STRING, 53 | } 54 | 55 | class StringInputTest(unittest.TestCase): 56 | def test_from_json(self): 57 | schema.StringInput.from_json(INPUT_STRING_1) 58 | schema.StringInput.from_json(INPUT_STRING_WITH_PATTERN) 59 | schema.StringInput.from_json(INPUT_STRING_WITH_DEFAULT) 60 | schema.StringInput.from_json(INPUT_STRING_REQUIRED) 61 | schema.StringInput.from_json(INPUT_STRING_NOT_REQUIRED) 62 | schema.StringInput.from_json(INPUT_STRING_REQUIRED_WITH_DEFAULT) 63 | 64 | 65 | def test_prompt(self): 66 | input_values = ['FOO', 'foo'] 67 | 68 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 69 | si = schema.StringInput.from_json(INPUT_STRING_1) 70 | value = si.get_value() 71 | 72 | self.assertEqual(value, input_values[0]) 73 | self.assertEqual(mock_input.call_count, 1) 74 | 75 | def test_prompt_default_required(self): 76 | input_values = ['', 'foo'] 77 | 78 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 79 | si = schema.StringInput.from_json(INPUT_STRING_1) 80 | value = si.get_value() 81 | 82 | self.assertEqual(value, input_values[1]) 83 | self.assertEqual(mock_input.call_count, 2) 84 | 85 | def test_prompt_pattern(self): 86 | input_values = ['FOO', 'foo'] 87 | 88 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 89 | si = schema.StringInput.from_json(INPUT_STRING_WITH_PATTERN) 90 | value = si.get_value() 91 | 92 | self.assertEqual(value, input_values[1]) 93 | self.assertEqual(mock_input.call_count, 2) 94 | 95 | def test_prompt_default(self): 96 | input_values = [''] 97 | 98 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 99 | si = schema.StringInput.from_json(INPUT_STRING_WITH_DEFAULT) 100 | value = si.get_value() 101 | 102 | self.assertEqual(value, DEFAULT_VALUE_STRING) 103 | self.assertEqual(mock_input.call_count, 1) 104 | 105 | input_values = [EOFError()] 106 | 107 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 108 | si = schema.StringInput.from_json(INPUT_STRING_WITH_DEFAULT) 109 | value = si.get_value() 110 | 111 | self.assertEqual(value, DEFAULT_VALUE_STRING) 112 | self.assertEqual(mock_input.call_count, 1) 113 | 114 | def test_prompt_required(self): 115 | input_values = ['', 'foo'] 116 | 117 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 118 | si = schema.StringInput.from_json(INPUT_STRING_REQUIRED) 119 | value = si.get_value() 120 | 121 | self.assertEqual(value, input_values[1]) 122 | self.assertEqual(mock_input.call_count, 2) 123 | 124 | input_values = [EOFError(), 'foo'] 125 | 126 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 127 | si = schema.StringInput.from_json(INPUT_STRING_REQUIRED) 128 | value = si.get_value() 129 | 130 | self.assertEqual(value, input_values[1]) 131 | self.assertEqual(mock_input.call_count, 2) 132 | 133 | def test_prompt_not_required(self): 134 | input_values = ['', 'foo'] 135 | 136 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 137 | si = schema.StringInput.from_json(INPUT_STRING_NOT_REQUIRED) 138 | value = si.get_value() 139 | 140 | self.assertEqual(value, None) 141 | self.assertEqual(mock_input.call_count, 1) 142 | 143 | def test_prompt_required_with_default(self): 144 | input_values = ['', 'foo'] 145 | 146 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 147 | si = schema.StringInput.from_json(INPUT_STRING_REQUIRED_WITH_DEFAULT) 148 | value = si.get_value() 149 | 150 | self.assertEqual(value, DEFAULT_VALUE_STRING) 151 | self.assertEqual(mock_input.call_count, 1) 152 | 153 | input_values = [EOFError(), 'foo'] 154 | 155 | with mock.patch.object(schema.StringInput, '_input', side_effect=input_values) as mock_input: 156 | si = schema.StringInput.from_json(INPUT_STRING_REQUIRED_WITH_DEFAULT) 157 | value = si.get_value() 158 | 159 | self.assertEqual(value, DEFAULT_VALUE_STRING) 160 | self.assertEqual(mock_input.call_count, 1) 161 | 162 | INPUT_SECRET_1 = { 163 | 'name': 'secret_schema_1', 164 | 'type': 'secret', 165 | } 166 | 167 | INPUT_SECRET_WITH_DEFAULT = { 168 | 'name': 'secret_schema_with_default', 169 | 'type': 'secret', 170 | 'default': DEFAULT_VALUE_STRING, 171 | } 172 | 173 | class SecretInputTest(unittest.TestCase): 174 | def test_from_json(self): 175 | schema.SecretInput.from_json(INPUT_SECRET_1) 176 | with self.assertRaises(schema.SchemaError): 177 | schema.SecretInput.from_json(INPUT_SECRET_WITH_DEFAULT) 178 | 179 | DEFAULT_VALUE_NUMBER = 2.7 180 | 181 | INPUT_NUMBER_1 = { 182 | 'name': 'number_input_1', 183 | 'type': 'number', 184 | } 185 | 186 | INPUT_NUMBER_WITH_DEFAULT = { 187 | 'name': 'number_input_with_default', 188 | 'type': 'number', 189 | 'default': DEFAULT_VALUE_NUMBER, 190 | } 191 | 192 | class NumberInputTest(unittest.TestCase): 193 | def test_from_json(self): 194 | schema.NumberInput.from_json(INPUT_NUMBER_1) 195 | schema.NumberInput.from_json(INPUT_NUMBER_WITH_DEFAULT) 196 | 197 | def test_prompt(self): 198 | input_values = ['1', '2'] 199 | 200 | with mock.patch.object(schema.NumberInput, '_input', side_effect=input_values) as mock_input: 201 | si = schema.NumberInput.from_json(INPUT_NUMBER_1) 202 | value = si.get_value() 203 | 204 | self.assertEqual(value, float(input_values[0])) 205 | self.assertEqual(mock_input.call_count, 1) 206 | 207 | input_values = ['x', '2'] 208 | 209 | with mock.patch.object(schema.NumberInput, '_input', side_effect=input_values) as mock_input: 210 | si = schema.NumberInput.from_json(INPUT_NUMBER_1) 211 | value = si.get_value() 212 | 213 | self.assertEqual(value, float(input_values[1])) 214 | self.assertEqual(mock_input.call_count, 2) 215 | 216 | def test_prompt_default(self): 217 | input_values = [EOFError()] 218 | 219 | with mock.patch.object(schema.NumberInput, '_input', side_effect=input_values) as mock_input: 220 | si = schema.NumberInput.from_json(INPUT_NUMBER_WITH_DEFAULT) 221 | value = si.get_value() 222 | 223 | self.assertEqual(value, DEFAULT_VALUE_NUMBER) 224 | self.assertEqual(mock_input.call_count, 1) 225 | 226 | DEFAULT_VALUE_STRINGLIST = ['a', 'b', 'c'] 227 | 228 | INPUT_STRINGLIST_1 = { 229 | 'name': 'stringlist_1', 230 | 'type': 'list', 231 | } 232 | 233 | INPUT_STRINGLIST_WITH_SIZE = { 234 | 'name': 'stringlist_with_default', 235 | 'type': 'list', 236 | 'size': 3, 237 | } 238 | 239 | INPUT_STRINGLIST_WITH_PATTERN = { 240 | 'name': 'stringlist_with_pattern', 241 | 'type': 'list', 242 | 'pattern': r'^[^A-Z]+$', 243 | } 244 | 245 | class StringListTest(unittest.TestCase): 246 | def test_from_json(self): 247 | schema.StringListInput.from_json(INPUT_STRINGLIST_1) 248 | schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_SIZE) 249 | schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_PATTERN) 250 | 251 | def test_prompt(self): 252 | input_values = ['a', 'b', 'c', '', Exception()] 253 | 254 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 255 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_1) 256 | value = si.get_value() 257 | 258 | self.assertEqual(value, ['a', 'b', 'c']) 259 | self.assertEqual(mock_input.call_count, 4) 260 | 261 | input_values = ['a', 'b', 'c', EOFError(), Exception()] 262 | 263 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 264 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_1) 265 | value = si.get_value() 266 | 267 | self.assertEqual(value, ['a', 'b', 'c']) 268 | self.assertEqual(mock_input.call_count, 4) 269 | 270 | def test_prompt_size(self): 271 | size = 3 272 | input_values = ['a', 'b', 'c', '', Exception()] 273 | 274 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 275 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_SIZE) 276 | value = si.get_value() 277 | 278 | self.assertEqual(value, ['a', 'b', 'c']) 279 | self.assertEqual(mock_input.call_count, 3) 280 | 281 | size = 3 282 | input_values = ['a', 'b', '', 'c', Exception()] 283 | 284 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 285 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_SIZE) 286 | value = si.get_value() 287 | 288 | self.assertEqual(value, ['a', 'b', 'c']) 289 | self.assertEqual(mock_input.call_count, 4) 290 | 291 | size = 3 292 | input_values = ['a', 'b', EOFError(), 'c', Exception()] 293 | 294 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 295 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_SIZE) 296 | value = si.get_value() 297 | 298 | self.assertEqual(value, ['a', 'b', 'c']) 299 | self.assertEqual(mock_input.call_count, 4) 300 | 301 | def test_prompt_with_pattern(self): 302 | input_values = ['a', 'b', 'c', EOFError(), Exception()] 303 | 304 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 305 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_PATTERN) 306 | value = si.get_value() 307 | 308 | self.assertEqual(value, ['a', 'b', 'c']) 309 | self.assertEqual(mock_input.call_count, 4) 310 | 311 | input_values = ['a', 'b', 'C', 'c', EOFError(), Exception()] 312 | 313 | with mock.patch.object(schema.StringListInput, '_input', side_effect=input_values) as mock_input: 314 | si = schema.StringListInput.from_json(INPUT_STRINGLIST_WITH_PATTERN) 315 | value = si.get_value() 316 | 317 | self.assertEqual(value, ['a', 'b', 'c']) 318 | self.assertEqual(mock_input.call_count, 5) 319 | 320 | INPUT_INVALID_NO_NAME = { 321 | 'type': 'string', 322 | } 323 | 324 | INPUT_INVALID_NO_TYPE = { 325 | 'name': 'invalid_input_no_type', 326 | } 327 | 328 | INPUT_INVALID_BAD_TYPE = { 329 | 'name': 'invalid_input_bad_type', 330 | 'type': 'bad_type', 331 | } 332 | 333 | class SchemaTest(unittest.TestCase): 334 | def test_input_from_json(self): 335 | si = schema.Schema._input_from_json(INPUT_STRING_1) 336 | self.assertIsInstance(si, schema.StringInput) 337 | 338 | si = schema.Schema._input_from_json(INPUT_SECRET_1) 339 | self.assertIsInstance(si, schema.SecretInput) 340 | 341 | si = schema.Schema._input_from_json(INPUT_NUMBER_1) 342 | self.assertIsInstance(si, schema.NumberInput) 343 | 344 | si = schema.Schema._input_from_json(INPUT_STRINGLIST_1) 345 | self.assertIsInstance(si, schema.StringListInput) 346 | 347 | with self.assertRaises(schema.SchemaError): 348 | schema.Schema._input_from_json(INPUT_INVALID_NO_NAME) 349 | 350 | with self.assertRaises(schema.SchemaError): 351 | schema.Schema._input_from_json(INPUT_INVALID_NO_TYPE) 352 | 353 | with self.assertRaises(schema.SchemaError): 354 | schema.Schema._input_from_json(INPUT_INVALID_BAD_TYPE) --------------------------------------------------------------------------------