├── requirements.txt ├── MANIFEST.in ├── .travis.yml ├── test-requirements.txt ├── pytest.ini ├── placebo ├── TestCfnCalls.test_notfound │ └── cloudformation.DescribeStacks_1.json ├── TestCfnCalls.test_no_outputs │ └── cloudformation.DescribeStacks_1.json └── TestCfnCalls.test_normal_outputs │ └── cloudformation.DescribeStacks_1.json ├── LICENSE ├── .gitignore ├── tests ├── test_cfn.py └── test_env.py ├── serverless_helpers ├── __init__.py ├── cfn_detect.py └── dotenv.py ├── README.md └── setup.py /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | 5 | sudo: false 6 | 7 | install: travis_retry pip install -r requirements.txt -r test-requirements.txt -e . 8 | 9 | script: py.test 10 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=2.9 2 | pytest-cov 3 | pytest-xdist 4 | mock 5 | placebo 6 | 7 | # version number retrieved March 29 2015 8 | # https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html 9 | boto3==1.2.6 10 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 2.8 3 | looponfailroots = tests serverless_helpers 4 | testpaths = tests 5 | 6 | # use this line to re-run tests on any file edits, for example during development 7 | addopts = --cov serverless_helpers --durations=3 8 | -------------------------------------------------------------------------------- /placebo/TestCfnCalls.test_notfound/cloudformation.DescribeStacks_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 400, 3 | "data": { 4 | "ResponseMetadata": { 5 | "HTTPStatusCode": 400, 6 | "RequestId": "1614c05f-edcf-11e5-b368-ed79f32fee88" 7 | }, 8 | "Error": { 9 | "Message": "Stack with id nonexistent-dev-r does not exist", 10 | "Code": "ValidationError", 11 | "Type": "Sender" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ryan Scott Brown 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,vim 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | .python-version 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | #Ipython Notebook 67 | .ipynb_checkpoints 68 | 69 | 70 | ### Vim ### 71 | [._]*.s[a-w][a-z] 72 | [._]s[a-w][a-z] 73 | *.un~ 74 | Session.vim 75 | .netrwhist 76 | *~ 77 | 78 | -------------------------------------------------------------------------------- /tests/test_cfn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT Licensed, Copyright (c) 2016 Ryan Scott Brown 3 | 4 | import os 5 | from placebo.utils import placebo_session 6 | 7 | import serverless_helpers 8 | 9 | def test_unset_environment(): 10 | os.environ.pop('SERVERLESS_PROJECT_NAME', None) 11 | os.environ.pop('SERVERLESS_STAGE', None) 12 | stack_name = serverless_helpers.cfn_detect.stack_name() 13 | assert stack_name == '' 14 | 15 | class TestCfnCalls(object): 16 | @placebo_session 17 | def test_normal_outputs(self, session): 18 | os.environ['SERVERLESS_STAGE'] = 'dev' 19 | os.environ['SERVERLESS_PROJECT_NAME'] = 'mws' 20 | out = serverless_helpers.load_cfn_outputs(session) 21 | assert len(out) == 2 22 | assert 'Description' in out['IamRoleArnLambda'] 23 | assert 'Value' in out['IamRoleArnLambda'] 24 | assert out['IamRoleArnLambda']['Value'].startswith('arn:aws:iam::123456789012') 25 | assert out['DynamoTable']['Description'] == 'Name of DDB table' 26 | 27 | assert os.getenv('SERVERLESS_CF_IamRoleArnLambda').startswith('arn:aws:iam::123456789012') 28 | 29 | @placebo_session 30 | def test_notfound(self, session): 31 | os.environ['SERVERLESS_STAGE'] = 'dev' 32 | os.environ['SERVERLESS_PROJECT_NAME'] = 'nonexistent' 33 | out = serverless_helpers.load_cfn_outputs(session) 34 | assert out == {} 35 | 36 | @placebo_session 37 | def test_no_outputs(self, session): 38 | os.environ['SERVERLESS_STAGE'] = 'dev' 39 | os.environ['SERVERLESS_PROJECT_NAME'] = 'no_outputs' 40 | out = serverless_helpers.load_cfn_outputs(session) 41 | assert out == {} 42 | -------------------------------------------------------------------------------- /serverless_helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT Licensed, Copyright (c) 2016 Ryan Scott Brown 3 | 4 | __all__ = ['load_dotenv', 'get_key', 'set_key', 'unset_key', 'load_envs', 'load_cfn_outputs'] 5 | 6 | import os 7 | import logging 8 | logger = logging.getLogger() 9 | 10 | from dotenv import load_dotenv, get_key, set_key, unset_key 11 | from cfn_detect import load_cfn_outputs 12 | 13 | def load_envs(path): 14 | """Recursively load .env files starting from `path` 15 | 16 | Usage: from your Lambda function, call load_envs with the value __file__ to 17 | give it the current location as a place to start looking for .env files. 18 | 19 | import serverless_helpers 20 | serverless_helpers.load_envs(__file__) 21 | 22 | Given the path "foo/bar/myfile.py" and a directory structure like: 23 | foo 24 | \---.env 25 | \---bar 26 | \---.env 27 | \---myfile.py 28 | 29 | Values from foo/bar/.env and foo/.env will both be loaded, but values in 30 | foo/bar/.env will take precedence over values from foo/.env 31 | """ 32 | path = os.path.abspath(path) 33 | path, _ = os.path.split(path) 34 | 35 | 36 | if path == '/': 37 | # bail out when you reach top of the FS 38 | _maybe_load(os.path.join(path, '.env')) 39 | return 40 | # load higher envs first 41 | # closer-to-base environments need higher precedence. 42 | load_envs(path) 43 | _maybe_load(os.path.join(path, '.env')) 44 | 45 | 46 | def _maybe_load(env): 47 | if os.path.isfile(env): 48 | logger.debug("Loading .env file %s" % env) 49 | load_dotenv(env) 50 | else: 51 | logger.info(".env file %s does not exist, not loading." % env) 52 | -------------------------------------------------------------------------------- /placebo/TestCfnCalls.test_no_outputs/cloudformation.DescribeStacks_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "data": { 4 | "Stacks": [ 5 | { 6 | "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/no_outputs-dev-r/6434c570-ec35-11e5-88f9-500c28afa0ba", 7 | "Description": "The AWS CloudFormation template for this Serverless application's resources outside of Lambdas and Api Gateway", 8 | "Tags": [ 9 | { 10 | "Value": "dev", 11 | "Key": "STAGE" 12 | } 13 | ], 14 | "Outputs": [], 15 | "CreationTime": { 16 | "hour": 11, 17 | "__class__": "datetime", 18 | "month": 3, 19 | "second": 56, 20 | "microsecond": 428000, 21 | "year": 2016, 22 | "day": 17, 23 | "minute": 42 24 | }, 25 | "Capabilities": [ 26 | "CAPABILITY_IAM" 27 | ], 28 | "StackName": "no_outputs-dev-r", 29 | "NotificationARNs": [], 30 | "StackStatus": "UPDATE_COMPLETE", 31 | "DisableRollback": false, 32 | "LastUpdatedTime": { 33 | "hour": 23, 34 | "__class__": "datetime", 35 | "month": 3, 36 | "second": 37, 37 | "microsecond": 438000, 38 | "year": 2016, 39 | "day": 18, 40 | "minute": 44 41 | } 42 | } 43 | ], 44 | "ResponseMetadata": { 45 | "HTTPStatusCode": 200, 46 | "RequestId": "16648e45-edcf-11e5-a790-ab812dd680fc" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /serverless_helpers/cfn_detect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT Licensed, Copyright (c) 2016 Ryan Scott Brown 3 | 4 | import os 5 | import logging 6 | import boto3 7 | import botocore.exceptions as core_exc 8 | 9 | logger = logging.getLogger() 10 | PREFIX = u'SERVERLESS_CF_' 11 | 12 | 13 | def stack_name(): 14 | if not (os.getenv('SERVERLESS_PROJECT_NAME') or 15 | os.getenv('SERVERLESS_STAGE')): 16 | logger.warning( 17 | 'Could not get SERVERLESS_PROJECT_NAME or SERVERLESS_STAGE from ' 18 | 'environment. This probably means the .env file was not loaded. ' 19 | 'Make sure you call `serverless_helpers.load_envs(__file__)` ' 20 | 'first.') 21 | return '' 22 | 23 | return u'%s-%s-r' % ( 24 | os.getenv('SERVERLESS_PROJECT_NAME'), 25 | os.getenv('SERVERLESS_STAGE') 26 | ) 27 | 28 | 29 | def load_cfn_outputs(boto3_session=None): 30 | """ 31 | Load all stack outputs into the environment, with prefix "SERVERLESS_CF_". 32 | Also returns a dict of return values. 33 | 34 | { 35 | "OutputName": { 36 | "Value": , 37 | "Description": "what this value means" 38 | } 39 | } 40 | """ 41 | if boto3_session is None: 42 | cfn_client = boto3.client('cloudformation') 43 | else: 44 | cfn_client = boto3_session.client('cloudformation') 45 | try: 46 | stacks = cfn_client.describe_stacks(StackName=stack_name()) 47 | except core_exc.ClientError as e: 48 | import json 49 | logger.exception('Failed when retrieving stack') 50 | logger.error('Full response from AWS: %s' % json.dumps(e.response)) 51 | return {} 52 | else: 53 | logger.debug('Retrieved stack %s from CFN API' % stack_name()) 54 | 55 | outputs = stacks['Stacks'][0]['Outputs'] 56 | reformatted = { 57 | o['OutputKey']: { 58 | 'Value': o['OutputValue'], 59 | 'Description': o['Description'] 60 | } for o in outputs 61 | } 62 | for key, value in reformatted.items(): 63 | os.environ.setdefault(PREFIX + key, value['Value']) 64 | 65 | return reformatted 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## serverless_helpers 2 | 3 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 4 | [![test status](https://api.travis-ci.org/serverless/serverless-helpers-py.svg)](https://travis-ci.org/serverless/serverless-helpers-py) 5 | [![version](https://img.shields.io/pypi/v/serverless_helpers.svg)](https://pypi.python.org/pypi/serverless_helpers/) 6 | [![downloads](https://img.shields.io/pypi/dm/serverless_helpers.svg)](https://pypi.python.org/pypi/serverless_helpers/) 7 | [![license](https://img.shields.io/pypi/l/serverless_helpers.svg)](https://github.com/serverless/serverless-helpers-py/blob/master/LICENSE) 8 | [![gitter](https://img.shields.io/gitter/room/serverless/serverless.svg)](https://gitter.im/serverless/serverless) 9 | 10 | This library isn't *required* for writing Python in the [serverless][sls], but 11 | it does make your life easier by handling things like environment variables for 12 | you. 13 | 14 | ## Usage 15 | 16 | ``` 17 | import serverless_helpers 18 | 19 | # all .env files are loaded into the environment 20 | # This is optional if you are using serverless v0.5 or later, because it 21 | # automatically loads variables without help 22 | serverless_helpers.load_envs(__file__) 23 | 24 | # Loads stack outputs into environment variables as `SERVERLESS_CF_[output name]` 25 | serverless_helpers.load_cfn_outputs() 26 | 27 | import os 28 | os.getenv('SERVERLESS_STAGE') # dev 29 | 30 | # get role ARN from default serverless CloudFormation stack 31 | os.getenv('SERVERLESS_CF_IamRoleArnLambda') # arn:aws:iam::123456789012:.... 32 | 33 | # alternate way to read roles 34 | outputs = serverless_helpers.load_cfn_outputs() 35 | outputs['IamRoleArnLambda'] # arn:aws:iam::123456789012:.... 36 | ``` 37 | 38 | ## License 39 | 40 | This code is released under the MIT software license, see LICENSE file for 41 | details. No warranty of any kind is included, and the copyright notice must be 42 | included in redistributions. 43 | 44 | *Notable exception*: `dotenv.py` is from 45 | [python-dotenv](https://github.com/theskumar/python-dotenv) to remove 46 | dependencies on click and ordereddict for performance/deployment size reasons. 47 | Read the license contained in `dotenv.py` for details on its creators and 48 | license conditions. 49 | 50 | [sls]: http://serverless.com/ 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/en/latest/distributing.html 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | # Always prefer setuptools over distutils 9 | from setuptools import setup 10 | # To use a consistent encoding 11 | from codecs import open 12 | from os import path 13 | 14 | here = path.abspath(path.dirname(__file__)) 15 | 16 | test_deps = ['mock', 'pytest', 'pytest-cov', 'pytest-xdist', 'placebo', 'boto3'] 17 | 18 | # Get the long description from the README file 19 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 20 | long_description = f.read() 21 | 22 | setup( 23 | name='serverless_helpers', 24 | 25 | version='0.3.2', 26 | 27 | description='Handy dandy functions for python development using the Serverless framework', 28 | long_description=long_description, 29 | url='https://github.com/serverless/serverless-helpers-py', 30 | author='Ryan Scott Brown', 31 | author_email='sb@ryansb.com', 32 | license='MIT', 33 | 34 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 35 | classifiers=[ 36 | # How mature is this project? Common values are 37 | # 3 - Alpha 38 | # 4 - Beta 39 | # 5 - Production/Stable 40 | 'Development Status :: 4 - Beta', 41 | 42 | # Indicate who your project is intended for 43 | 'Intended Audience :: Developers', 44 | 45 | 'License :: OSI Approved :: MIT License', 46 | 'Programming Language :: Python :: 2', 47 | 'Programming Language :: Python :: 2.7', 48 | ], 49 | 50 | # What does your project relate to? 51 | keywords='serverless lambda aws amazon', 52 | 53 | py_modules=['serverless_helpers'], 54 | packages=['serverless_helpers'], 55 | 56 | # List run-time dependencies here. These will be installed by pip when 57 | # your project is installed. For an analysis of "install_requires" vs pip's 58 | # requirements files see: 59 | # https://packaging.python.org/en/latest/requirements.html 60 | install_requires=[], 61 | 62 | # List additional groups of dependencies here (e.g. development 63 | # dependencies). You can install these using the following syntax, 64 | # for example: 65 | # $ pip install -e .[dev,test] 66 | extras_require={ 67 | 'test': test_deps, 68 | }, 69 | package_data={}, 70 | data_files=[], 71 | entry_points={}, 72 | ) 73 | -------------------------------------------------------------------------------- /placebo/TestCfnCalls.test_normal_outputs/cloudformation.DescribeStacks_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "data": { 4 | "Stacks": [ 5 | { 6 | "StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/fooproj-dev-r/6434c570-ec35-11e5-88f9-500c28afa0ba", 7 | "Description": "The AWS CloudFormation template for this Serverless application's resources outside of Lambdas and Api Gateway", 8 | "Tags": [ 9 | { 10 | "Value": "dev", 11 | "Key": "STAGE" 12 | } 13 | ], 14 | "Outputs": [ 15 | { 16 | "Description": "Name of DDB table", 17 | "OutputKey": "DynamoTable", 18 | "OutputValue": "fooproj-dev-r-DynamoTable-10BOBXVSYCSEZ" 19 | }, 20 | { 21 | "Description": "ARN of the lambda IAM role", 22 | "OutputKey": "IamRoleArnLambda", 23 | "OutputValue": "arn:aws:iam::123456789012:role/fooproj-dev-r-IamRoleLambda-1FXF6RB60LLUT" 24 | } 25 | ], 26 | "CreationTime": { 27 | "hour": 11, 28 | "__class__": "datetime", 29 | "month": 3, 30 | "second": 56, 31 | "microsecond": 428000, 32 | "year": 2016, 33 | "day": 17, 34 | "minute": 42 35 | }, 36 | "Capabilities": [ 37 | "CAPABILITY_IAM" 38 | ], 39 | "StackName": "fooproj-dev-r", 40 | "NotificationARNs": [], 41 | "StackStatus": "UPDATE_COMPLETE", 42 | "DisableRollback": false, 43 | "LastUpdatedTime": { 44 | "hour": 23, 45 | "__class__": "datetime", 46 | "month": 3, 47 | "second": 37, 48 | "microsecond": 438000, 49 | "year": 2016, 50 | "day": 18, 51 | "minute": 44 52 | } 53 | } 54 | ], 55 | "ResponseMetadata": { 56 | "HTTPStatusCode": 200, 57 | "RequestId": "16648e45-edcf-11e5-a790-ab812dd680fc" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # MIT Licensed, Copyright (c) 2016 Ryan Scott Brown 3 | 4 | import os 5 | import mock 6 | import serverless_helpers 7 | 8 | def write_testenv(env_fname): 9 | with open(str(env_fname), 'w') as env: 10 | env.write('''SERVERLESS_TEST=1 11 | SERVERLESS_STAGE=dev 12 | # this is a comment 13 | SERVERLESS_DATA_MODEL_STAGE=dev 14 | SERVERLESS_PROJECT_NAME=test-sls-helpers''') 15 | return str(env_fname) 16 | 17 | def test_load(tmpdir): 18 | env_file = write_testenv(tmpdir.join('.env')) 19 | success = serverless_helpers.load_dotenv(env_file) 20 | 21 | assert success # is True when load succeeds 22 | 23 | assert int(os.getenv('SERVERLESS_TEST')) == 1 24 | assert os.getenv('SERVERLESS_STAGE') == 'dev' 25 | assert os.getenv('SERVERLESS_PROJECT_NAME') == 'test-sls-helpers' 26 | 27 | def test_load_nonexistent(tmpdir): 28 | success = serverless_helpers.load_dotenv('/fake/place/.env') 29 | assert not success 30 | 31 | def test_single_key(tmpdir): 32 | env_file = write_testenv(tmpdir.join('.env')) 33 | data_stage = serverless_helpers.get_key(env_file, 'SERVERLESS_DATA_MODEL_STAGE') 34 | assert data_stage == 'dev' 35 | def test_unset_key(tmpdir): 36 | env_file = write_testenv(tmpdir.join('.env')) 37 | stage = serverless_helpers.get_key(env_file, 'SERVERLESS_STAGE') 38 | assert stage == 'dev' 39 | success, _ = serverless_helpers.unset_key(env_file, 'SERVERLESS_STAGE') 40 | assert success 41 | stage = serverless_helpers.get_key(env_file, 'SERVERLESS_STAGE') 42 | assert stage is None 43 | 44 | def test_read_nonexistent(tmpdir): 45 | env_file = write_testenv(tmpdir.join('.env')) 46 | data_stage = serverless_helpers.get_key(env_file + 'fooooo', 'SERVERLESS_DATA_MODEL_STAGE') 47 | assert data_stage is None 48 | 49 | def test_write_nonexistent(tmpdir): 50 | env_file = write_testenv(tmpdir.join('.env')) 51 | success, key, val = serverless_helpers.set_key(env_file + 'fooooo', 'WRITE', 'nope') 52 | assert success is None 53 | assert key == 'WRITE' 54 | 55 | def test_get_nonexistent(tmpdir): 56 | env_file = write_testenv(tmpdir.join('.env')) 57 | data = serverless_helpers.get_key(env_file, 'NOT_A_THING') 58 | assert data is None 59 | 60 | def test_override_key(tmpdir): 61 | env_file = write_testenv(tmpdir.join('.env')) 62 | data_stage = serverless_helpers.get_key(env_file, 'SERVERLESS_DATA_MODEL_STAGE') 63 | assert data_stage == 'dev' 64 | 65 | serverless_helpers.set_key(env_file, 'SERVERLESS_DATA_MODEL_STAGE', 'overridden') 66 | data_stage = serverless_helpers.get_key(env_file, 'SERVERLESS_DATA_MODEL_STAGE') 67 | 68 | assert data_stage == 'overridden' 69 | 70 | @mock.patch('serverless_helpers.load_dotenv') 71 | def test_get_path_up(load_mock): 72 | """ 73 | Test that recursive config loading only reads upwards. 74 | """ 75 | serverless_helpers.load_envs(__file__) 76 | prev_call = '/' 77 | for call in load_mock.call_args_list: 78 | assert call[0][0].startswith(prev_call.replace('.env', '')) 79 | prev_call = call[0][0] 80 | 81 | def test_more_specific_dirs_override(tmpdir): 82 | """ 83 | Test that .env files closer to the starting dir override 84 | environments that are higher in the heirarchy 85 | """ 86 | base = tmpdir.join('.env') 87 | base.write('OVERRIDE_ME=dev\nCONST=foo') 88 | 89 | serverless_helpers.load_envs(os.path.join(str(tmpdir), 'file.py')) 90 | assert os.getenv('OVERRIDE_ME') == 'dev' 91 | assert os.getenv('CONST') == 'foo' 92 | 93 | override = tmpdir.mkdir('first').join('.env') 94 | override.write('OVERRIDE_ME=prod') 95 | 96 | serverless_helpers.load_envs(os.path.join(str(tmpdir), 'first', 'file.py')) 97 | assert os.getenv('CONST') == 'foo' 98 | assert os.getenv('OVERRIDE_ME') == 'prod' 99 | -------------------------------------------------------------------------------- /serverless_helpers/dotenv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | This code is from https://github.com/theskumar/python-dotenv 5 | copied Mar 10 2016 6 | 7 | LICENSE: 8 | python-dotenv 9 | Copyright (c) 2014, Saurabh Kumar 10 | 11 | All rights reserved. 12 | 13 | Redistribution and use in source and binary forms, with or without modification, 14 | are permitted provided that the following conditions are met: 15 | 16 | * Redistributions of source code must retain the above copyright notice, 17 | this list of conditions and the following disclaimer. 18 | * Redistributions in binary form must reproduce the above copyright notice, 19 | this list of conditions and the following disclaimer in the documentation 20 | and/or other materials provided with the distribution. 21 | * Neither the name of python-dotenv nor the names of its contributors 22 | may be used to endorse or promote products derived from this software 23 | without specific prior written permission. 24 | 25 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 26 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 27 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 28 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 29 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 30 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 31 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 32 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 33 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 34 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 35 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 36 | 37 | 38 | django-dotenv-rw 39 | Copyright (c) 2013, Ted Tieken 40 | 41 | All rights reserved. 42 | 43 | Redistribution and use in source and binary forms, with or without modification, 44 | are permitted provided that the following conditions are met: 45 | 46 | * Redistributions of source code must retain the above copyright notice, 47 | this list of conditions and the following disclaimer. 48 | * Redistributions in binary form must reproduce the above copyright notice, 49 | this list of conditions and the following disclaimer in the documentation 50 | and/or other materials provided with the distribution. 51 | * Neither the name of django-dotenv nor the names of its contributors 52 | may be used to endorse or promote products derived from this software 53 | without specific prior written permission. 54 | 55 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 56 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 57 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 58 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 59 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 60 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 61 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 62 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 63 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 64 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 65 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 66 | 67 | Original django-dotenv 68 | Copyright (c) 2013, Jacob Kaplan-Moss 69 | 70 | All rights reserved. 71 | 72 | Redistribution and use in source and binary forms, with or without modification, 73 | are permitted provided that the following conditions are met: 74 | 75 | * Redistributions of source code must retain the above copyright notice, 76 | this list of conditions and the following disclaimer. 77 | * Redistributions in binary form must reproduce the above copyright notice, 78 | this list of conditions and the following disclaimer in the documentation 79 | and/or other materials provided with the distribution. 80 | * Neither the name of django-dotenv nor the names of its contributors 81 | may be used to endorse or promote products derived from this software 82 | without specific prior written permission. 83 | 84 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 85 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 86 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 87 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 88 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 89 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 90 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 91 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 92 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 93 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 94 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 95 | """ 96 | 97 | import os 98 | import warnings 99 | 100 | try: 101 | from collections import OrderedDict # noqa 102 | except ImportError: 103 | from ordereddict import OrderedDict # noqa 104 | 105 | 106 | def load_dotenv(dotenv_path): 107 | """ 108 | Read a .env file and load into os.environ. 109 | """ 110 | if not os.path.exists(dotenv_path): 111 | warnings.warn("Not loading %s - it doesn't exist." % dotenv_path) 112 | return None 113 | for k, v in parse_dotenv(dotenv_path): 114 | if k in os.environ: 115 | os.environ.pop(k) 116 | os.environ.setdefault(k, v) 117 | return True 118 | 119 | 120 | def get_key(dotenv_path, key_to_get): 121 | """ 122 | Gets the value of a given key from the given .env 123 | 124 | If the .env path given doesn't exist, fails 125 | """ 126 | key_to_get = str(key_to_get) 127 | if not os.path.exists(dotenv_path): 128 | warnings.warn("can't read %s - it doesn't exist." % dotenv_path) 129 | return None 130 | dotenv_as_dict = OrderedDict(parse_dotenv(dotenv_path)) 131 | if key_to_get in dotenv_as_dict: 132 | return dotenv_as_dict[key_to_get] 133 | else: 134 | warnings.warn("key %s not found in %s." % (key_to_get, dotenv_path)) 135 | return None 136 | 137 | 138 | def set_key(dotenv_path, key_to_set, value_to_set): 139 | """ 140 | Adds or Updates a key/value to the given .env 141 | 142 | If the .env path given doesn't exist, fails instead of risking creating 143 | an orphan .env somewhere in the filesystem 144 | """ 145 | key_to_set = str(key_to_set) 146 | value_to_set = str(value_to_set).strip("'").strip('"') 147 | if not os.path.exists(dotenv_path): 148 | warnings.warn("can't write to %s - it doesn't exist." % dotenv_path) 149 | return None, key_to_set, value_to_set 150 | dotenv_as_dict = OrderedDict(parse_dotenv(dotenv_path)) 151 | dotenv_as_dict[key_to_set] = value_to_set 152 | success = flatten_and_write(dotenv_path, dotenv_as_dict) 153 | return success, key_to_set, value_to_set 154 | 155 | 156 | def unset_key(dotenv_path, key_to_unset): 157 | """ 158 | Removes a given key from the given .env 159 | 160 | If the .env path given doesn't exist, fails 161 | If the given key doesn't exist in the .env, fails 162 | """ 163 | key_to_unset = str(key_to_unset) 164 | if not os.path.exists(dotenv_path): 165 | warnings.warn("can't delete from %s - it doesn't exist." % dotenv_path) 166 | return None, key_to_unset 167 | dotenv_as_dict = OrderedDict(parse_dotenv(dotenv_path)) 168 | if key_to_unset in dotenv_as_dict: 169 | dotenv_as_dict.pop(key_to_unset, None) 170 | else: 171 | warnings.warn("key %s not removed from %s - key doesn't exist." % (key_to_unset, dotenv_path)) 172 | return None, key_to_unset 173 | success = flatten_and_write(dotenv_path, dotenv_as_dict) 174 | return success, key_to_unset 175 | 176 | 177 | def parse_dotenv(dotenv_path): 178 | with open(dotenv_path) as f: 179 | for line in f: 180 | line = line.strip() 181 | if not line or line.startswith('#') or '=' not in line: 182 | continue 183 | k, v = line.split('=', 1) 184 | v = v.strip("'").strip('"') 185 | yield k, v 186 | 187 | 188 | def flatten_and_write(dotenv_path, dotenv_as_dict): 189 | with open(dotenv_path, "w") as f: 190 | for k, v in dotenv_as_dict.items(): 191 | f.write('%s="%s"\n' % (k, v)) 192 | return True 193 | --------------------------------------------------------------------------------