Repository: inhumantsar/python-ec2-reaper

Issue:
Unserializable datetimes strike again!
```
$ sls invoke -f cron
{
    "stackTrace": [
        [
            "/var/task/handler.py",
            4,
            "run",
            "return ec2_reaper.aws_lambda.handler(event, context)"
        ],
        [
            "/var/task/ec2_reaper/aws_lambda.py",
            128,
            "handler",
            "_notify(msg, attachments)"
        ],
        [
            "/var/task/ec2_reaper/aws_lambda.py",
            69,
            "_notify",
            "r = requests.post(SLACK_ENDPOINT, json=data, headers=headers)"
        ],
        [
            "/var/task/requests/api.py",
            112,
            "post",
            "return request('post', url, data=data, json=json, **kwargs)"
        ],
        [
            "/var/task/requests/api.py",
            58,
            "request",
            "return session.request(method=method, url=url, **kwargs)"
        ],
        [
            "/var/task/requests/sessions.py",
            494,
            "request",
            "prep = self.prepare_request(req)"
        ],
        [
            "/var/task/requests/sessions.py",
            437,
            "prepare_request",
            "hooks=merge_hooks(request.hooks, self.hooks),"
        ],
        [
            "/var/task/requests/models.py",
            308,
            "prepare",
            "self.prepare_body(data, files, json)"
        ],
        [
            "/var/task/requests/models.py",
            458,
            "prepare_body",
            "body = complexjson.dumps(json)"
        ],
        [
            "/usr/lib64/python2.7/json/__init__.py",
            244,
            "dumps",
            "return _default_encoder.encode(obj)"
        ],
        [
            "/usr/lib64/python2.7/json/encoder.py",
            207,
            "encode",
            "chunks = self.iterencode(o, _one_shot=True)"
        ],
        [
            "/usr/lib64/python2.7/json/encoder.py",
            270,
            "iterencode",
            "return _iterencode(o, 0)"
        ],
        [
            "/usr/lib64/python2.7/json/encoder.py",
            184,
            "default",
            "raise TypeError(repr(o) + \" is not JSON serializable\")"
        ]
    ],
    "errorType": "TypeError",
    "errorMessage": "datetime.datetime(2017, 12, 15, 20, 4, 33, tzinfo=tzlocal()) is not JSON serializable"
}
 
  Error --------------------------------------------------
 
  Invoked function failed
 
     For debugging logs, run again after setting the "SLS_DEBUG=*" environment variable.
 
  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Forums:        forum.serverless.com
     Chat:          gitter.im/serverless/serverless
 
  Your Environment Information -----------------------------
     OS:                     linux
     Node Version:           6.11.4
     Serverless Version:     1.24.1
 
```

Discussion:

Patch:
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 296e6d4..0000000
--- a/Makefile
+++ /dev/null
@@ -1,87 +0,0 @@
-.PHONY: clean clean-test clean-pyc clean-build docs help
-.DEFAULT_GOAL := help
-define BROWSER_PYSCRIPT
-import os, webbrowser, sys
-try:
-	from urllib import pathname2url
-except:
-	from urllib.request import pathname2url
-
-webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
-endef
-export BROWSER_PYSCRIPT
-
-define PRINT_HELP_PYSCRIPT
-import re, sys
-
-for line in sys.stdin:
-	match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
-	if match:
-		target, help = match.groups()
-		print("%-20s %s" % (target, help))
-endef
-export PRINT_HELP_PYSCRIPT
-BROWSER := python -c "$$BROWSER_PYSCRIPT"
-
-help:
-	@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
-
-clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
-
-
-clean-build: ## remove build artifacts
-	rm -fr build/
-	rm -fr dist/
-	rm -fr .eggs/
-	find . -name '*.egg-info' -exec rm -fr {} +
-	find . -name '*.egg' -exec rm -f {} +
-
-clean-pyc: ## remove Python file artifacts
-	find . -name '*.pyc' -exec rm -f {} +
-	find . -name '*.pyo' -exec rm -f {} +
-	find . -name '*~' -exec rm -f {} +
-	find . -name '__pycache__' -exec rm -fr {} +
-
-clean-test: ## remove test and coverage artifacts
-	rm -fr .tox/
-	rm -f .coverage
-	rm -fr htmlcov/
-
-lint: ## check style with flake8
-	flake8 ec2_reaper tests
-
-test: ## run tests quickly with the default Python
-	py.test
-	
-
-test-all: ## run tests on every Python version with tox
-	tox
-
-coverage: ## check code coverage quickly with the default Python
-	coverage run --source ec2_reaper -m pytest
-	coverage report -m
-	coverage html
-	$(BROWSER) htmlcov/index.html
-
-docs: ## generate Sphinx HTML documentation, including API docs
-	rm -f docs/ec2_reaper.rst
-	rm -f docs/modules.rst
-	sphinx-apidoc -o docs/ ec2_reaper
-	$(MAKE) -C docs clean
-	$(MAKE) -C docs html
-	$(BROWSER) docs/_build/html/index.html
-
-servedocs: docs ## compile the docs watching for changes
-	watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
-
-release: clean ## package and upload a release
-	python setup.py sdist upload
-	python setup.py bdist_wheel upload
-
-dist: clean ## builds source and wheel package
-	python setup.py sdist
-	python setup.py bdist_wheel
-	ls -l dist
-
-install: clean ## install the package to the active Python's site-packages
-	python setup.py install
diff --git a/ec2_reaper/aws_lambda.py b/ec2_reaper/aws_lambda.py
index ea519d0..c51e330 100644
--- a/ec2_reaper/aws_lambda.py
+++ b/ec2_reaper/aws_lambda.py
@@ -14,8 +14,6 @@ def _is_py3():
 
 log = logging.getLogger(__name__)
 
-DEFAULT_SLACK_ENDPOINT = ''
-
 MIN_AGE = os.environ.get('MIN_AGE', ec2_reaper.DEFAULT_MIN_AGE)
 REGIONS = os.environ.get('REGIONS', ec2_reaper.DEFAULT_REGIONS)
 REGIONS = REGIONS.split(' ') if isinstance(REGIONS, str) else REGIONS
@@ -36,7 +34,7 @@ else:
     logging.getLogger('botocore').setLevel(logging.WARNING)
     logging.getLogger('boto3').setLevel(logging.WARNING)
 
-# this is necessary because tz-aware dts aren't JSON serializable by default
+# so that we can send tz-aware datetimes through json
 class DateTimeJSONEncoder(json.JSONEncoder):
     def default(self, o):
         if isinstance(o, datetime):
@@ -47,7 +45,7 @@ def _respond(body, error=True, headers=None, status_code=500):
     o = {'statusCode': status_code, 'body': body}
     if headers:
         o['headers'] = headers
-    return json.dumps(o, cls=DateTimeJSONEncoder)
+    return o
 
 def _get_expires(launch_time, min_age=MIN_AGE):
     # if launch_time is naive, assume UTC
@@ -66,7 +64,8 @@ def _notify(msg, attachments=[]):
 
     data = {'text': msg, 'attachements': attachments}
     headers = {'Content-Type': 'application/json'}
-    r = requests.post(SLACK_ENDPOINT, json=data, headers=headers)
+    r = requests.post(SLACK_ENDPOINT, headers=headers,
+                      data=json.dumps(data, cls=DateTimeJSONEncoder))
 
     if r.status_code != 200:
         log.error('Slack notification failed: (HTTP {}) {}'.format(r.status_code, r.text))
@@ -127,4 +126,7 @@ def handler(event, context):
             })
         _notify(msg, attachments)
 
-    return _respond(reaperlog, error=False, status_code=200)
+    r = {'reaped': len(reaped), 'matches_under_min_age': len(too_young),
+         'tag_matches': len([i for i in reaperlog if i['tag_match']]),
+         'instances': len(reaperlog), 'log': reaperlog}
+    return _respond(r, error=False, status_code=200)


Test patch:
diff --git a/tests/test_lambda_handler.py b/tests/test_lambda_handler.py
index 08bd129..bc153b4 100644
--- a/tests/test_lambda_handler.py
+++ b/tests/test_lambda_handler.py
@@ -10,10 +10,11 @@ logging.basicConfig(level=logging.DEBUG)
 logging.getLogger('botocore').setLevel(logging.INFO)
 logging.getLogger('boto3').setLevel(logging.INFO)
 
-if sys.version_info >= (3, 0):
-    from unittest.mock import patch
-else:
+if sys.version_info < (3, 0) or (sys.version_info >= (3, 5) and
+                                 sys.version_info < (3, 6)):
     from mock import patch
+else:
+    from unittest.mock import patch
 
 # when no results, handler should have called reap, *not* called (slack) notify,
 # and should have returned a happy response json obj,
@@ -21,12 +22,13 @@ else:
 @patch.object(aws_lambda, '_notify')
 def test_reap_no_results(mock_notify, mock_reap):
     mock_reap.return_value = []
-    r = json.loads(aws_lambda.handler({}, {}))
+    r = aws_lambda.handler({}, {})
 
     mock_notify.assert_not_called()
     mock_reap.assert_called_once()
     assert r['statusCode'] == 200
-    assert r['body'] == []
+    assert r['body']['log'] == []
+    assert r['body']['reaped'] == 0
 
 # with pos and neg results, handler should have called reap,
 # called (slack) notify, and should have returned a happy response json obj with
@@ -46,9 +48,10 @@ def test_reap_2neg_1pos(mock_notify, mock_reap):
          'launch_time': match_time, 'reaped': True, 'region': 'us-east-1'},
     ]
     mock_reap.return_value = mock_reap_results
-    r = json.loads(aws_lambda.handler({}, {}))
+    r = aws_lambda.handler({}, {})
 
     mock_notify.assert_called()
     mock_reap.assert_called_once()
     assert r['statusCode'] == 200
-    assert r['body'] == json.loads(json.dumps(mock_reap_results, cls=aws_lambda.DateTimeJSONEncoder))
+    assert r['body']['log'] == mock_reap_results
+    assert r['body']['reaped'] == 1