diff --git a/hb.py b/hb.py new file mode 100644 index 0000000..5a5c1bc --- /dev/null +++ b/hb.py @@ -0,0 +1,1254 @@ +#!/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,v 1.89 2002/09/08 23:31: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 consistency 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...