#!/usr/bin/env python # timeclock, a time-keeping program based on the Ledger library # # Copyright (c) 2003-2004, New Artisans LLC. All rights reserved. # # This program is made available under the terms of the BSD Public # License. See the LICENSE file included with the distribution for # details and disclaimer. # # This program implements a simple timeclock, using the identical # format as my timeclock.el module for Emacs (which is part of the # Emacs 21 distribution). This allows you to use either this script # or that module for creating time events. # # Usage is very simple: Set the environment variable TIMELOG to the # path to your timelog file (if this variable is unset, any events # created will simply be printed to stdout). Once this variable is # set: # # timeclock in "project" what aspect of the project will I do today # timeclock out what did I accomplish # # The description text is optional, but the project is required when # clocking in. This project should be a account name, which means it # can use ":" to separate the project from the task, for example: # # timeclock in Client:Meetings at the code review meeting # # To generate a balance report of time spent, use "timeclock" with no # arguments, or "timeclock balance". The options available are the # same as those used for ledger. import os import sys import string import time true, false = 1, 0 from ledger import * home = os.getenv ("HOME") config.init_file = home + "/.timeclockrc"; config.cache_file = home + "/.timeclock-cache"; # Define some functions for reporting time quantities workday = 8 * 60 * 60 # length of a nominal workday def secstr (secs): return "%d:%02d" % (abs (secs) / 60 / 60, (abs (secs) / 60) % 60) def daystr (amt): dy = int (amt) / int (workday) amt = amt - (dy * workday) if dy >= 5: wk = dy / 5 dy = dy % 5 if dy: amt = "%sw %sd %s" % (wk, dy, secstr (amt)) else: amt = "%sw %s" % (wk, secstr (amt)) else: if dy: amt = "%sd %s" % (dy, secstr (amt)) else: amt = secstr (amt) return amt def adaystr (details): result = "" for amt in account_xdata(details.account).total: if amt.commodity ().symbol == "s": result = daystr (float (amt)) break return result config.amount_expr = "{1.00h}*(a/{3600.00h})" config.total_expr = "{1.00h}*(O/{3600.00h})" config.balance_format = "%20@adaystr() %8T %2_%-a\n"; # Help text specific to timeclock def show_version (arg): print """Timeclock, a command-line timekeeping tool Copyright (c) 2003-2004, New Artisans LLC. All rights reserved. This program is made available under the terms of the BSD Public License. See the LICENSE file included with the distribution for details and disclaimer.""" sys.exit (0) def option_help (arg): print """usage: timeclock [options] COMMAND [ACCT REGEX]... Basic options: -h, --help display this help text -v, --version show version information -i, --init FILE initialize ledger by loading FILE (def: ~/.ledgerrc) --cache FILE use FILE as a binary cache when --file is not used -f, --file FILE read ledger data from FILE -o, --output FILE write output to FILE Report filtering: -b, --begin DATE set report begin date -e, --end DATE set report end date -c, --current show only current and past entries (not future) -C, --cleared consider only cleared transactions -U, --uncleared consider only uncleared transactions -R, --real consider only real (non-virtual) transactions -Z, --actual consider only actual (non-automated) transactions -r, --related calculate report using related transactions Output customization: -F, --format STR use STR as the format; for each report type, use: --balance-format --register-format --plot-amount-format --plot-total-format -y, --date-format STR use STR as the date format (def: %Y/%m/%d) --wide for the default register report, use 132 columns -E, --empty balance: show accounts with zero balance -n, --collapse register: collapse entries with multiple transactions -s, --subtotal balance: show sub-accounts; register: show subtotals -S, --sort EXPR sort report according to the value expression EXPR -p, --period STR report using the given period --period-sort EXPR sort each report period's entries by EXPR --dow show a days-of-the-week report -W, --weekly show weekly sub-totals -M, --monthly show monthly sub-totals -Y, --yearly show yearly sub-totals -l, --limit EXPR calculate only transactions matching EXPR -d, --display EXPR display only transactions matching EXPR -t, --amount EXPR use EXPR to calculate the displayed amount -T, --total EXPR use EXPR to calculate the displayed total -j, --amount-data print only raw amount data (useful for scripting) -J, --total-data print only raw total data Commodity reporting: -A, --average report average transaction amount -D, --deviation report deviation from the average Commands: balance [REGEXP]... show balance totals for matching accounts register [REGEXP]... show register of matching events""" sys.exit (0) # This call registers all of the default command-line options that # Ledger supports into the option handling mechanism. Skip this call # if you wish to do all of your own processing -- in which case simply # modify the 'config' object however you like. add_config_option_handlers () add_option_handler ("help", "h", option_help) add_option_handler ("version", "v", show_version) # Process the command-line arguments, test whether caching should be # enabled, and then process any option settings from the execution # environment. Some historical environment variable names are also # supported. args = process_arguments (sys.argv[1:]) config.use_cache = not config.data_file process_environment (os.environ, "TIMECLOCK_") if os.environ.has_key ("TIMELOG"): process_option ("file", os.getenv ("TIMELOG")) # The command word is in the first argument. Canonicalize it to a # unique, simple form that the remaining code can use to find out # which command was specified. if len (args) == 0: args = ["balance"] command = args.pop (0); if command == "balance" or command == "bal" or command == "b": command = "b" elif command == "register" or command == "reg" or command == "r": command = "r" elif command == "entry": command = "e" elif command == "in" or command == "out": if config.data_file: log = open (config.data_file, "a") else: log = sys.stdout if command == "in": if len (args) == 0: print "A project name is required when clocking in." sys.exit (1) log.write ("i %s %s" % (time.strftime ("%Y/%m/%d %H:%M:%S"), args.pop (0))) if len (args) > 0: log.write (" %s\n" % string.join (args, " ")) else: log.write ("o %s" % time.strftime ("%Y/%m/%d %H:%M:%S")) if len (args) > 0: log.write (" %s" % string.join (args, " ")) log.write ("\n") log.close () sys.exit (0) else: print "Unrecognized command:", command sys.exit (1) # Create the main journal object, into which all entries will be # recorded. Once done, the 'journal' may be iterated to yield those # entries, in the same order as which they appeared in the journal # file. journal = Journal () # This parser is intended only for timelog files. class Event: def __init__(self, kind, when, desc): self.kind = kind self.when = when self.desc = desc class Interval: def __init__(self, begin, end): self.begin = begin self.end = end def length(self): "Return the length of the interval in seconds." return self.end.when - self.begin.when def parse_timelog(path, journal): import re if not os.path.exists (path): print "Cannot read timelog file '%s'" % path sys.exit (1) file = open(path) history = [] begin = None linenum = 0 for line in file: linenum += 1 match = re.match("([iIoO])\s+([0-9/]+\s+[0-9:]+)\s*(.+)", line) if match: (kind, when, desc) = match.groups() when = time.strptime(when, "%Y/%m/%d %H:%M:%S") when = time.mktime(when) event = Event(kind, when, desc) if kind == "i" or kind == "I": begin = event else: if begin.desc: match = re.match ("(.+?) (.+)", begin.desc) if match: acct = match.group (1) desc = match.group (2) else: acct = begin.desc desc = "" else: acct = "Misc" desc = event.desc l = Interval(begin, event).length () e = Entry () e.date = int (begin.when) e.payee = desc x = Transaction (journal.find_account (acct), Amount ("%ss" % l), TRANSACTION_VIRTUAL) e.add_transaction (x) if not journal.add_entry (e): print "%s, %d: Failed to entry" % (path, linenum) sys.exit (1) parse_timelog (config.data_file, journal) # Now that everything has been correctly parsed (parse_ledger_data # would have thrown an exception if not), we can take time to further # process the configuration options. This changes the configuration a # bit based on previous option settings, the command word, and the # remaining arguments. if command == "b" and \ config.amount_expr == "{1.00h}*(a/{3600.00h})": config.amount_expr = "a" config.process_options (command, args); # Determine the format string to used, based on the command. if config.format_string: format = config.format_string elif command == "b": format = config.balance_format elif command == "r": format = config.register_format else: format = config.print_format # The following two classes are responsible for outputing transactions # and accounts to the user. There are corresponding C++ versions to # these, but they rely on I/O streams, which Boost.Python does not # provide a conversion layer for. class FormatTransactions (TransactionHandler): last_entry = None output = None def __init__ (self, fmt): try: i = string.index (fmt, '%/') self.formatter = Format (fmt[: i]) self.nformatter = Format (fmt[i + 2 :]) except ValueError: self.formatter = Format (fmt) self.nformatter = None self.last_entry = None if config.output_file: self.output = open (config.output_file, "w") else: self.output = sys.stdout TransactionHandler.__init__ (self) def __del__ (self): if config.output_file: self.output.close () def flush (self): self.output.flush () def __call__ (self, xact): if not transaction_has_xdata (xact) or \ not transaction_xdata (xact).dflags & TRANSACTION_DISPLAYED: if self.nformatter is not None and \ self.last_entry is not None and \ xact.entry == self.last_entry: self.output.write (self.nformatter.format (xact)) else: self.output.write (self.formatter.format (xact)) self.last_entry = xact.entry transaction_xdata (xact).dflags |= TRANSACTION_DISPLAYED class FormatAccounts (AccountHandler): output = None def __init__ (self, fmt, pred): self.formatter = Format (fmt) self.predicate = AccountPredicate (pred) if config.output_file: self.output = open (config.output_file, "w") else: self.output = sys.stdout AccountHandler.__init__ (self) def __del__ (self): if config.output_file: self.output.close () def final (self, account): if account_has_xdata (account): xdata = account_xdata (account) if xdata.dflags & ACCOUNT_TO_DISPLAY: print "-------------------- ---------" xdata.value = xdata.total self.output.write (self.formatter.format (account)) def flush (self): self.output.flush () def __call__ (self, account): if display_account (account, self.predicate): if not account.parent: account_xdata (account).dflags |= ACCOUNT_TO_DISPLAY else: self.output.write (self.formatter.format (account)) account_xdata (account).dflags |= ACCOUNT_DISPLAYED # Set the final transaction handler: for balances and equity reports, # it will simply add the value of the transaction to the account's # xdata, which is used a bit later to report those totals. For all # other reports, the transaction data is sent to the configured output # location (default is sys.stdout). if command == "b": handler = SetAccountValue () else: handler = FormatTransactions (format) # Chain transaction filters on top of the base handler. Most of these # filters customize the output for reporting. None of this is done # for balance or equity reports, which don't need it. if command != "b": if config.display_predicate: handler = FilterTransactions (handler, config.display_predicate) handler = CalcTransactions (handler) if config.sort_string: handler = SortTransactions (handler, config.sort_string) if config.show_revalued: handler = ChangedValueTransactions (handler, config.show_revalued_only) if config.show_collapsed: handler = CollapseTransactions (handler); if config.show_subtotal and not (command == "b" or command == "E"): handler = SubtotalTransactions (handler) if config.days_of_the_week: handler = DowTransactions (handler) elif config.by_payee: handler = ByPayeeTransactions (handler) if config.report_period: handler = IntervalTransactions (handler, config.report_period, config.report_period_sort) handler = SortTransactions (handler, "d") # The next two transaction filters are used by all reports. if config.show_inverted: handler = InvertTransactions (handler) if config.show_related: handler = RelatedTransactions (handler, config.show_all_related) if config.predicate: handler = FilterTransactions (handler, config.predicate) if config.comm_as_payee: handler = SetCommAsPayee (handler) # Walk the journal's entries, and pass each entry's transaction to the # handler chain established above. walk_entries (journal, handler) # Flush the handlers, causing them to output whatever data is still # pending. handler.flush () # For the balance and equity reports, the account totals now need to # be displayed. This is different from outputting transactions, in # that we are now outputting account totals to display a summary of # the transactions that were just walked. if command == "b": acct_formatter = FormatAccounts (format, config.display_predicate) sum_accounts (journal.master) walk_accounts (journal.master, acct_formatter, config.sort_string) acct_formatter.final (journal.master) acct_formatter.flush ()