├── bin ├── __init__.py ├── fz ├── flask-zappa └── client.py ├── flask_zappa ├── __init__.py └── handler.py ├── test_settings.py ├── test.sh ├── MANIFEST.in ├── README.md ├── test_settings.json ├── .travis.yml ├── requirements.txt ├── .gitignore ├── tests └── tests.py ├── LICENSE └── setup.py /bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/fz: -------------------------------------------------------------------------------- 1 | client.py -------------------------------------------------------------------------------- /flask_zappa/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/flask-zappa: -------------------------------------------------------------------------------- 1 | client.py -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | APP_MODULE="test_app" 2 | APP_OBJECT="app" 3 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | nosetests --with-coverage --cover-package=flask_zappa,bin 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE requirements.txt 2 | recursive-include flask_zappa *.py 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flask-zappa 2 | 3 | **flask-zappa** has been merged upsteam into **[Zappa](https://github.com/Miserlou/Zappa)!** 4 | 5 | Go there instead! 6 | -------------------------------------------------------------------------------- /test_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "s3_bucket": "my-flask-test-bucket", 4 | "settings_file": "test_settings.py", 5 | "project_name": "MyFlaskTestProject", 6 | "exclude": ["*.git*", "./static/*", "*.DS_Store", "tests/*", "*.zip"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | # command to install dependencies 5 | install: 6 | - "pip install setuptools --upgrade; pip install -r requirements.txt; python setup.py install" 7 | # command to run tests 8 | script: nosetests --with-coverage --cover-package=flask_zappa,bin 9 | after_success: 10 | coveralls 11 | notifications: 12 | slack: zappateam:TTJ0mfHunDK0IBweKkEXjGpR 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | base58==0.2.2 2 | boto3==1.2.5 3 | botocore==1.3.30 4 | click==6.2 5 | coverage==4.0.3 6 | coveralls==1.1 7 | docopt==0.6.2 8 | docutils==0.12 9 | filechunkio==1.6 10 | Flask==0.10.1 11 | futures==3.0.4 12 | itsdangerous==0.24 13 | Jinja2==2.8 14 | jmespath==0.9.0 15 | lambda-packages==0.3.0 16 | MarkupSafe==0.23 17 | nose==1.3.7 18 | placebo==0.7.2 19 | python-dateutil==2.4.2 20 | requests==2.9.1 21 | six==1.10.0 22 | tqdm==3.7.1 23 | Werkzeug==0.11.4 24 | wheel==0.24.0 25 | wsgi-request-logger==0.4.4 26 | zappa==0.13.1 27 | -------------------------------------------------------------------------------- /.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 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # Mac OS X 65 | *.DS_Store 66 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | import base64 3 | import collections 4 | import json 5 | import os 6 | import random 7 | import string 8 | import unittest 9 | 10 | from zappa.zappa import Zappa 11 | from flask_zappa.handler import lambda_handler 12 | from bin.client import _init, _package 13 | 14 | class TestZappa(unittest.TestCase): 15 | 16 | ## 17 | # Sanity Tests 18 | ## 19 | 20 | def test_test(self): 21 | self.assertTrue(True) 22 | ## 23 | # Basic Tests 24 | ## 25 | 26 | def test_zappa(self): 27 | self.assertTrue(True) 28 | Zappa() 29 | 30 | ## 31 | # Bin settings 32 | ## 33 | 34 | def test_init(self): 35 | with open('test_settings.json') as f: 36 | _init('test', f) 37 | 38 | def test_package(self): 39 | with open('test_settings.json') as f: 40 | zappa, settings, lmbda, zip_path = _package('test', f) 41 | self.assertTrue(os.path.isfile(zip_path)) 42 | os.unlink(zip_path) 43 | 44 | if __name__ == '__main__': 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rich Jones 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 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup 4 | 5 | # Set external files 6 | try: 7 | from pypandoc import convert 8 | README = convert('README.md', 'rst') 9 | except ImportError: 10 | README = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() 11 | 12 | with open(os.path.join(os.path.dirname(__file__), 'requirements.txt')) as f: 13 | required = f.read().splitlines() 14 | 15 | setup( 16 | name='flask-zappa', 17 | version='0.0.1', 18 | packages=['flask_zappa'], 19 | scripts=['bin/flask-zappa', 'bin/fz', 'bin/client.py'], 20 | install_requires=required, 21 | include_package_data=True, 22 | license='MIT License', 23 | description='Serverless Flask With AWS Lambda + API Gateway', 24 | long_description=README, 25 | url='https://github.com/Miserlou/flask-zappa', 26 | author='Rich Jones', 27 | author_email='rich@openwatch.net', 28 | classifiers=[ 29 | 'Environment :: Console', 30 | 'Environment :: Web Environment', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: Apache Software License', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 2.7', 36 | 'Programming Language :: Python :: 3.5', 37 | 'Topic :: Internet :: WWW/HTTP', 38 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /flask_zappa/handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import base64 4 | import os 5 | import importlib 6 | from urllib import urlencode 7 | from StringIO import StringIO 8 | 9 | from werkzeug.wrappers import Response 10 | from zappa.wsgi import create_wsgi_request 11 | 12 | from zappa.middleware import ZappaWSGIMiddleware 13 | 14 | 15 | def lambda_handler(event, context, settings_name="zappa_settings"): 16 | """ An AWS Lambda function which parses specific API Gateway input into a 17 | WSGI request, feeds it to Flask, procceses the Flask response, and returns 18 | that back to the API Gateway. 19 | """ 20 | # Loading settings from a python module 21 | settings = importlib.import_module(settings_name) 22 | 23 | # The flask-app module 24 | app_module = importlib.import_module(settings.APP_MODULE) 25 | 26 | # The flask-app 27 | app = getattr(app_module, settings.APP_OBJECT) 28 | app.config.from_object('zappa_settings') 29 | 30 | app.wsgi_app = ZappaWSGIMiddleware(app.wsgi_app) 31 | 32 | # This is a normal HTTP request 33 | if event.get('method', None): 34 | # If we just want to inspect this, 35 | # return this event instead of processing the request 36 | # https://your_api.aws-api.com/?event_echo=true 37 | event_echo = getattr(settings, "EVENT_ECHO", True) 38 | if event_echo: 39 | if 'event_echo' in list(event['params'].values()): 40 | return {'Content': str(event) + '\n' + str(context), 'Status': 200} 41 | 42 | # TODO: Enable Let's Encrypt 43 | # # If Let's Encrypt is defined in the settings, 44 | # # and the path is your.domain.com/.well-known/acme-challenge/{{lets_encrypt_challenge_content}}, 45 | # # return a 200 of lets_encrypt_challenge_content. 46 | # lets_encrypt_challenge_path = getattr(settings, "LETS_ENCRYPT_CHALLENGE_PATH", None) 47 | # lets_encrypt_challenge_content = getattr(settings, "LETS_ENCRYPT_CHALLENGE_CONTENT", None) 48 | # if lets_encrypt_challenge_path: 49 | # if len(event['params']) == 3: 50 | # if event['params']['parameter_1'] == '.well-known' and \ 51 | # event['params']['parameter_2'] == 'acme-challenge' and \ 52 | # event['params']['parameter_3'] == lets_encrypt_challenge_path: 53 | # return {'Content': lets_encrypt_challenge_content, 'Status': 200} 54 | 55 | # Create the environment for WSGI and handle the request 56 | environ = create_wsgi_request(event, script_name=settings.SCRIPT_NAME, 57 | trailing_slash=False) 58 | 59 | # We are always on https on Lambda, so tell our wsgi app that. 60 | environ['wsgi.url_scheme'] = 'https' 61 | 62 | response = Response.from_app(app, environ) 63 | 64 | # This doesn't work. It should probably be set right after creation, not 65 | # at such a late stage. 66 | # response.autocorrect_location_header = False 67 | 68 | zappa_returndict = dict() 69 | 70 | if response.data: 71 | zappa_returndict['Content'] = response.data 72 | 73 | # Pack the WSGI response into our special dictionary. 74 | for (header_name, header_value) in response.headers: 75 | zappa_returndict[header_name] = header_value 76 | zappa_returndict['Status'] = response.status_code 77 | 78 | # TODO: No clue how to handle the flask-equivalent of this. Or is this 79 | # something entirely specified by the middleware? 80 | # # Parse the WSGI Cookie and pack it. 81 | # cookie = response.cookies.output() 82 | # if ': ' in cookie: 83 | # zappa_returndict['Set-Cookie'] = response.cookies.output().split(': ')[1] 84 | 85 | # To ensure correct status codes, we need to 86 | # pack the response as a deterministic B64 string and raise it 87 | # as an error to match our APIGW regex. 88 | # The DOCTYPE ensures that the page still renders in the browser. 89 | if response.status_code in [400, 401, 403, 404, 500]: 90 | content = "" + str(response.status_code) + response.data 91 | b64_content = base64.b64encode(content) 92 | raise Exception(b64_content) 93 | # Internal are changed to become relative redirects 94 | # so they still work for apps on raw APIGW and on a domain. 95 | elif response.status_code in [301, 302]: 96 | # Location is by default relative on Flask. Location is by default 97 | # absolute on Werkzeug. We can set autocorrect_location_header on 98 | # the response to False, but it doesn't work. We have to manually 99 | # remove the host part. 100 | location = response.location 101 | hostname = 'https://' + environ['HTTP_HOST'] 102 | if location.startswith(hostname): 103 | location = location[len(hostname):] 104 | raise Exception(location) 105 | else: 106 | return zappa_returndict 107 | 108 | # # This is a management command invocation. 109 | # elif event.get('command', None): 110 | # from django.core import management 111 | 112 | # # Couldn't figure out how to get the value into stdout with StringIO.. 113 | # # Read the log for now. :[] 114 | # management.call_command(*event['command'].split(' ')) 115 | # return {} 116 | -------------------------------------------------------------------------------- /bin/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import json 4 | import importlib 5 | import zipfile 6 | import inspect 7 | 8 | import requests 9 | import click 10 | 11 | from zappa.zappa import Zappa 12 | import flask_zappa 13 | 14 | 15 | CUSTOM_SETTINGS = [ 16 | 'http_methods', 17 | 'parameter_depth', 18 | 'integration_response_codes', 19 | 'method_response_codes', 20 | 'role_name', 21 | 'aws_region' 22 | ] 23 | 24 | DEFAULT_SETTINGS = { 25 | 'vpc_config': {}, 26 | 'delete_zip': True, 27 | 'touch': True, 28 | 'memory_size': 256 29 | } 30 | 31 | 32 | class SettingsError(Exception): pass 33 | 34 | 35 | def apply_zappa_settings(zappa_obj, zappa_settings, environment): 36 | '''Load Zappa settings, set defaults if needed, and apply to the Zappa object''' 37 | 38 | settings_all = json.load(zappa_settings) 39 | settings = settings_all[environment] 40 | 41 | # load defaults for missing options 42 | for key,value in DEFAULT_SETTINGS.items(): 43 | settings[key] = settings.get(key, value) 44 | 45 | if '~' in settings['settings_file']: 46 | settings['settings_file'] = settings['settings_file'].replace('~', os.path.expanduser('~')) 47 | if not os.path.isfile(settings['settings_file']): 48 | raise SettingsError("Please make sure your settings_file " 49 | "is properly defined in {0}.".format(zappa_settings)) 50 | 51 | for setting in CUSTOM_SETTINGS: 52 | if setting in settings: 53 | setattr(zappa_obj, setting, settings[setting]) 54 | 55 | return settings 56 | 57 | 58 | 59 | def _init(environment, zappa_settings): 60 | """ 61 | 62 | """ 63 | # Make your Zappa object 64 | zappa = Zappa() 65 | 66 | # Load settings and apply them to the Zappa object 67 | settings = apply_zappa_settings(zappa, zappa_settings, environment) 68 | 69 | # Create the Lambda zip package (includes project and virtualenvironment) 70 | # Also define the path the handler file so it can be copied to the zip root 71 | # for Lambda. 72 | module_dir = os.path.dirname(os.path.abspath(flask_zappa.__file__)) 73 | handler_file = os.path.join(module_dir, 'handler.py') 74 | lambda_name = settings['project_name'] + '-' + environment 75 | 76 | return zappa, settings, handler_file, lambda_name 77 | 78 | def _package(environment, zappa_settings): 79 | """ 80 | """ 81 | zappa, settings, handler_file, lambda_name = _init(environment, zappa_settings) 82 | 83 | # assume zappa_settings is at the project root 84 | #os.chdir(os.path.dirname(zappa_settings)) 85 | 86 | # List of patterns to exclude when zipping our package for Lambda 87 | exclude = settings.get('exclude', list()) 88 | 89 | zip_path = zappa.create_lambda_zip(lambda_name, 90 | handler_file=handler_file, 91 | exclude=exclude) 92 | 93 | # Add this environment's settings to that zipfile 94 | with open(settings['settings_file'], 'r') as f: 95 | contents = f.read() 96 | all_contents = contents 97 | if 'domain' not in settings: 98 | script_name = environment 99 | else: 100 | script_name = '' 101 | 102 | all_contents = (all_contents + 103 | '\n# Automatically added by Zappa:\nSCRIPT_NAME=\'/' + 104 | script_name + '\'\n') 105 | f.close() 106 | 107 | with open('zappa_settings.py', 'w') as f: 108 | f.write(all_contents) 109 | 110 | with zipfile.ZipFile(zip_path, 'a') as lambda_zip: 111 | lambda_zip.write('zappa_settings.py', 'zappa_settings.py') 112 | lambda_zip.close() 113 | 114 | os.unlink('zappa_settings.py') 115 | 116 | return zappa, settings, lambda_name, zip_path 117 | 118 | 119 | @click.group() 120 | def cli(): 121 | """ Tool for interacting with flask-lambda-apigateway.""" 122 | pass 123 | 124 | 125 | @cli.command() 126 | @click.argument('environment', required=True) 127 | @click.argument('zappa_settings', required=True, type=click.File('rb')) 128 | def deploy(environment, zappa_settings): 129 | """ Package, create and deploy to Lambda.""" 130 | print(("Deploying " + environment)) 131 | 132 | zappa, settings, lambda_name, zip_path = \ 133 | _package(environment, zappa_settings) 134 | 135 | s3_bucket_name = settings['s3_bucket'] 136 | 137 | try: 138 | # Load your AWS credentials from ~/.aws/credentials 139 | zappa.load_credentials() 140 | 141 | # Make sure the necessary IAM execution roles are available 142 | zappa.create_iam_roles() 143 | 144 | # Upload it to S3 145 | zip_arn = zappa.upload_to_s3(zip_path, s3_bucket_name) 146 | 147 | # Register the Lambda function with that zip as the source 148 | # You'll also need to define the path to your lambda_handler code. 149 | lambda_arn = zappa.create_lambda_function(bucket=s3_bucket_name, 150 | s3_key=zip_path, 151 | function_name=lambda_name, 152 | handler='handler.lambda_handler', 153 | vpc_config=settings['vpc_config'], 154 | memory_size=settings['memory_size']) 155 | 156 | # Create and configure the API Gateway 157 | api_id = zappa.create_api_gateway_routes(lambda_arn, lambda_name) 158 | 159 | # Deploy the API! 160 | endpoint_url = zappa.deploy_api_gateway(api_id, environment) 161 | 162 | # Remove the uploaded zip from S3, because it is now registered.. 163 | zappa.remove_from_s3(zip_path, s3_bucket_name) 164 | 165 | if settings['touch']: 166 | requests.get(endpoint_url) 167 | finally: 168 | try: 169 | # Finally, delete the local copy our zip package 170 | if settings['delete_zip']: 171 | os.remove(zip_path) 172 | except: 173 | print("WARNING: Manual cleanup of the zip might be needed.") 174 | 175 | print(("Your Zappa deployment is live!: " + endpoint_url)) 176 | 177 | 178 | @cli.command() 179 | @click.argument('environment', required=True) 180 | @click.argument('zappa_settings', required=True, type=click.File('rb')) 181 | def update(environment, zappa_settings): 182 | """ Update an existing deployment.""" 183 | print(("Updating " + environment)) 184 | 185 | # Package dependencies, and the source code into a zip 186 | zappa, settings, lambda_name, zip_path = \ 187 | _package(environment, zappa_settings) 188 | 189 | s3_bucket_name = settings['s3_bucket'] 190 | 191 | try: 192 | 193 | # Load your AWS credentials from ~/.aws/credentials 194 | zappa.load_credentials() 195 | 196 | # Update IAM roles if needed 197 | zappa.create_iam_roles() 198 | 199 | 200 | # Upload it to S3 201 | zip_arn = zappa.upload_to_s3(zip_path, s3_bucket_name) 202 | 203 | # Register the Lambda function with that zip as the source 204 | # You'll also need to define the path to your lambda_handler code. 205 | lambda_arn = zappa.update_lambda_function(s3_bucket_name, zip_path, 206 | lambda_name) 207 | 208 | # Remove the uploaded zip from S3, because it is now registered.. 209 | zappa.remove_from_s3(zip_path, s3_bucket_name) 210 | finally: 211 | try: 212 | # Finally, delete the local copy our zip package 213 | if settings['delete_zip']: 214 | os.remove(zip_path) 215 | except: 216 | print("WARNING: Manual cleanup of the zip might be needed.") 217 | 218 | print("Your updated Zappa deployment is live!") 219 | 220 | 221 | @cli.command() 222 | @click.argument('environment', required=True) 223 | @click.argument('zappa_settings', required=True, type=click.File('rb')) 224 | def tail(environment, zappa_settings): 225 | """ Stolen verbatim from django-zappa: 226 | https://github.com/Miserlou/django-zappa/blob/master/django_zappa/management/commands/tail.py 227 | """ 228 | 229 | def print_logs(logs): 230 | 231 | for log in logs: 232 | timestamp = log['timestamp'] 233 | message = log['message'] 234 | if "START RequestId" in message: 235 | continue 236 | if "REPORT RequestId" in message: 237 | continue 238 | if "END RequestId" in message: 239 | continue 240 | 241 | print("[" + str(timestamp) + "] " + message.strip()) 242 | 243 | zappa, settings, _, lambda_name = _init(environment, zappa_settings) 244 | 245 | try: 246 | # Tail the available logs 247 | all_logs = zappa.fetch_logs(lambda_name) 248 | print_logs(all_logs) 249 | 250 | # Keep polling, and print any new logs. 251 | while True: 252 | all_logs_again = zappa.fetch_logs(lambda_name) 253 | new_logs = [] 254 | for log in all_logs_again: 255 | if log not in all_logs: 256 | new_logs.append(log) 257 | 258 | print_logs(new_logs) 259 | all_logs = all_logs + new_logs 260 | except KeyboardInterrupt: 261 | # Die gracefully 262 | try: 263 | sys.exit(0) 264 | except SystemExit: 265 | os._exit(0) 266 | 267 | if __name__ == "__main__": 268 | cli() 269 | --------------------------------------------------------------------------------