I'm relatively new to Python, coming from a deep C++ background. I'm mostly looking for feedback on how to make my code more idiomatic/pythonic, but I would welcome and appreciate any and all other feedback as well.
This code uses the Python library PyEphem, which builds upon XEphem, an X (UNIX GUI) program that's an astrological ephemeris, which is (was historically) a table of celestial bodies and their positions in the sky. PyEphem is used to track the altitudes of astronomical bodies, which are used as an indication of when astrological aspects have or are going to occur.
I'm deploying this code as an AWS Lambda microservice, thus the lambda_handler
function. Consider also reviewing the test and deployment Makefile that corresponds to the module below.
Here is the entirety of my module:
#!/usr/bin/env python2.7
# aspectus: aspect+prospectus. iCalendars with astrological aspect events.
# Copyright (C) 2017 Frederick Eugene Aumson
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""aspectus: aspect+prospectus. Generates iCalendar .ics files to populate your
calendar application with events for astrological aspects (angles) between two
celestial bodies. Currently supports only the trine and sextant aspects, and
only between the sun and the earth. Could easily be extended to support other
bodies, and perhaps with not much more work extended to support aspects between
two bodies outside of Earth.
"""
from dateutil.tz import gettz
from geopy.geocoders import Nominatim
from ephem import Observer, Sun # pylint:disable=no-name-in-module
from datetime import timedelta
from math import degrees, radians
from icalendar import Calendar, Event
def find_altitude(targets, body, observer, step, latest_date):
"""returns a dict with 'target' and 'datetime' properties, where 'datetime'
is the next point in time (UTC) at which the ephem Body :param body will be
found at one of the altitudes in the list :param targets, and 'target' is
the altitude of the body at that datetime. begins the search at the (UTC!)
datetime contained by ephem Observer :param observer and proceeds searching
by stepping the observer's datetime forward by datetime.timedelta :param
step in each iteration. :param targets should be a list of floats
representing altitudes in degrees. :param observer should be already
initialized with a datetime and location. body.compute(observer) should
already have been called. Returns None if :param latest_date is reached
without finding a target.
"""
#print targets, body, observer, step
altitude = degrees(float(repr(body.alt)))
while True:
# capture each target's relation to altitude before taking step forward
before = [target < altitude for target in targets]
# step observer date forward
observer.date = observer.date.datetime() + step
if observer.date.datetime() > latest_date:
#print 'reached latest date'
return None
body.compute(observer)
altitude = degrees(float(repr(body.alt)))
#print altitude, observer
# if any target is "close enough", return it
for target in targets:
if abs(target - altitude) < abs(target * 0.00001):
# step forward to prevent double-catching this target
observer.date = observer.date.datetime() + \
timedelta(seconds=step.total_seconds()*10)
body.compute(observer)
#print 'found', altitude, 'at', observer
return {'target': target, 'datetime': observer.date.datetime()}
# capture each target's relation to altitude after step forward
after = [target < altitude for target in targets]
# a target whose relation changed was passed by the step forward.
# if there is such a target, step the observer date backwards, to
# "un-pass" the target, and then recurse, with a finer-grained step.
changes = [delta[0] != delta[1] for delta in zip(before, after)]
for index, changed in enumerate(changes):
if changed:
observer.date = observer.date.datetime() - step
body.compute(observer)
return find_altitude([targets[index]], body, observer,
timedelta(seconds=step.total_seconds()/10), latest_date)
def generate_icalendar(place, lookaheaddays, start):
"""generate icalendar containing events for all sun alignments at the place
described by string :param place, looking by int()-compatible string :param
lookaheaddays number of days, optionally starting from the optional
dateutil.parser-compatible string :param start, which is interpreted as the
datetime with local time (NOT UTC) in the timezone at :param place, as
determined by tzwhere.
"""
location = Nominatim().geocode(place)
observer = Observer()
observer.lat = radians(location.latitude)
observer.lon = radians(location.longitude)
if start is not None:
from dateutil.parser import parse
observer.date = parse(start).astimezone(gettz('UTC'))
startdate = observer.date.datetime()
sun = Sun()
sun.compute(observer)
cal = Calendar()
cal.add('prodid',
'-//[email protected]//'
'https://github.com/feuGeneA/aspectus//EN')
# embed revision number into URL above
cal.add('version', '2.0') # iCalendar spec version, not prodid version
cal_has_events = False
while observer.date.datetime() < \
startdate + timedelta(days=int(lookaheaddays)):
find = find_altitude([30, -30], sun, observer, timedelta(hours=1),
startdate + timedelta(days=int(lookaheaddays)))
if find == None:
break
event = Event()
if find['target'] == 30:
event.add('summary', 'Sun-earth Trine, at '+place)
elif find['target'] == -30:
event.add('summary', 'Sun-earth Sextant, at '+place)
event.add('dtstart', find['datetime'].replace(tzinfo=gettz('UTC')))
event.add('dtend', \
find['datetime'].replace(tzinfo=gettz('UTC'))\
+timedelta(seconds=1))
cal.add_component(event)
cal_has_events = True
if cal_has_events:
return cal
else:
return None
def lambda_handler(event, context): # pylint:disable=unused-argument
"""handle event from AWS lambda"""
print 'event: ', event
print 'context: ', context
cal = generate_icalendar( \
event['queryStringParameters']['place'],
event['queryStringParameters']['lookaheaddays'],
event['queryStringParameters']['startdatetime'] \
if 'startdatetime' in event['queryStringParameters'] else None)
if cal is None:
return {'statusCode': '204'}
else:
return {
'statusCode': '200',
'body': cal.to_ical(),
'headers': {
'Content-Type': 'text/calendar',
'Content-Disposition': \
'attachment; filename="Sun-earth trines at '+\
event['queryStringParameters']['place']+'.ics"'
},
}
def main():
from sys import argv
cal = generate_icalendar(argv[1], argv[2], None)
print cal.to_ical()
if __name__ == "__main__":
main()
"""Backlog of improvements to this file:
As a user, I want to pass calendar-generation options via the command line, so
that I can generate a calendar by running the python script directly, rather
than relying on an online instance of the service.
As a tester of the web service, I want any Python exceptions to be conveyed as
part of the body of an HTTP 500 response, so that if something goes wrong I
have some indication of what the problem was.
As a user, I want to specify a number of minutes ahead of the alignment at
which I will be notified by my calendar application, so that I don't get
stuck with the application's default (30 minutes for Google Calendar), or,
worse yet, so that I don't have no alarm at all. (Add VALARM components to
the events.)
As a user, rather than the alignment events being a single moment in time, I
want them to have a duration, using an astrological "orb" based on the size
of the body in angular distance, so that I understand how long the alignment
is having an effect. (Use pyehem.body.size, which is diameter in
arcseconds)
As a maintainer, I want the commented-out print statements in
find_altitude() to replaced with debug/trace logger statements, so that I
can enable/disable them in one place, via logger configuration, rather than
hunting for and changing each statement.
""" # PEP258: Additional Docstrings # pylint:disable=pointless-string-statement
I have two unit test modules, which live in a tests
subdirectory alongside the module above. Here is the first:
#!/usr/bin/env python2.7
import unittest
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
import aspectus
from datetime import datetime, timedelta
import icalendar
def mock_find_altitude(targets, body, observer, step, latest_date):
observer.date = observer.date.datetime()+timedelta(days=1)
return {'target': targets[0], 'datetime': observer.date.datetime()}
aspectus.find_altitude = mock_find_altitude
class TestGenerateiCalendar(unittest.TestCase):
def test_one_day_out(self):
cal = aspectus.generate_icalendar('avon nc', '1', None)
self.assertIsInstance(cal, icalendar.Calendar)
self.assertTrue(len(cal.content_lines()) > 0)
if __name__ == "__main__":
unittest.main()
And here is the second unit test module:
#!/usr/bin/env python2.7
import unittest
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
import aspectus
import icalendar
def mock_gen_ical_returning_calendar(place, lookaheaddays, start):
cal = icalendar.Calendar()
return cal
def mock_gen_ical_returning_None(place, lookaheaddays, start):
return None
class TestLambdaHndler(unittest.TestCase):
def test_good(self):
aspectus.generate_icalendar = mock_gen_ical_returning_calendar
resp = aspectus.lambda_handler(
{
'httpMethod': 'GET',
'queryStringParameters': {
'place': 'Avon, North Carolina',
'lookaheaddays': '1',
'startdatetime': '2017/01/01 00:00:00Z-0500'
}
},
None)
self.assertIn('statusCode', resp)
self.assertEqual(resp['statusCode'], '200')