461 lines
14 KiB
Python
Executable file
461 lines
14 KiB
Python
Executable file
#!/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 ()
|