#!/usr/local/bin/python
# hb.py - A Home Budget Management Program
# Copyright (c) 2001, 2002 TundraWare Inc.  All Rights Reserved.


# If you don't already, learn how to use RCS for version control.
# It can save your bacon if you fumble your keyboard during editing.
# This is a nice way to embedd version information in your
# program.  In the BANNER definition  below, you'll see  VERSION
# getting split, so I can pick up just the version number.

VERSION = "$Id: hb.py 1.88 2002/09/04 23:32:43 tundra Exp $"



############################################################
###          Variables User Might Change                 ###
############################################################


####################
# Default budget file in "Name Burn Bal" format.  At a minimum,
# this file must have a CASH account.  The budget must also have
# a BALACCT account or the Balance feature will do nothing.
####################

from os import getenv

BASEDIR  = getenv("HOME") + "/"
BUDFIL   = "hb.txt"  

BALACCT = "Savings"            # Account used to Balance Budgets
CASH = "Cash"                  # Name of cash account

####################
# Default printer
####################

LPTW = "lpt1:"                    # Default print device for Win32
LPTX = BASEDIR + "HB-Report.txt"  # Default print file for non-Win32

####################
# Screen Dimensions
####################

CWIDTH   = 12                 # Column width
OHEIGHT  = 25                 # Output height
OWIDTH   = 79                 # Output width


#------------------- Nothing Below Here Should Need Changing ------------------#

############################################################
###                     Imports                          ###
############################################################

# If you use 'import libname', then every feature of that
# library has to be referenced in its namespace as in
# 'os.path.exists("file")'.  OTOH, if you import a single
# part of a library like 'from string import atof', that
# feature is in your namespace and you can refer to it
# directly as in 'atof(x)' - not 'string.atof(x)'.

import getopt
import os
import re
from string import atof
from time import ctime
import sys


############################################################
###               Aliases & Redefinitions                ###
############################################################

# I don't like verbs like Python's 'print' that stick
# spaces into my output without my permission.  There is
# a way to suppress that with 'print', but I wanted to
# demonstrate how object name assignment can help you alias
# things for clarity and simplicity's sake.  This is also
# a way to be able to reference something in your namespace
# that is actually in another (its library's) namespace.

tprint = sys.stdout.write


############################################################
###              Constants & Literals                    ###
############################################################

####################
# Constants
####################

FALSE = 0 == 1                # Booleans
TRUE = not FALSE

APL = OWIDTH/(CWIDTH * 2)     # Accounts per line to display


####################
# Literals
####################

BKUEXT  = "~"                 # Extension string for backup file    
CLSUNIX = "clear"             # Clear screen on unix
CLSWIN  = "cls"               # Clear screen on windows

OSWIN32 = 'nt'                # Operating system name - Win32
OSUNIX  = 'posix'             # Operating system name - Unix 

SEP = '-' * 79                # Separator bar

# Regular Expression used to validate legitimate currency formats.
# Will not allow negative values or leading zeros.  If a decimal
# point is present, requires two digits following it.

ISCURRENCY = r"^(([1-9]\d*(\.\d{2})?)|(\.\d{2}))$"


############################################################
###            Prompts, & Application Strings            ###
###                                                      ###
### These are separated out both for ease of maintenance ###
### as well as making it easy to later change if we want ###
###              to Internationalize the code.           ###
###                                                      ###
###   i.e., By "destringing" the code, there are no      ###
###   literals embedded in the logic.  Any changes to    ###
###    prompts (for content, language...) can be done    ###
###     here without touching the code proper itself.    ###
############################################################


####################
# Display Strings
####################

BANNER = ("HomeBudget - Version " + VERSION.split()[2] + " - " + \
          ctime()).center(OWIDTH) + "\n" + \
          "Copyright (c) 2001, 2002 TundraWare Inc.  All Rights Reserved.".center(OWIDTH) +\
          "\n"

ACASH =      "Available Cash: "
TCASH =      "Total Cash: "
TDEBIT =     "Total Debits: "


##############################
# Common Prompt & Delimiter Strings
##############################

DELIMITL = "<"                             # Delimiters for emphasized text
DELIMITR = ">"
PROMPT   = "? "                            # Command prompt
TRAILER  = "...\n"                         # Ending for informational messages


####################
# Error Messages
####################

eCONTINUE  = "Press Enter To Continue."
eNOCASH    = "Cannot Continue! - No Cash Account Defined."
eNOLOAD    = "Cannot Load Budget: %s%s!"
eNOUNDO    = "Nothing To Undo!"
eUNKNOWN   = "Unknown Error Occurred."


####################
# Informational Messages
####################

iBALANCING = "Balancing" + TRAILER
iBUDGET    = "Budget: "
iCREDIT    = "Credit"
iDEBIT     = "Debit"
iEXITING   = "Exiting" + TRAILER 
iLOADING   = "Loading Budget From %s%s" + TRAILER
iMONTHLY   = "Doing Monthly Account Update" + TRAILER
iPAY       = "Pay"
iPRINTING  = "Printing To %s" + TRAILER
iSAVING    = "Saving Budget" + TRAILER
iTRANSFER  = "Transfer"
iUNDO      = "UnDoing Last Transaction" + TRAILER
iUSAGE     = "hb " + VERSION.split()[2] + \
             "  Copyright (c), 2001, 2002 TundraWare Inc." + \
             "  All Rights Reserved.\n" + \
             "  usage:\n     hb [-d budget-directory] [-f budget-file]\n"
iWHOLEAMT  = "(Enter For Entire Account Balance)"


####################
# Interactive User Prompts
####################

pACCOUNT   = "Account to %s" + PROMPT
pAMOUNT    = "Amount to %s" + PROMPT
pBALANCING = "Balancing Budget" + TRAILER
pCOMMAND   = "Command" + PROMPT
pLOADFROM  = "Load New Budget From " + DELIMITL + "%s" + DELIMITR 
pPRINTTO   = "Print To " + DELIMITL + "%s" + DELIMITR
pTXFROM    = iTRANSFER + " From"
pTXTO      = iTRANSFER + " To"


############################################################
###                Command Options                       ###
###                                                      ###
### This is a tuple of tuples containing all the options ###
### to be presented to the user.  At program startup,    ###
### this is read and a Jump Table is created for the     ###
### Command Interpreter to use.  Each option is          ###
### described with a tuple in the following format:      ###
###                                                      ###
###  (  ("&ItemName", "Name of Main Handler Function"),  ###
###     (("Func to get 1st argument" ", 1st prompt"),    ###
###      ...                                             ###
###      ("Func to get last argument", " last prompt")   ###
###     )                                                ###
###  )                                                   ###
###                                                      ###
###  The letter following the "&" is understood to be    ###
###  the hotkey by which the user selects that feature,  ###
###  so make sure they are all unique.                   ###
###                                                      ###
###   At runtime, the Command Interpreter will first     ###
###   look for a command name (hotkey) it recognizes.    ###
###   Then, it will serially execute each argument-      ###
###   getting function, passing the corresponding        ###
###   prompt when doing so.  When all the arguments      ###
###   have been retrieved, the Command Interpreter       ###
###   ships them off to the Main Handler Function        ###
###   in the form of a list.                             ###
###                                                      ###
###   Each time the a Main Handler Function is called,   ###
###   a record of that call and it's parameters is       ###
###   stored on the Undo stack so that the user has      ###
###   unlimited levels of undo.  It is the job           ###
###   of each Main Handler Function to implement its     ###
###   own correct Undo logic which is indicated by       ###
###   a flag setting in the invocation.                  ###
###   At the very least, a Main Handler should be        ###
###   able to ignore an Undo, otherwise the function     ###
###   will be executed when it is called AND when        ###
###   it is Undone - usually not what you want.          ###
###                                                      ###
###  This approach makes is very easy to add new         ###
###  features or change the prompt string (into, say,    ###
###  another language) or assign a different hot key     ###
###  for a given feature.                                ###
###                                                      ###
###  If you change this table, do a quick check and      ###
###  see if any of the constant(s) in the next section   ###
###  also need updating.                                 ###
###                                                      ###
############################################################


Options  = (
            (("&Balance", "Balance"),
             (("BalAmount", ""),)       
            ),                          

            # Explanation of an entry:
            # In the entry above, the feature is named "Balance".
            # It's user hotkey will be 'B'.
            # The Main Handler Function that supports it is
            # Balance() -  At runtime, the Jump Table loader
            # does an eval("Balance") and thereby loads the
            # Jump Table with the correct object corresponding
            # to Balance().
            # Balance() only needs one argument when it is called
            # and this is computed by BalAmount() which needs no
            # prompt string.  Notice that there is a trailing
            # comma in this tuple.  Without it, we would have
            #              (("BalAmount", ""))
            # and Python would not be able to tell that this is
            # supposed to be a tuple of tuples.  The trailing
            # comma tells Python to arrange the data structure
            # as such.  In the entries below with multiple
            # argument handlers, this is not, strictly speaking,
            # required, but I put them in for consitency of style.

                                        
            (("&Credit", "Credit"),
             (("GetAccount", iCREDIT), ("GetAmount", iCREDIT),)
            ),
                               
            (("&Debit", "Debit"),
             (("GetAccount", iDEBIT), ("GetDebitAmount", iDEBIT),)
            ),

            (("&Monthly", "Monthly"), ()),

            (("P&rint", "Print"),
             (("GetPrintDevice", pPRINTTO),)
             ),

            (("&Load", "Load"),
             (("GetBudgetFile", pLOADFROM),)
             ),

            (("&Save", "Save"), ()),

            (("&Transfer", "Transfer"),
             (("GetAccount", pTXFROM), ("GetAccount", pTXTO,),
              ("GetAmount", iTRANSFER ))
            ),

            (("Save+&Quit", "SaveExit"), ()),

            (("&Pay", "Pay"),
             (("GetAccount", iPAY), ("GetPayAmount", iPAY),)
            ),

            (("&Undo", "Undo"), ()),

            (("&!Abandon", "Exit"), ())
           )


####################
# Features That Have Dependencies Or Special Behavior
#
# The constant(s) below, are the hotkeys for program features
# that have special dependencies in order to be enabled.
# They *must* be identical to the hotkey selected for the
# option in the table above (the character preceded by '&').
# If the table above is changed, make sure to reflect any
# relevant changes here as well.  While this is a little
# clumsy, it is nessary in order to keep from having to embed
# literal strings in the code itself.
#
# BuiltJT() - the routine that builds the Jump Table for the
# Command Interpreter - needs this information to determine whether
# these features should be enabled or disabled for a given
# budget.  The information is kept here in this manner to keep
# code below destringed.
####################

sBALANCE = "B"

############################################################
###        Global Variables & Data Structures            ###
############################################################

# Global Variables

ALLDONE    = FALSE            # Controls exit from main execution loop
DEBIT      = 0                # Running debit total
INPUTABORT = FALSE            # Input aborted by user
OSNAME     = ""               # Name of the operating system we're using

# Global Data Structures

Budget        = {}            # Dictionary of all account objects
LoadStack     = []            # Place we preserve old budgets when loading new
AccountNames  = []            # Account names discovered at load time
JumpTable     = {}            # Command Interpreter dispatch
UndoStack     = []            # The Undo stack


#---------------------------Code Begins Here----------------------------------#

############################################################
#                     General Note                         #
#                                                          #
#   Python has no special block delimiters like {} in 'C'  #
#   or DO-END in other languages.  It uses *INDENTATION*   #
#   to indicate block scope.  Personally, I like this a    #
#   lot - it's much harder to write ugly code in Python    #
#   and you don't waste all those keystrokes ;)  Just      #
#   pay plenty of attention to indentation or you will     #
#   get strange results.                                   #
############################################################


############################################################
###           Object Base Class Definitions              ###
############################################################

####################
# Account Base Class           
####################

# Stuff declared in  __init__() is local to a given instance
# of a class.  Stuff declared elsewhere in the class
# is global to all instances of that class.  This can bite
# you if you have variables declared outside of __init__()
# because one instance of a class will be changing a
# variable and all other instances will see the change
# as well.  This can be very painful to debug if you
# do this unintentionally. (DAMHIKT ;)

class Account:
    def __init__(self):
        self.Burn  =  0           # Periodic burn rate
        self.Bal   =  0           # Current balance

    def Credit(self, amt):        # Credit the account
        self.Bal += amt

    def Debit (self, amt):        # Debit the account
        self.Bal -= amt

    def Monthly(self, Undo):      # Monthly update of account
        if Undo:                  # Support Undoing
            self.Bal -= self.Burn
        else:
            self.Bal += self.Burn

####################
# Jump Table Entry Base Class
####################

class JumpEntry:
    def __init__(self):
        self.Handler = None   # Function handling this action
        self.Name    = ""     # Name of action
        self.Args    = []     # List of argument function-prompt pairs
    

############################################################
###               Main Handler Functions                 ###
############################################################

# There is one Main Handler Function for each feature
# the program presents to the user.

############################################################

# Balance debits against savings account (if present)
#
# Excess available cash is put into BALACCT.  If there
# is a cash deficit, BALACCT is reduced accordingly to
# eliminate it.
#
# Notice the two forms of the arguments in this function
# protype.  Args has no assigned value so it is *required*
# to be passed at call time.  Undo is not required in the call
# and defaults to FALSE if not passed by the caller.

def Balance(Args, Undo=FALSE):
    if Undo:
        Budget[BALACCT].Debit(Args[0])
    else:
        # Balance the budget
        tprint(pBALANCING)
        Budget[BALACCT].Credit(Args[0])

############################################################

# Add to an account

def Credit(Args, Undo=FALSE):
    if Undo:
        Budget[Args[0]].Debit(Args[1])
    else:
        Budget[Args[0]].Credit(Args[1])
                
############################################################

# Decrement an account.  A 0 amount passed into this function
# is understood to mean that the entire account balance should
# be debited.

def Debit(Args, Undo=FALSE):

    if Undo:
        Budget[Args[0]].Credit(Args[1])
    else:
        # Find out the amount being requested

        amt = Args[1]

        # If a 0 amount was passed it means we want to
        # pay off the whole account.
        # If amt is 0, it is logically FALSE

        if not amt:        
            amt = Budget[Args[0]].Bal     # What's the account balance?
            ir = UndoStack.pop()          # Fix the Invocation Record
            ir[1][1] = amt
            UndoStack.append(ir)
        Budget[Args[0]].Debit(amt)        # And do the math
        

############################################################

# Exit the program
#
# As a matter of clean coding style, I don't like to
# just exit from a called subroutine.  Instead, I change
# state in the variable ALLDONE and let the main control
# loop exit when control is passed back to it.
#
# Variables such as ALLDONE are globally *readable*
# when they are instantiated in a scope higher than
# the one in which it is referenced.  However, if you are
# going to *write* them, you have explicitly tell Python that
# the variable is global or it will create a new variable with
# local scope when you first write the value.  That's what
# 'global ALLDONE' does - it tells Python you want to
# modify the global copy of ALLDONE when it is set to
# TRUE at the end of the function, not create a new, local
# variable called ALLDONE.

def Exit(Args, Undo=FALSE):
    tprint(iEXITING)
    global ALLDONE
    ALLDONE = TRUE

############################################################

# Load budget from disk and initialize account objects   

def Load(Args, Undo=FALSE):
    global BASEDIR
    global AccountNames
    global Budget
    global JumpTable

    # A little Armor - Make sure BASEDIR ends with slash
    if BASEDIR[-1] != "/":
        BASEDIR = BASEDIR + "/"

    if Undo:
        # Reestablish the program state as it was before the last Load()
        AccountNames = LoadStack.pop()
        JumpTable    = LoadStack.pop()
        Budget       = LoadStack.pop()
    else:
        # If there is currently a budget, save for later undo
        # We also save the current command Jump Table because some
        # budgets having features others do not - the presence of
        # a BALACCT account, for instance, enables the Balance feature.
        # Finally, we have to save the AccountNames data structure,
        # because it is a globally used *sorted* list of available accounts.
        if Budget:
            LoadStack.append(Budget)
            LoadStack.append(JumpTable)
            LoadStack.append(AccountNames)

        # Initialize the global data structures for this budget
        Budget = {}
        AccountNames = {}
            
        # Open the new budget
        # The 'iLOADING % Args[0]' business is Python's version
        # of output formatting in the spirit of (and very similar to)
        # printf() and sprintf() you find in 'C'.
        
        try:
            tprint(iLOADING % (BASEDIR, Args[0]))
            f = open(BASEDIR + Args[0])
            accounts = f.read().splitlines()
            f.close()
        except IOError:
           Error(eNOLOAD % (BASEDIR, Args[0]), Fatal=TRUE)

        # Intitialize all account objects

        for BudgetItem in accounts:
            # Split the text line we read from disk
            # into a list with three strings: [Name, Burn, Bal]
            x = BudgetItem.split()

            # Make sure dictionary index is always capitalized
            index = x[0].capitalize()

            # Instantiate a new Account object in the budget dictionary
            Budget[index] = Account()
            Budget[index].Burn = float(x[1])   # Convert the numeric strings
            Budget[index].Bal  = float(x[2])   # to float and store

        # Build and sort list of available account names
        # So they'll display alphabetically
        AccountNames = Budget.keys()
        AccountNames.sort()

        # Every budget must have a cash account
        if not Budget.has_key(CASH):
            Error(eNOCASH, Fatal=TRUE)  # We cannot recover from this error.

        # Build the Command Interpreter Jump Table for this budget
        BuildJT()

############################################################

# Update each account by its monthly increment
#
# The second entry on each line of our budget file is the
# so-called monthly "Burn Rate" - It is the amount you have to
# budget every month for that account.

def Monthly(Args, Undo=FALSE):
    tprint(iMONTHLY)
    for x in AccountNames:
        Budget[x].Monthly(Undo)

############################################################

# Pay off some or all of an account.  This means the account
# is decremented by the desired amount and so it the CASH
# account.
#
# If Pay() is passed 0 as the amount, it understands
# this to mean paying off the entire amount in that account.
# In this special case, the Undo logic of Pay() has to make sure
# the amount paid off is properly preserved on the Undo stack for
# potential Undoing later. This is necessary, because GetPayAmount()
# sends a value of 0 if the user enters a blank line.
#
# When Pay() is actually dispatched out of the Jump Table, it's
# Invocation Record (which is pushed on the UnDo stack), will have
# 0 passed in the argument list. So, an Undo later, would do nothing 
# more than "UnPay" 0 Dollars (or Euros, or Yen, or Pesos...) into
# the appropriate account and CASH - this is not all that useful ;)
#
# So, in the event we get a 0 amount passed,  Pay() simply pops
# its own Invocation Record off the Undo stack, loads that
# record with the actual amount about to be paid, and pushes
# the record back onto the Undo stack.  This assures that a
# subsequent Undo can  "remember" how much to put back into the
# account and into the CASH accounts.
#
# To see another way to handle this kind of situation, see how
# the Balance() function is implemented without having to fiddle
# with the UnDo stack.

def Pay(Args, Undo=FALSE):

    # Find out the amount being requested
    amt = Args[1]

    if Undo:
        Budget[Args[0]].Credit(amt)
        if Args[0] != CASH:
            Budget[CASH].Credit(amt)
    else:
        # If a 0 amount was passed it means we want to
        # pay off the whole account.

        # If amt is 0, it is logically FALSE
        if not amt:        
            amt = Budget[Args[0]].Bal     # What's the account balance?
            ir = UndoStack.pop()          # Fix the Invocation Record
            ir[1][1] = amt
            UndoStack.append(ir)
        Budget[Args[0]].Debit(amt)        # And do the math
        if Args[0] != CASH:               # If we are "paying" from CASH,
            Budget[CASH].Debit(amt)       # don't debit the account twice

############################################################

# Print the budget to the file or device passed in Args

def Print(Args, Undo=FALSE):
    if not Undo:                    # Without this we'd print on do *and* undo
        tprint(iPRINTING % Args[0])
        f = open(Args[0], "w")
        for x in ReCalc():
            f.write(x)
        f.write("\f")
        f.close()

############################################################

# Save budget to disk

def Save(Args, Undo=FALSE):
    if not Undo:
        tprint(iSAVING)
        # All budget files are presumed to be relative to BASEDIR
        BUDGET = BASEDIR + BUDFIL
        BKU = BUDGET + BKUEXT

        if os.path.exists(BKU):
            os.remove(BKU)
        if os.path.exists(BUDGET):
            os.rename(BUDGET, BKU)
        f = open(BUDGET, "w")
        for x in AccountNames:
            Burn = str(Budget[x].Burn)
            Bal  = str(Budget[x].Bal)
            f.write(x + (CWIDTH - len(x)) * " " +
                    Burn + (CWIDTH - len(Burn)) * " " +
                    Bal  + (CWIDTH - len(Bal))  * " " +
                    "\n")
        f.close()

############################################################

# Save budget and exit program
#
# We just call the other Main Handler Function that do
# this for us directly (as opposed to having the Command
# Interpreter do it).  But, we have to send an Args argument
# along because that's how it's done when these functions
# are called via the Jump Table.

def SaveExit(Args, Undo=FALSE):
    Save(Args=[])
    Exit(Args=[])

############################################################

# Transfer funds between accounts

def Transfer(Args, Undo=FALSE):
    if Undo:
        Budget[Args[0]].Credit(Args[2])
        Budget[Args[1]].Debit(Args[2])
    else:
        Budget[Args[0]].Debit(Args[2])
        Budget[Args[1]].Credit(Args[2])


############################################################

# Undo the last transaction
#
# This simply involves popping the last Invocation
# Record (if there is one)  off the UnDo stack
# and manually executing the Main Handler Function
# found in that record with the Undo flag passed as TRUE.
# (Once again, we're just doing manually what the Command
# Interpreter did in the first place when the user first
# did that function.)  It's up to each Main Handler Function
# to do the right thing with UnDo.  If it does not care,
# it can just ignore the UnDo calls and return quietly.

def Undo(Args, Undo=FALSE):
    # Whoops, the Undo Stack is empty - there's nothing to undo
    # We'll tell the user, but it's a recoverable error
    if not len(UndoStack):
               Error(eNOUNDO, Fatal=FALSE)

    # There's UnDoing to be done    
    else:
        tprint(iUNDO)
        action = UndoStack.pop()
        action[0](Args=action[1], Undo=TRUE)


############################################################
###                 Support Functions                    ###
############################################################

# These are one of two kinds of functions.  They are either
# argument producing functions named in the Options table
# above (so the Command Interpreter can get the required
# arguments before calling a given Main Handler Function).
# Or, they are internal routines the program needs to
# do its job.

############################################################

# Figure out how out-of-balance the budget is
#
# We calculate this here as an argument producing function
# invoked by the Command Interpreter, not in the Balance()
# Main Handler Function (even though it could be done there).
# By doing it here, the balance amount is pushed onto the
# UnDo stack as part of invoking Balance() so we can later
# 'un-Balance' if we want to.  If we just had the Command
# Interpreter call Balance() directly, and computed the amount
# there, we'd have to fiddle with the UnDo stack to keep
# track of the amount.  This is exactly the scenario in Pay()
# so you can see it done both ways.

def BalAmount(str):
    return Budget[CASH].Bal - DEBIT

############################################################

# Build the runtime Jump Table from the options list
#
# The Jump Table is a dictionary of JumpEntry objects -
# one for each main feature in the program .  Each feature prompt has
# a "hotkey" which is used to invoke it.  The relevant hotkey is
# delimited for visual emphasis.
#
# The contents of the Jump Table is described with *strings* in the
# Options tuple above (to separate string literals from the body
# of the code itelf).  Here, we read in those strings and turn
# them into an actual Jump Table.


def BuildJT():
    global JumpTable
    # Initialize a new Jump Table
    JumpTable = {}
    
    # The next line of code uses so-called 'tuple unpacking' - very handy
    # shorthand for tearing data structures apart.
    
    for ((name, handler), argpairs) in Options:

        # Instantiate a new JumpEntry
        je = JumpEntry()

        # Find the hotkey
        y = name.split("&")
        index = y[1][0].upper()

        # Form the prompt
        je.Name = y[0].lower() + DELIMITL + index + DELIMITR + y[1][1:].lower()

        # Find the Main Handler Function
        # The name of the Main Handler Function is stored in Options
        # as a *string*.  This line evaluates the string and stores
        # a reference to the actual handler object so we can
        # execute it when we want to.  Kinda nifty, no?
        #
        # This indirectly illustrates an important central idea in Python:
        # **** PYTHON VARIABLES ARE JUST NAMES WHICH REFER TO OBJECTS!**
        # These variables themselves have no type - it is the objects
        # that carry the type. Understanding this idea well is kinda
        # at the heart of "getting" Python-   Most everything is
        # an object *reference* not a copy of the object.

        je.Handler = eval(handler)

        # Now build the list of ArgFunction-prompt pairs
        for z in argpairs:
            p = []
            p.append(eval(z[0]))
            p.append(z[1])
            je.Args.append(p)

        # Now add this entry to the Jump Table

        # Only install the Balance feature if the corresponding
        # BALACCT is present in the current budget.

        if index == sBALANCE.upper():

            # list.count(val) returns how many instances of val
            # exist in list.  An account should only appear 0 or 1
            # times in AccountNames so we use the call as a logic
            # test. (If you look and see how AccountNames gets built
            # in the first place, you'll see that each entry has
            # to be unique - its an index into a dictionary and these
            # can never be duplicates.)

            if AccountNames.count(BALACCT):
                JumpTable[index] = je
        else:
            JumpTable[index] = je


############################################################

# Error display handler

def Error(msg=eUNKNOWN, Fatal=TRUE):
    tprint(msg + "\n")
    if Fatal:
        sys.exit()
    else:
        UserInput(eCONTINUE + TRAILER)


############################################################

# Clear screen
#
# A cheater's way of clearing the screen without resorting
# to (non-portable) cursor addressing libaries like 'curses'.

def cls():
    if OSNAME == OSUNIX:
        os.system(CLSUNIX)
    elif OSNAME == OSWIN32:
        os.system(CLSWIN)
    else:
        # OK, I have no idea how to do it on a Mac
        # so we'll just shove newlines at it until
        # nothing is left.  Anyone who recognizes this
        # technique also knows what an ASR-33 is ;))
        
        tprint("\n" * OHEIGHT)
    
############################################################

# Display current budget on screen
#
# All we do is call ReCalc which creates a list of lines
# to display (or print - the Print() Main Handler Function
# calls ReCalc() too) and then display them.

def Display():

    # Clear the screen
    cls()
    # Calculate and display the current budget
    for x in ReCalc():
        tprint(x)
    # Display the command options toolbar
    ToolBar()

############################################################

# Ask user for an account name
#
# The prompt to use is supplied as an input parameter.
# The user  can respond with either the exact name or a
# unique starting substring which identifies the account.
# Return the name of the selected account.

def GetAccount(Action):
    DONE = FALSE
    retval = ""

    while not DONE:
        # Ask user for account name
        x = UserInput(pACCOUNT % Action, "").lower()

        if  INPUTABORT:         # Did the user abort the action?
            DONE = TRUE
        else:
            account = []
            for y in AccountNames:             # See if it matches
                if y.lower() == x:             # Exact match
                    retval = y
                    DONE = TRUE
                    break
                else:
                    if y.lower().startswith(x): # beginning of any
                        account.append(y)       # known account name

            # If we got here it means there was no exact
            # match.  We have to make sure we have a *unique*
            # starting substring or we won't know which account
            # the user is talking about.

            if len(account) == 1:  
                retval = account[0]
                DONE = TRUE
    return retval

############################################################

# Ask user for a currency amount
#
# The prompt to use is supplied as an input parameter.
# Returns floating point value of amount specified.
#
# By default, AllowBlank is FALSE which means if the user
# enters a blank line, they want to abort input.
#
# However, if AllowBlank is set TRUE, a blank line is allowed and
# will force the return value of 0. This can be interpreted
# by the calling function as it likes.  The Pay() function,
# for example, takes this to mean paying off the whole amount
# in the selected account.

def GetAmount(Action, AllowBlank = FALSE):
    global INPUTABORT
    DONE = FALSE
    while not DONE:

        # If we are allowing blank lines,  don't set abort flag,
        # but return a value of 0.
        #
        # The 'AllowBlank=AllowBlank' needs some explaining.
        # The left side refers to the formal parameter that
        # UserInput() is expecting from us.  The right side
        # refers to the the parameter that was passed to *us*
        # in this routine.  This is *really* crappy coding
        # style but I kind of backed myself into it as I
        # hacked away.  I was going to fix it, but decided
        # to leave it in to illustrate this very point -
        # That's my story, and I'm stickin' to it.
        
        y = UserInput(pAMOUNT % Action, AllowBlank=AllowBlank)

        # If y is a blank line, then 'not y' will be TRUE
        if AllowBlank and not y:  # We're allowing blank lines
            y = "0"
            DONE = TRUE
        elif not INPUTABORT:      # They did not type a blank line
            DONE = IsCurrency.match(y)
        else:                     # User typed a blank - they want to abort input
            y = "0"
            DONE = TRUE

    return atof(y)

############################################################

# Find out what budget file the user wants to load
#
# A blank line from the user here means that they want
# to accept the current default value for a file name.

def GetBudgetFile(Action):
    global BUDFIL
    while 1:
        BUDFIL = UserInput(Action % BUDFIL + PROMPT, BUDFIL, AllowBlank=TRUE)
        if INPUTABORT:
            return ""
        else:
            if os.path.exists(BASEDIR + BUDFIL):
                return BUDFIL
            else:
                Error(eNOLOAD % (BASEDIR, BUDFIL), Fatal=FALSE)

############################################################

# Get the amount to debit
#
# If the user just hits Enter, then debit the entire amount in that
# account.  This function is nothing more than a  wrapper to
# GetAmount() with the proper flags set.

def GetDebitAmount(Action):
    return GetAmount(Action + "\n" + iWHOLEAMT, AllowBlank = TRUE)

############################################################

# Get the amount to pay
#
# If the user just hits Enter, then pay the entire amount in that
# account.  This function is nothing more than a  wrapper to
# GetAmount() with the proper flags set.

def GetPayAmount(Action):
    return GetAmount(Action + "\n" + iWHOLEAMT, AllowBlank = TRUE)

############################################################

# Find out what printer or file the user want to print to
#
# A blank line from the user here means they want to
# accept the current default printer or file name.
# If the user enters something other than a device name,
# we will 'print' to a file.
#
# Under WinDoze, the intial default is the first printer device,
# LPT1:  Under anything else, the default is a report file.

def GetPrintDevice(Action):
    global LPTW
    while 1:
        LPTW = UserInput(Action % LPTW + PROMPT, LPTW, AllowBlank=TRUE)
        if INPUTABORT:
            return ""
        else:
            return LPTW

############################################################

# Calculate the current budget and return as a list of strings
#
# We can then display, print, or save these strings as desired.
#
# I'm fond of the Python idiom: 'string' * number' which returns
# a new string of 'number' copies of 'string' concatenated together.
#
# Notice that Python strings are *objects* which have methods.
# This is very handy.  You can do things like "mystring".center(80)
# or "mystring".upper() or you can do the same to string variables
# like 'stringvar.lower()'.  Mighty useful...

def ReCalc():
    global DEBIT
    retval =[]
    retval.append(BANNER)
    retval.append((iBUDGET + BASEDIR + BUDFIL).center(OWIDTH))
    retval.append("\n" * 5)
    DEBIT = 0
    apl = 0
    for cat in AccountNames:
        if cat.capitalize() != CASH:
            if apl == APL:
                retval.append("\n")
                apl = 0
            bal = Budget[cat].Bal
            DEBIT += bal
            bal = str(bal)
            retval.append(cat + (CWIDTH-len(cat))*" " +
                              bal + (CWIDTH-len(bal))*" ")
            apl += 1
    retval.append("\n" + SEP + "\n")
    income = Budget[CASH].Bal
    retval.append(TCASH + str(income) + 4 * " " +
                  TDEBIT + str(DEBIT) + 5 * " " +
                  ACASH + str(income-DEBIT) + 3 * "\n")
    return retval

############################################################

# Display command toolbar

def ToolBar():
    # Display the command in alphabetic order
    y = JumpTable.keys()
    y.sort()

    apl = 0
    for x in y:
        tprint(JumpTable[x].Name + " " * (CWIDTH - len(JumpTable[x].Name)))
        apl += 1
        if apl == APL * 2:
            tprint("\n")
            apl = 0
    if apl != 0:
            tprint("\n")


############################################################

# Primitive used by all routines asking for user input.
#
# By default, a blank line from the user means they
# are aborting the input underway.  However, if AllowBlank
# is passed as TRUE, this tells the routine that a blank line
# is legitimate input and to pass it back as such.

def UserInput(Prompt="", Default="", AllowBlank=FALSE):
    global INPUTABORT
    INPUTABORT = FALSE
    x = ""

    # Go talk to the user
    # Watch for keyboard interrupts and EOFs.
    try:
        x = raw_input(Prompt)
    except (EOFError, KeyboardInterrupt):
        Exit(Args=[])

    # We got some input
    if x:
        retval = x
    # We got a blank line
    else:
        if not AllowBlank:
            INPUTABORT = TRUE  # User can abort w/blank line
        retval = Default
    return retval


############################################################
###               hp.py program entry point              ###
############################################################

# Do some setup to prepare for program runtime.

# First, we compile our regular expression string for
# validating numeric input into an re object.  We can then
# use it to see if a given string matches our requirements.
# in the GetAmount() function, you'll see this done via:
#
#             IsCurrency.match(StringToCheck)

IsCurrency = re.compile(ISCURRENCY)

# Find out what operating system we're running on.  Some
# functions like cls() and Print() care.

OSNAME = os.name

# Setup the default print device/file

if OSNAME != OSWIN32:         # We're not printing on WinDoze
    LPTW = LPTX               # so default to writing a file.


# Command line argument processing

try:
    opts, args = getopt.getopt(sys.argv[1:], '-d:f:')

except getopt.GetoptError:
    tprint(iUSAGE)
    sys.exit(2)
    
for opt, val in opts:
    if opt == "-d":
        BASEDIR = val
    if opt == "-f":
        BUDFIL = val


# Load budget from disk.  Must give it an
# Args parameter to satisfy the loading function.
# Load() is setup to be table driven as a Main
# Handler Function.  These are always invoked
# with an 'Args' argument by the command interpeter.
# We're just calling it here manually to get things rolling.
#
# Load also initializes the Command Interpreter Jump Table.
# This is done at load time because some program
# features are enabled/disabled on a per-budget
# basis.  For example, the Balance function only works
# if the account named in BALACCT is present.  If such an
# account is not present, then Load() sees to it that the
# Balance feature is not presented to the user.

Load(Args=[BUDFIL])


#############################
# OK , now we're ready to actually start the program.
# Run The Command Interpreter
#############################

# We'll do this until something decides we're done
while not ALLDONE:

    # Show the user the current state of the budget
    Display()
    
    # Wait for them to tell us what they want
    x = UserInput("\n" + pCOMMAND, "").upper()

    # If it's something we recognize, we'll do it
    if JumpTable.has_key(x):

        # First, get the function to execute
        func = JumpTable[x].Handler

        # Then, go get the arguments.
        # This is done by iterating through all
        # arg function-prompt pairs until every
        # function has been executed and its results
        # appended to the args[] list.  Then we'll
        # ship the args off the the Main Handler Function
        # for this feature to finish the work.
        
        args = []
        for y in JumpTable[x].Args:
            if not INPUTABORT:           # Somwhere during input, the user
                args.append(y[0](y[1]))  # might have decided to bag it

        # Unless the user aborted input (thereby indicating their
        # desire to get out of the selected function), go do it.

        if not INPUTABORT:
            # Save the function and its arguments for potential Undo later.
            # Don't save the Undos themselves, however.
            if JumpTable[x].Handler != Undo:
                UndoStack.append([func, args])

            # Actually execute the request
            func(Args=args)


# So, that's it - if you have comments or questions, ship 'em
# off to: tundra@tundraware.com
# Thank you for playing...
