#!/usr/bin/python -tt
from __future__ import with_statement

# am-i, a script to determine whether Google Calendar events currently apply
# Copyright (c) 2010-3 John Morrissey <jwm@horde.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307, USA.

# I got tired of having to enable vacation(1) manually when I go on
# vacation. I already track when I'm not working using Google Calendar,
# so I wrote this program to automatically check whether I'm on vacation
# and invoke a subprogram (in my case, vacation(1)) when I am. Instead of
# executing a subprogram, it can also use its exitstatus to signal whether
# a given condition currently applies, based on Google Calendar events.
#
# It's generic enough to be useful in a wide variety of cases. If you track
# events in a Google Calendar and want to determine whether a type of event
# applies right now, it should be easy to configure this program to work
# for you.
#
# It tries to be intelligent about weekends and workday hours. For example,
# you can configure it (using the day_start/day_end/weekend items) so that
# if it's Friday and you're on vacation (the 'vacation' condition applies),
# it will use a return date of the following Monday.

# Useful invocations
# ==================
#
# - Automatically detect when you're on vacation and invoke the vacation(1)
#   utility to send an autoreply.
#
#       am-i vacation jwm -- vacation -m @MSG_FILE@ jwm
#
# - Determine whether you're on call. Use this as a conditional in your
#   mail filteirng rules to determine, for example, whether to forward
#   Nagios alerts to you via SMS only when you're on call.
#
#       am-i oncall jwm

# Message template variables
# ==========================
#
# This program can expand certain variables in a message template.
# - $RETURN_ON
#   The date when the specified condition is no longer met. For example,
#   when calendar events indicate you're no longer on vacation. The
#   format of this date may be overridden on the command line.
# - $RETURN_ON_ROMAN
#   $RETURN_ON, expressed in Roman numerals. This variable is not subject
#   to the specified date format.

# Subprogram variables
# ====================
#
# This program can pass variables as arguments to subprograms.
# - @MSG_FILE@
#   If this variable is present in the subprogram's arguments, variables in
#   the specified message template will be expanded. When expansion is
#   complete, the resulting message is written to a temporary file. This
#   variable is replaced by the filename of the temporary file.
# - @RETURN_ON@
#   The date when the specified condition is no longer met. For example,
#   when calendar events indicate you're no longer on vacation. The
#   format of this date may be overridden on the command line.

# Exit status
# ====================
#
# - If a subprogram *is* specified: exits nonzero if the subprogram
#   invocation failed or an error was encountered in determining
#   whether the specified condition applies. Otherwise, exits
#   successfully (exitstatus 0). This is useful for invoking this
#   program from a mail filter (such as procmail or maildrop) to
#   invoke vacation(1) when on vacation.
# - If a subprogram is *not* specified: exits successfully (exit status 0)
#   if the specified condition applies. Exits with status 1 if the condition
#   does not apply. Exits with any other exit status if an error was
#   encountered. This makes the program useful as a condition in shell
#   scripts or mail filtering recipes.

# This should be ready to run under Python 3, once the Python gdata bindings
# have Python 3 support:
#
#   http://code.google.com/p/gdata-python-client/issues/detail?id=229
#
# Python's 'except' syntax changed in Python 3 ('except Exception, e' ->
# 'except Exception as e'). Support for 'as' has been backported as far
# as Python 2.6, so this program now requires Python 2.6 or newer.

import calendar
import datetime
import fcntl
import optparse
import os
import pickle
import stat
import subprocess
import sys
import tempfile
import time
import traceback

import atom
import gdata.calendar
import gdata.calendar.client

# E-mail address and password for your Google account.
EMAIL = ''
PASSWORD = ''
# Calendar to look for events in.
CALENDAR_NAME = ''

# Keys are conditions (specified on the command line). Values contain
# further configuration specific to that condition:
#
# - keywords: words that must be present in a calendar event for the
#   calendar event to be considered for that condition.
# - day_start, day_end (optional): when the 'work day' starts and ends.
#
#   After the workday ends, the following day's events scheduled after
#   day_start are used to determine whether the specified condition is met.
#   Before the workday starts, events starting after day_start are used to
#   determine whether the specified condition is met.
#
#   If these items aren't present, this logic is disabled.
#
#   This allows the script to (for example) send vacation messages
#   after the workday ends, so someone e-mailing you at night will
#   still get a vacation message, even though the calendar event
#   for your vacation might not "start" until the following day.
#   This makes it easy to schedule a full day's vacation by creating
#   an all day event for the day you'll be gone.
# - weekend_days (optional): days that are part of the weekend. When
#   calculating a return date, this program skips weekend days if
#   they're in the middle of a satisfied condition. For example, if
#   your calendar events have you on vacation for Friday and Monday
#   and weekend_days set to Saturday and Sunday, your return date
#   will be correctly calculated as Tuesday.
#
#   If these items aren't present, this logic is disabled.
#
# The (optional) 'vacation_everyone' trigger is automatically checked
# when the 'vacation' condition is specified. The difference is that
# vacation_everyone applies to all users; it doesn't require that
# the specified username is present in the event title. This is useful
# for detecting holidays that apply to everyone.
TRIGGERS = {
	'oncall': {
		'keywords': ['oncall'],
	},
	'vacation': {
		'keywords': ['pto', 'vacation'],
		'day_start': datetime.time(hour=8),
		'day_end': datetime.time(hour=16),
		'weekend_days': [calendar.SATURDAY, calendar.SUNDAY],
	},
	'vacation_everyone': {
		'keywords': ['holiday'],
	},
}

# Search this far into the future for a return date. If no return date is
# found in this time period, we use the day after SEARCH_FOR_RETURN as the
# return date.
SEARCH_FOR_RETURN = datetime.timedelta(days=21)

# Calendar events are cached in EVENTS_CACHE for EVENTS_CACHE_LIFETIME.
EVENTS_CACHE = '~/.am-i.eventscache'
EVENTS_CACHE_LIFETIME = datetime.timedelta(hours=1)

# Enables debugging mode. Enables verbose output and disables
# subprogram execution.
DEBUG = False


EX_OK = 0
EX_USAGE = 64
EX_TEMPFAIL = 75


def calService():
	"""Get a Google Calendar service handle."""

	try:
		return calService.svc, calService.cal
	except AttributeError:
		pass

	calService.svc = gdata.calendar.client.CalendarClient()
	calService.svc.ClientLogin(EMAIL, PASSWORD, '%s (%s)' % (
		os.path.basename(sys.argv[0]), EMAIL,
	))

	calService.cal = [
		cal
		for cal
		 in calService.svc.GetAllCalendarsFeed().entry
		 if cal.title.text == CALENDAR_NAME
	]
	if not calService.cal:
		raise Exception('Calendar "%s" doesn\'t exist, or your account doesn\'t have access to it.' %
			CALENDAR_NAME)
	calService.cal = calService.cal[0]

	if DEBUG:
		print('Feed timezone: %s' % calService.cal.timezone.value)
	os.environ['TZ'] = calService.cal.timezone.value
	time.tzset()

	return calService.svc, calService.cal

def timezone_offset_string():
	"""Get the current timezone's offset as a string. For example,
	'-04:00'"""

	if time.localtime().tm_isdst:
		tz_offset = -time.altzone / 60
	else:
		tz_offset = -time.timezone / 60
	
	if tz_offset < 0:
		sign = '-'
	else:
		sign = '+'
	return '%s%02d:%02d' % (
		sign, abs(tz_offset) / 60, abs(tz_offset) % 60,
	)

def get_events(day_start, day_end, weekend_days):
	svc, cal = calService()

	query = gdata.calendar.client.CalendarEventQuery()
	query.max_results = 10000
	query.singleevents = 'true'
	query.orderby = 'starttime'

	start = datetime.datetime.now()
	start = wrap_day(start, day_start, day_end)
	# Weekends consider the following Monday's events.
	start = skip_weekend(start, weekend_days)

	offset_str = timezone_offset_string()

	query.start_min = start.strftime('%Y-%m-%dT%H:%M:%S') + offset_str
	query.start_max = datetime.datetime.combine(
		start.date() + SEARCH_FOR_RETURN, datetime.time()).\
		strftime('%Y-%m-%dT%H:%M:%S') + offset_str
	if DEBUG:
		print('Fetching events from %s to %s.' % (
			query.start_min, query.start_max,
		))

	feed = svc.GetCalendarEventFeed(
		uri=cal.GetLink('http://schemas.google.com/gCal/2005#eventFeed').href,
		q=query)
	# 'orderby starttime' yields most recent entries first,
	# but we want to iterate in chronological order.
	feed.entry.reverse()

	return feed.entry

def gcal2datetime(when):
	"""Convert a Google Calendar date and time string to a
	datetime.datetime instance."""
	
	try:
		return datetime.datetime.strptime(when[:23], "%Y-%m-%dT%H:%M:%S.000")
	except ValueError:
		try:
			return datetime.datetime.strptime(when, "%Y-%m-%d")
		except ValueError:
			return None

def skip_weekend(when, days):
	while when.weekday() in days:
		if DEBUG:
			print('%s is during the weekend, adding one day.' % when)
		when += datetime.timedelta(days=1)
	return when

def wrap_day(when, day_start, day_end):
	"""If a datetime object is after the end of the workday, wrap it
	to the beginning of the next day."""

	if when.time() >= day_end:
		when += datetime.timedelta(days=1)
		when = datetime.datetime.combine(
			when.date(), day_start)
	if when.time() < day_start:
		when = datetime.datetime.combine(
			when.date(), day_start)
	return when

def eligible_event(event, who, trigger_key):
	for word in event.title.text.split():
		if trigger_key == 'vacation' and \
		   'vacation_everyone' in TRIGGERS and \
		   word.lower() in TRIGGERS['vacation_everyone']['keywords']:
			return True
		if who in event.title.text and \
		   word.lower() in TRIGGERS[trigger_key]['keywords']:
			return True

	return False

def expected_return(now, events, trigger_key, who):
	"""Calculate the date a condition no longer applies, honoring
	weekends and workday start/end."""

	if DEBUG:
		print('Now: %s' % now)

	if type(now) == datetime.date:
		return_on = datetime.datetime.combine(now, datetime.time())
	else:
		return_on = now

	day_start = TRIGGERS[trigger_key].get('day_start',
		datetime.time(hour=8))
	day_end = TRIGGERS[trigger_key].get('day_end',
		datetime.time(hour=16))

	return_on = wrap_day(return_on, day_start, day_end)
	return_on = skip_weekend(return_on,
		TRIGGERS[trigger_key].get('weekend_days', []))
	orig_return_on = return_on

	for event in events:
		if DEBUG:
			print('Event: %s, from %s to %s' % (
				event.title.text, event.when[0].start,
				event.when[0].end,
			))

		if not eligible_event(event, who, trigger_key):
			continue

		start = gcal2datetime(event.when[0].start)
		end = gcal2datetime(event.when[0].end)
		if start > return_on or end < return_on:
			continue

		if DEBUG:
			print('Event bumps return_on from %s to %s.' % (
				return_on, end,
			))
		return_on = end
		# Don't stop the loop here; we want to check all events,
		# since they might extend return_on even further.

		return_on = wrap_day(return_on, day_start, day_end)
		return_on = skip_weekend(return_on,
			TRIGGERS[trigger_key].get('weekend_days', []))
		if DEBUG:
			print('Day-wrapped return_on: %s.' % return_on)

	if orig_return_on.date() >= return_on.date():
		return None
	return return_on

def call_subprogram(success_exec, return_on, date_format, msg_template=None):
	return_on_pretty = return_on.strftime(date_format)
	try:
		import roman
		return_on_roman = '%s, %s %s %s' % (
			return_on.strftime('%A'),
			roman.toRoman(int(return_on.strftime('%d'))),
			return_on.strftime('%B'),
			roman.toRoman(int(return_on.strftime('%Y'))),
		)
	except ImportError:
		return_on_roman = '[Python roman module is not installed]'

	if '@MSG_FILE@' in success_exec:
		fp = open(os.path.expanduser(msg_template))
		msg = ''.join(fp.readlines())
		fp.close()

		msg = msg.replace('$RETURN_ON_ROMAN', return_on_roman)
		msg = msg.replace('$RETURN_ON', return_on_pretty)

		tmp = tempfile.NamedTemporaryFile()
		tmp.file.write(msg)
		tmp.file.close()

	def expand_args(arg):
		if arg == '@MSG_FILE@':
			try:
				# If a tempfile was generated, return its name.
				return tmp.name
			except NameError:
				# Otherwise, fall through and return the raw arg.
				pass
		elif arg == '@RETURN_ON@':
			return return_on_pretty
		return arg
	success_exec = [
		expand_args(arg)
		for arg
		 in success_exec
	]

	if DEBUG:
		print('Would invoke "%s", returning on %s.' % (
			' '.join(success_exec), return_on_pretty,
		))
		return

	retcode = subprocess.call(success_exec)
	if retcode != 0:
		raise Exception(
			'Subprogram invocation failed with exitstatus %d.' % \
				retcode)

def main(trigger_key, who, success_exec, options):
	cache = os.path.expanduser(EVENTS_CACHE)
	expires_after = EVENTS_CACHE_LIFETIME.days * 60 * 60 + \
		EVENTS_CACHE_LIFETIME.seconds

	fd = -1
	try:
		fd = os.open(cache, os.O_RDWR | os.O_CREAT,
			stat.S_IRUSR | stat.S_IWUSR)
		fcntl.flock(fd, fcntl.LOCK_EX)
		st = os.stat(cache)
	except IOError as e:
		if fd != -1:
			os.close(fd)
		sys.stderr.write(
			'Failed to open and lock event cache %s: %s\n' % (
				cache, str(e)
			)
		)
		return EX_TEMPFAIL

	# If the cache doesn't already exist, creating and locking it
	# creates a zero-byte file, which would cause pickle to raise
	# an EOFError.
	if st[stat.ST_SIZE] > 0 and \
	   st[stat.ST_MTIME] + expires_after >= time.time():
		pickled = os.read(fd, st[stat.ST_SIZE])
		if len(pickled) != st[stat.ST_SIZE]:
			os.close(fd)
			sys.stderr.write(
				'Short read from pickled events cache %s.' %
				cache
			)
			return EX_TEMPFAIL
		tz, events = pickle.loads(pickled)
		os.environ['TZ'] = tz
		time.tzset()

		if DEBUG:
			print('Using cached events from %s in timezone %s.' % (
				cache, tz
			))
	else:
		try:
			events = get_events(
				TRIGGERS[trigger_key].get('day_start',
					datetime.time(hour=0)),
				TRIGGERS[trigger_key].get('day_end',
					datetime.time(hour=23, minute=59, second=59)),
				TRIGGERS[trigger_key].get('weekend_days', [])
			)

			pickled = pickle.dumps([os.environ['TZ'], events])
			os.ftruncate(fd, 0)
			if len(pickled) != os.write(fd, pickled):
				os.close(fd)
				sys.stderr.write(
					'Short write to pickled events cache %s.\n' %
					cache
				)
				return EX_TEMPFAIL
		except Exception as e:
			os.close(fd)
			sys.stderr.write(str(e))
			sys.stderr.write('\n')
			return EX_TEMPFAIL

	os.close(fd)

	return_on = expected_return(datetime.datetime.now(),
		events, trigger_key, who)

	if not return_on:
		if DEBUG:
			print('Not %s.' % trigger_key)
		if success_exec:
			return EX_OK
		# The specified condition hasn't been met.
		return 1

	if DEBUG:
		print('Will return on: %s' % return_on)

	if success_exec:
		try:
			call_subprogram(success_exec, return_on,
				options.date_format, options.msg_template)
		except Exception as e:
			sys.stderr.write(str(e))
			sys.stderr.write('\n')
			return EX_TEMPFAIL

	# The subprogram has been successfully invoked, and/or
	# the specified condition has been met.
	return EX_OK

try:
	import unittest
except ImportError:
	unittest.TestCase = object

class ExpectedReturnTest(unittest.TestCase):
	def setUp(self):
		global TRIGGERS

		self._removeStaleEvents()
		# We use some year ~far off in the future so we have
		# a playground to make our mess in, ensuring our test
		# events don't scribble on a calendar time period
		# someone might care about while the test suite is
		# running.
		#
		# Also, we need to use the same month that we're
		# currently in. Differing daylight savings time
		# states can cause problems I don't care enough
		# to sort out since they should only affect the
		# test suite:
		#
		# Creating event am-i.testcase vacation
		#   2020-05-11 14:00:00 - 2020-05-11 16:00:00
		# Event: am-i.testcase vacation, from
		#   2020-05-11T14:00:00.000-04:00 to
		#   2020-05-11T15:00:00.000-04:00
		self.days = calendar.Calendar().monthdatescalendar(
			datetime.datetime.now().year + 10,
			datetime.datetime.now().month)[0]

		TRIGGERS = {
			'vacation': {
				'keywords': ['pto', 'vacation'],
				'day_start': datetime.time(hour=8),
				'day_end': datetime.time(hour=16),
				'weekend_days': [
					calendar.SATURDAY,
					calendar.SUNDAY,
				],
			},
		}

	def tearDown(self):
		# Remove stale events after the test case, too,
		# so we don't leave the last test case's events
		# hanging around.
		self._removeStaleEvents()

	def _removeStaleEvents(self):
		query = gdata.calendar.client.CalendarEventQuery(
			text_query='am-i.testcase')
		query.max_results = 10000
		query.orderby = 'starttime'

		svc = calService()[0]
		feed = svc.GetCalendarEventFeed(q=query)
		for event in feed.entry:
			# We search for events containing this string
			# in the calendar query, but it doesn't hurt
			# to be defensive since we're removing events.
			if 'am-i.testcase' not in event.title.text:
				continue
			if DEBUG:
				print('Removing stale test event %s (%s - %s)' % (
					event.title.text, event.when[0].start,
					event.when[0].end,
				))
			# Force the delete. Fixed in:
			# https://code.google.com/p/gdata-python-client/source/detail?r=f98fff494fb89fca12deede00c3567dd589e5f97
			svc.Delete(event.GetEditLink().href, force=True)

	def _createEvent(self, title, start, end):
		if DEBUG:
			print('Creating event %s (%s - %s)' % (title, start, end))

		event = gdata.calendar.data.CalendarEventEntry()
		event.title = atom.data.Title(text=title)

		offset_str = timezone_offset_string()
		event.when.append(gdata.data.When(
			start=start.strftime('%Y-%m-%dT%H:%M:%S.000') + offset_str,
			end=end.strftime('%Y-%m-%dT%H:%M:%S.000') + offset_str
		))

		svc = calService()[0]
		return svc.InsertEvent(event)

	def test_all_day_event(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event = self._createEvent(
			'%s %s' % (who, trigger_key),
			self.days[calendar.TUESDAY],
			self.days[calendar.WEDNESDAY],
		)

		# After day_end the day before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.WEDNESDAY],
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.MONDAY],
					TRIGGERS[trigger_key]['day_end']),
				[event], trigger_key, who))

		# Before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.WEDNESDAY],
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				self.days[calendar.TUESDAY],
				[event], trigger_key, who))

		# At the start of the event
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.WEDNESDAY],
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				self.days[calendar.TUESDAY],
				[event], trigger_key, who))

	def test_partial_day_event_before_day_end(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event_end = datetime.datetime.combine(
			self.days[calendar.MONDAY],
			TRIGGERS[trigger_key]['day_end'])
		event_end -= datetime.timedelta(hours=2)
		event_start = event_end - datetime.timedelta(hours=2)
		event = self._createEvent(
			'%s %s' % (who, trigger_key),
			event_start, event_end,
		)

		# Before
		self.assertEqual(
			None,
			expected_return(
				self.days[calendar.MONDAY],
				[event], trigger_key, who))

		# At the start of the event
		self.assertEqual(
			None,
			expected_return(
				event_start, [event], trigger_key, who))

	def test_partial_day_event_at_day_end(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event_end = datetime.datetime.combine(
			self.days[calendar.MONDAY],
			TRIGGERS[trigger_key]['day_end'])
		event_start = event_end - datetime.timedelta(hours=2)
		event = self._createEvent(
			'%s %s' % (who, trigger_key),
			event_start, event_end
		)

		# Before
		self.assertEqual(
			None,
			expected_return(self.days[calendar.MONDAY],
				[event], trigger_key, who))

		# At the start of the event
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.TUESDAY],
				TRIGGERS[trigger_key]['day_start']),
			expected_return(event_start,
				[event], trigger_key, who))

	def test_partial_day_event_at_day_end_before_weekend(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event_end = datetime.datetime.combine(
			self.days[calendar.FRIDAY],
			TRIGGERS[trigger_key]['day_end'])
		event_start = event_end - datetime.timedelta(hours=2)
		event = self._createEvent(
			'%s %s' % (who, trigger_key),
			event_start, event_end
		)

		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(event_start,
				[event], trigger_key, who))

		self.assertEqual(
			None,
			expected_return(self.days[calendar.FRIDAY],
				[event], trigger_key, who))

	def test_partial_day_event_at_day_end_before_weekend_then_more_elig_events(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event_end = datetime.datetime.combine(
			self.days[calendar.FRIDAY],
			TRIGGERS[trigger_key]['day_end'])
		event_start = event_end - datetime.timedelta(hours=2)
		following_monday = self.days[calendar.MONDAY] + \
			datetime.timedelta(days=7)
		events = [
			self._createEvent(
				'%s %s' % (who, trigger_key),
				event_start, event_end
			),
			self._createEvent(
				'%s %s' % (who, trigger_key),
				following_monday,
				following_monday + datetime.timedelta(days=1)
			),
		]

		self.assertEqual(
			datetime.datetime.combine(
				following_monday + datetime.timedelta(days=1),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(event_start,
				events, trigger_key, who))

		self.assertEqual(
			None,
			expected_return(self.days[calendar.FRIDAY],
				events, trigger_key, who))

	def test_in_weeklong_event(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		event = self._createEvent(
			'%s %s' % (who, trigger_key),
			self.days[calendar.MONDAY],
			self.days[calendar.SATURDAY],
		)

		# Midnight before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				self.days[calendar.MONDAY],
				[event], trigger_key, who))

		# During
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.TUESDAY],
					datetime.time(hour=12)),
				[event], trigger_key, who))

		# Weekend before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.SATURDAY] -
						datetime.timedelta(days=7),
					datetime.time(hour=12)),
				[event], trigger_key, who))

		# Before
		self.assertEqual(
			None,
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.FRIDAY] -
						datetime.timedelta(days=7),
					datetime.time(hour=12)),
				[event], trigger_key, who))

	def test_string_of_separate_all_day_events(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		events = []
		for day in self.days:
			events.append(self._createEvent(
				'%s %s' % (who, trigger_key),
				day, day + datetime.timedelta(days=1)
			))

		# Midnight before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.MONDAY],
					datetime.time()),
				events, trigger_key, who))

		# During
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				self.days[calendar.WEDNESDAY],
				events, trigger_key, who))

		# Weekend before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.SATURDAY] -
						datetime.timedelta(days=7),
					datetime.time()),
				events, trigger_key, who))

		# Before
		self.assertEqual(
			None,
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.FRIDAY] -
						datetime.timedelta(days=7),
					datetime.time()),
				events, trigger_key, who))

	def test_string_of_separate_workday_events(self):
		trigger_key = 'vacation'
		who = 'am-i.testcase'
		events = []
		for day in self.days:
			events.append(self._createEvent(
				'%s %s' % (who, trigger_key),
				datetime.datetime.combine(day,
					TRIGGERS[trigger_key]['day_start']),
				datetime.datetime.combine(day,
					TRIGGERS[trigger_key]['day_end']),
			))

		# Midnight before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.MONDAY],
					datetime.time()),
				events, trigger_key, who))

		# During
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				self.days[calendar.WEDNESDAY],
				events, trigger_key, who))

		# Weekend before
		self.assertEqual(
			datetime.datetime.combine(
				self.days[calendar.MONDAY] +
					datetime.timedelta(days=7),
				TRIGGERS[trigger_key]['day_start']),
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.SATURDAY] -
						datetime.timedelta(days=7),
					datetime.time()),
				events, trigger_key, who))

		# Before
		self.assertEqual(
			None,
			expected_return(
				datetime.datetime.combine(
					self.days[calendar.FRIDAY] -
						datetime.timedelta(days=7),
					datetime.time()),
				events, trigger_key, who))

if __name__ == '__main__':
	parser = optparse.OptionParser(
		usage='%%prog [-d] [-m] [-t] %s USERNAME -- [SUBPROGRAM [ARG...]]' %
			'|'.join(sorted(TRIGGERS)),
		version='%prog $Id$')
	parser.add_option('-d', '--date-format', dest='date_format',
		default='%A, %d %B',
		metavar='FORMAT',
		help='specify a strftime() format to use for the return date')
	parser.add_option('-m', '--message', dest='msg_template',
		default=os.path.expanduser(os.path.join('~', '.vacation.msg')),
		metavar='FILE',
		help='message template to pass as the subprogram\'s @MSG_FILE@ argument')
	parser.add_option('-t', '--test', dest='test', default=False,
		action='store_true',
		help='run the test suite')

	(options, args) = parser.parse_args()

	if options.test:
		# unittest.main() parses arguments too, so pass
		# along any non-option arguments to it.
		sys.argv[1:] = args

		unittest.main()
		sys.exit(0)

	if len(args) < 2:
		parser.print_usage()
		sys.exit(EX_USAGE)

	if args[0] not in TRIGGERS:
		print('Action must be one of: %s' % \
			', '.join(sorted(TRIGGERS)))
		parser.print_usage()
		sys.exit(EX_USAGE)

	try:
		sys.exit(main(args[0], args[1], args[2:], options))
	except Exception as e:
		sys.stderr.write(str(e))
		sys.stderr.write('\n')
		traceback.print_exc(file=sys.stderr)
		sys.exit(EX_TEMPFAIL)
