Created
November 1, 2012 16:36
-
-
Save chadwhitacre/3994887 to your computer and use it in GitHub Desktop.
Time tracking script.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
"""This script prints time tracking reports to stdout. | |
The program takes its configuration from ~/.ttrc, which is in INI format | |
with one section and two keys: | |
[main] | |
timesheet=~/.timesheet | |
timezone=US/Eastern | |
It needs pytz: | |
http://pytz.sourceforge.net/ | |
The timesheet format that timetough is designed to work with is as follows: | |
0 First Personal Thing | |
1 Second Personal Thing | |
3 Third Personal Thing | |
a A Work Thing | |
--==START==-- | |
03/17/2009 | |
09:33 0 12:00 comments go here | |
12:00 a 13:30 | |
--==END==-- | |
03/18/2009 | |
08:30 0 11:13 | |
11:13 3 11:58 | |
13:00 3 15:33 | |
Only the lines between the START and END markers are processed. Allowable lines | |
between these markers are of three types: | |
o blank lines | |
o dates in MM/DD/YYYY format | |
o entries in <start-time> <category> <end-time> format; 24 hour clock, | |
and must be preceded by a date line; anything after <end-time> is ignored | |
TODO: | |
- test! | |
- document! | |
===done=== | |
- account for timezone | |
- account for overlapping entries | |
- figure out minute boundary overlaps for sure (ok in FB ui?) | |
- parse overlaps on client rather than hitting server N times | |
The name comes from "Time Tough", a song by Toots and the Maytals: | |
http://www.youtube.com/watch?v=TXxOU49TVKA | |
http://songza.com/z/mgj2hg (RIP, Songza) | |
Legal (MIT License; http://www.opensource.org/licenses/mit-license.php): | |
Copyright (c) 2009 Zeta Design & Development, LLC | |
Permission is hereby granted, free of charge, to any person obtaining a | |
copy of this software and associated documentation files (the "Software"), | |
to deal in the Software without restriction, including without limitation | |
the rights to use, copy, modify, merge, publish, distribute, sublicense, | |
and/or sell copies of the Software, and to permit persons to whom the | |
Software is furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in | |
all copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER | |
DEALINGS IN THE SOFTWARE. | |
""" | |
import ConfigParser | |
import datetime | |
import os | |
import re | |
import stat | |
import sys | |
from collections import defaultdict | |
CONF = '~/.ttrc' | |
START = '--==START==--' | |
END = '--==END==--' | |
timezone = None | |
# Helpers | |
# ======= | |
def die(msg): | |
print >> sys.stderr, msg | |
raise SystemExit(1) | |
# Import pytz | |
# =========== | |
# This is here so we have die(). | |
try: | |
import pytz | |
except ImportError: | |
die("This program depends on pytz: http://pytz.sourceforge.net/") | |
# The Thing Itself | |
# ================ | |
utc = pytz.timezone('UTC') | |
class Interval(object): | |
def __init__(self, category, start, end, date=None): | |
"""Takes three strings and a datetime.datetime object. | |
Or take an int and two datetime objects. | |
""" | |
self.category = category | |
if isinstance(start, basestring): | |
parse_time = lambda t: [int(x) for x in t.split(':')] | |
def make_time(h, m): | |
dt = datetime.datetime(date.year, date.month, date.day, h, m) | |
dt = timezone.localize(dt) | |
#dt = dt.astimezone(utc) | |
return dt | |
start_h, start_m = parse_time(start) | |
end_h, end_m = parse_time(end) | |
start = make_time(start_h, start_m) | |
if end_h < start_h: # somebody's working late ;^) | |
date += datetime.timedelta(days=1) | |
end = make_time(end_h, end_m) | |
self.start = start | |
self.end = end | |
def __cmp__(self, other): | |
return cmp(self.start, other.start) | |
def __repr__(self): | |
return "<%2.2f hrs on %d>" % (self.to_hours(), self.category) | |
def to_hours(self): | |
return (self.end - self.start).seconds / 60.0 / 60.0 | |
def process(timesheet): | |
"""Given a timesheet path, return four data structures. | |
We want to be careful not to duplicate intervals. So first we batch all | |
intervals by day, and ask the user for confirmation if they already have | |
any intervals posted for that day. Then check each interval for an interval | |
matching the exact start and end times, and skip-and-warn those. | |
""" | |
# Read in the timesheet. | |
# ====================== | |
timesheet = open(timesheet) | |
# read in categories | |
personal = {} | |
work = {} | |
for line in timesheet: | |
line = line.strip() | |
if not line: | |
break | |
k, v = line.split(None, 1) | |
categories = personal if k.isdigit() else work | |
categories[k] = [v, 0] | |
# set up month data structure | |
by_month = defaultdict(float) | |
# set up week data structure | |
def populate_week_with_categories(): | |
by_category = {} | |
for code, (name, hours) in personal.items(): by_category[code] = 0.0 | |
for code, (name, hours) in work.items(): by_category[code] = 0.0 | |
return [0.0, by_category] | |
by_week = defaultdict(populate_week_with_categories) | |
# skip down to start marker | |
for line in timesheet: | |
line = line.strip() | |
if line == START: | |
break | |
date = earliest = latest = None | |
date_pattern = re.compile(r'^\d\d\d\d-\d\d-\d\d$') | |
for line in timesheet: | |
line = line.strip() | |
if not line: continue | |
if line == END: break | |
# 2012-02-29 -- date line | |
m = date_pattern.match(line) | |
if m is not None: | |
year, month, day = [int(x) for x in m.group(0).split('-')] | |
date = datetime.date(year, month, day) | |
sunday = date - datetime.timedelta(date.weekday() + 1) | |
continue | |
# 09:00 0 17:00 -- interval line | |
if date is None: | |
die("no date set") | |
start, category_id, end = line.split(None, 2) | |
if ' ' in end: | |
end, comment = end.split(None, 1) | |
interval = Interval(category_id, start, end, date) | |
hours = interval.to_hours() | |
if (earliest is None) or (interval.start < earliest): | |
earliest = interval.start | |
if (latest is None) or (interval.end > latest): | |
latest = interval.end | |
categories = personal if interval.category.isdigit() else work | |
categories[interval.category][1] += hours | |
by_month[(year, month)] += hours | |
by_week[sunday][0] += hours | |
by_week[sunday][1][interval.category] += hours | |
return personal, work, earliest, latest, by_month, by_week | |
def main(codes): | |
global timezone | |
# Find a secure conf file | |
# ======================= | |
conf_path = os.path.expanduser(CONF) | |
if not os.path.isfile(conf_path): | |
die("you need a configuration file at %s" % CONF) | |
perms = stat.S_IMODE(os.stat(conf_path)[stat.ST_MODE]) | |
if (perms ^ 0600) > 0: | |
die("permissions on %s must be 0600, not 0%o" % (CONF, perms)) | |
# Load configuration | |
# ================== | |
conf = ConfigParser.RawConfigParser() | |
conf.read([conf_path]) | |
timesheet = os.path.expanduser(conf.get('main', 'timesheet')) | |
try: | |
_timezone = conf.get('main', 'timezone') | |
timezone = pytz.timezone(_timezone) | |
except pytz.UnknownTimeZoneError: | |
die("unknown timezone: %s" % _timezone) | |
# Process timesheet | |
# ================= | |
personal, work, earliest, latest, by_month, by_week = process(timesheet) | |
# Print report | |
# ============ | |
buf = [''] | |
def w(s): | |
if buf[-1].endswith(os.linesep): | |
buf.append(s) | |
else: | |
buf[-1] += s | |
def br(): | |
if buf[-1].endswith(os.linesep): | |
buf.append(os.linesep) | |
else: | |
buf[-1] += os.linesep | |
def display(): | |
width = max([len(line) for line in buf]) - 1 # don't include newline | |
for line in buf: | |
line = line.rstrip(os.linesep).center(width) + os.linesep | |
sys.stdout.write(line) | |
key = lambda item: item[1][1] # total hours | |
category_map = {} | |
category_map.update(personal) | |
category_map.update(work) | |
categories = sorted(category_map.items(), key=key, reverse=True) | |
grand_total = 0.0 | |
br() | |
br() | |
w("Hours by Category and Week".center(160)) | |
br() | |
br() | |
w(" " * 19) | |
for code, (name, total) in categories: | |
width = max(len(name) + 2, 10) | |
category_map[code].append(width) | |
if (not codes and total == 0) or (codes and code not in codes): | |
continue | |
assert name != '-' | |
w(name.center(width)) | |
br() | |
w(" " * 19) | |
for code, (name, total, width) in categories: | |
if (not codes and total == 0) or (codes and code not in codes): | |
continue | |
assert name != '-' | |
w(code.center(width)) | |
br() | |
br() | |
for sunday, (total, by_category) in sorted(by_week.items(), reverse=True): | |
if codes: | |
total = sum([h for c, h in by_category.items() if c in codes]) | |
w("%s %7.02f" % (sunday, total)) | |
for code, (name, total, width) in categories: | |
if (not codes and total == 0) or (codes and code not in codes): | |
continue | |
assert name != '-' | |
hours = round(by_category[code], 2) | |
grand_total += hours | |
cell = ("%6.02f" % hours) if hours > 0 else " - " | |
w(cell.center(width)) | |
br() | |
br() | |
w(" " * 12) | |
w("%7.02f" % grand_total) | |
for code, (name, total, width) in categories: | |
if (not codes and total == 0) or (codes and code not in codes): | |
continue | |
assert name != '-' | |
w(("%7.02f" % total).center(width)) | |
br() | |
display() | |
if __name__ == '__main__': | |
codes = sys.argv[1:] | |
main(codes) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment