diff --git a/examples/accounts.py b/examples/accounts.py new file mode 100644 index 0000000..fc04499 --- /dev/null +++ b/examples/accounts.py @@ -0,0 +1,129 @@ +#demo account management program +import argparse +from nvm.fake_pmemobj import PersistentObjectPool, PersistentDict, PersistentList +import decimal +import datetime + +#initial account creation module + +#top-level parser +parser = argparse.ArgumentParser() +parser.add_argument('--foo', action = 'store_true', help = 'foo help') +subparsers = parser.add_subparsers(help = 'sub-command help', dest = 'subcommand') +parser.add_argument('-f', '--filename', default='accounts.pmem', help="filename to store data in") +parser.add_argument('-d', '--date', default = datetime.date.today(), help = 'specify date for this transaction') + +#create the parser for the 'accounts create' command +parser_create = subparsers.add_parser('create', description= 'account creation') +parser_create.add_argument('account', help = 'create specific type of bank account') +parser_create.add_argument('amount', help = 'establish initial balance', type=decimal.Decimal, default = decimal.Decimal('0.00'), nargs = '?') +#list account info., incl. past transactions +parser_create = subparsers.add_parser('list', description = 'individual account information display') +parser_create.add_argument('account', help = 'specify account') +#transfer money between accounts +parser_create = subparsers.add_parser('transfer', description = 'transer funds between accounts') +parser_create.add_argument('transferAmount', help ='quantity of money to transfer', type=decimal.Decimal, default = decimal.Decimal('0.00')) +parser_create.add_argument('pastLocation', help ='account from which funds are withdrawn') +parser_create.add_argument('futureLocation', help = 'account to which funds are deposited') +parser_create.add_argument('memo', help = 'explanation of transfer', nargs = argparse.REMAINDER) +#withdraw money from account (check) +parser_create = subparsers.add_parser('check', description = 'withdraw money via check') +parser_create.add_argument('checkNumber', help = 'number of check') +parser_create.add_argument('account', help = 'account from which to withdraw money') +parser_create.add_argument('amount', help = 'amount withdrawn', type = decimal.Decimal, default = decimal.Decimal('0.00')) +parser_create.add_argument('memo', help = 'check memo', nargs = argparse.REMAINDER) +#deposit money into account +parser_create = subparsers.add_parser('deposit', description = 'deposit money into account') +parser_create.add_argument('account', help = 'account in which to deposit money') +parser_create.add_argument('amount', help = 'total deposit', type = decimal.Decimal, default = decimal.Decimal('0.00')) +parser_create.add_argument('memo', help = 'source of deposit', nargs = argparse.REMAINDER) +#withdraw money from account (cash) +parser_create = subparsers.add_parser('withdraw', description = 'withdraw amount of cash from account') +parser_create.add_argument('account', help = 'account from which to withdraw money') +parser_create.add_argument('amount', help = 'total withdrawl', type = decimal.Decimal, default = decimal.Decimal('0.00')) +parser_create.add_argument('memo', help = 'reason for withdrawl', nargs = argparse.REMAINDER) +args = parser.parse_args() + +#check to see if args.date is a string -- convert to datetime object if so +if isinstance(args.date, str) == True: + args.date = datetime.datetime.strptime(args.date, '%Y-%m-%d') + +with PersistentObjectPool(args.filename, flag='c') as pop: + if pop.root is None: + pop.root = pop.new(PersistentDict) + accounts = pop.root + if args.subcommand == 'create': + accounts[args.account] = [[args.date, decimal.Decimal(args.amount), 'Initial account balance']] + print("Created account '" + args.account + "'.") + elif args.subcommand == 'list': + L1 = ("Date Amount Balance Memo\n" + "---------- ------- ------- ----") + print(L1) + accntBalance = decimal.Decimal(0) + for x in accounts[args.account]: + accntBalance = accntBalance + x[1] + for transaction in reversed(accounts[args.account]): + L2= "{:%Y-%m-%d}{:>9}{:>9} {}".format(transaction[0], transaction[1], accntBalance, transaction[2]) + print(L2) + accntBalance = accntBalance - transaction[1] + elif args.subcommand == 'transfer': + s= " " + memo = s.join(args.memo) + memo = memo[0].upper() + memo[1:] + accounts[args.pastLocation].append([args.date, -decimal.Decimal(args.transferAmount), memo]) + accounts[args.futureLocation].append([args.date, decimal.Decimal(args.transferAmount), memo]) + pBalance = decimal.Decimal(0) + for transaction in accounts[args.pastLocation]: + pBalance = pBalance + transaction[1] + fBalance = decimal.Decimal(0) + for transaction in accounts[args.futureLocation]: + fBalance = fBalance + transaction[1] + print("Transferred {} from {} (new balance {}) to {} (new balance {})".format(args.transferAmount, args.pastLocation, str(pBalance), args.futureLocation, str(fBalance))) + elif args.subcommand == 'check': + c = " " + memo = c.join(args.memo) + memo = memo[0].upper() + memo[1:] + accounts[args.account].append([args.date, -decimal.Decimal(args.amount), "Check {}: {}".format(args.checkNumber, memo)]) + newBalance = decimal.Decimal(0) + for transaction in accounts[args.account]: + newBalance = newBalance + transaction[1] + print("Check {} for {} debited from {} (new balance {})".format(args.checkNumber, args.amount, args.account, newBalance)) + elif args.subcommand == 'deposit': + c = " " + memo = c.join(args.memo) + memo = memo[0].upper() + memo[1:] + accounts[args.account].append([args.date, decimal.Decimal(args.amount), memo]) + newBalance = decimal.Decimal(0) + for transaction in accounts[args.account]: + newBalance = newBalance + transaction[1] + print("{} added to {} (new balance {})".format(args.amount, args.account, newBalance)) + elif args.subcommand == 'withdraw': + c = " " + memo = c.join(args.memo) + memo = memo[0].upper() + memo[1:] + accounts[args.account].append([args.date, -decimal.Decimal(args.amount), memo]) + newBalance = decimal.Decimal(0) + for transaction in accounts[args.account]: + newBalance = newBalance + transaction[1] + print("{} withdrawn from {} (new balance {})".format(args.amount, args.account, newBalance)) + else: + #check for accounts + if accounts: + #25 character spaces + m1= ("Account Balance\n" + "------- -------") + print(m1) + accntTotal = decimal.Decimal(0) + for specificAccount in sorted(accounts): + balance= decimal.Decimal(0.0) + for transaction in accounts[specificAccount]: + balance = balance + transaction[1] + accntInfo = "{:<20}{:>6}".format(specificAccount, balance) + print(accntInfo) + accntTotal = accntTotal + balance + m2=(" _______\n" + " Net Worth: {:>6.2f}").format(accntTotal) + print (m2) + #print message if no accounts exist + else: + print("No accounts currently exist. Add an account using 'account create'.") diff --git a/nvm/fake_pmemobj.py b/nvm/fake_pmemobj.py new file mode 100644 index 0000000..45a3b7e --- /dev/null +++ b/nvm/fake_pmemobj.py @@ -0,0 +1,56 @@ +""" +A fake PersistentObjectPool. It does the persistence magic by using json +on the root object to store it in a file, and the transactions are fake. But +it allows for testing the "this persists" logic of a program without dealing +with any bugs that may exist in the real PersistentObjectPool. + +""" + +import os +import pickle + +from contextlib import contextmanager + +#from nvm.pmemobj import PersistentList, PersistentDict + +class PersistentList(object): + pass +class PersistentDict(object): + pass + +class PersistentObjectPool: + def __init__(self, filename, flag='w', *args, **kw): + self.filename = filename + exists = os.path.exists(filename) + if flag == 'w' or (flag == 'c' and exists): + with open(filename, 'rb') as f: + self.root = pickle.load(f)[0] + elif flag == 'x' or (flag == 'c' and not exists): + with open(filename, 'wb') as f: + self.root = None + pickle.dump([None], f) + elif flag == 'r': + raise ValueError("Read-only mode is not supported") + else: + raise ValueError("Invalid flag value {}".format(flag)) + + def new(self, typ, *args, **kw): + if typ == PersistentList: + return list(*args, **kw) + if typ == PersistentDict: + return dict(*args, **kw) + + @contextmanager + def transaction(self): + yield None + + def close(self): + with open(self.filename+'.tmp', 'wb') as f: + pickle.dump([self.root], f) + os.rename(self.filename+'.tmp', self.filename) + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/nvm/pmemobj/__init__.py b/nvm/pmemobj/__init__.py index 6f382c0..7693524 100644 --- a/nvm/pmemobj/__init__.py +++ b/nvm/pmemobj/__init__.py @@ -8,3 +8,5 @@ from .pool import open, create, MIN_POOL_SIZE, PersistentObjectPool from .list import PersistentList +class PersistentDict(object): + pass diff --git a/tests/accounts.txt b/tests/accounts.txt new file mode 100644 index 0000000..37b27a2 --- /dev/null +++ b/tests/accounts.txt @@ -0,0 +1,185 @@ +This file tests and demonstrates the 'accounts' example program. We're using +doctest here for two reasons: (1) accounts is a command line program that +produces output on the terminal, so doctest's "show the command and check the +output" approach is a natural fit, and (2) accounts is demonstrating the +*persistence* of data between command runs, so a long string of examples that +progressively modify the data is a better fit than the unit test approach. To +do the same thing in unit test would be harder to read, more verbose (because +each test would need multiple commands to test the persistence), and not really +*unit* tests in the sense the unit test framework is designed for writing. + +To run these tests you should have your current directory be the directory +containing the 'nvm' package, and run the following command: + + python3 -m doctest tests/accounts.txt + +The doctest will only run if the 'accounts' demo is in 'examples/accounts' +relative to that same current directory. + +The persistent data used by the tests is backed by a file. For current testing +purposes we're using an ordinary file system file in the temporary directory +using libpmem's pmem emulation support. For a test of a demo program more +than that is not really needed. + +If a test run aborts, that file will be left behind, so the first thing +we need to do is remove the temp file: + + >>> import tempfile + >>> import os + >>> PMEM_FILE = os.path.join(tempfile.gettempdir(), 'accounts.pmem') + >>> if os.path.exists(PMEM_FILE): + ... os.remove(PMEM_FILE) + +The tests below use a common format: we call the accounts demo program using +a set of arguments, and see the output that produces. Since we're running this +from inside doctest, which executes python code, we need a helper function to +run the command: + + >>> import sys + >>> from subprocess import Popen, PIPE + >>> def run(cmd): + ... env = os.environ + ... env['PYTHONPATH'] = '.' + ... cmd, _, args = cmd.partition(' ') + ... cmd = (sys.executable + ' ' + os.path.join('examples', cmd + '.py') + ... + ' -f ' + PMEM_FILE + ' ' + '-d 2015-08-09 ' + args) + ... p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE, + ... universal_newlines=True, env=env) + ... rc = p.wait() + ... for line in p.stdout: + ... print(line.rstrip('\n')) + ... errs = p.stderr.read().splitlines() + ... if errs: + ... print('--------- error output --------') + ... for line in errs: + ... print(line) + +This function takes care of the boilerplate of turning the command name +'accounts' into a call to it using the same interpreter used to run the +doctests, in its expected location in the examples directory, and passing to it +the -f option to specify the location of our test database instead of using its +default. It also prints a line dividing normal output from error output if +there is any error output, allowing us to check that error output goes to the +correct standard stream. + +Initially, the file holding the data does not exist: + + >>> os.path.exists(PMEM_FILE) + False + +The default action of the 'accounts' command is to show a summary of the +current accounts. Initially that will just be a message that there are +no accounts: + + >>> run('accounts') + No accounts currently exist. Add an account using 'account create'. + +But now the (empty) persistent memory file will exist: + + >>> os.path.exists(PMEM_FILE) + True + +If we create an account, by default it starts with a zero balance: + + >>> run('accounts create checking') + Created account 'checking'. + >>> run('accounts') + Account Balance + ------- ------- + checking 0.00 + _______ + Net Worth: 0.00 + +When we create an account, we can specify an initial balance: + + >>> run('accounts create savings 119.00') + Created account 'savings'. + >>> run('accounts') + Account Balance + ------- ------- + checking 0.00 + savings 119.00 + _______ + Net Worth: 119.00 + +The only transaction in a newly created account is the initial balance: + + >>> run('accounts list checking') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 0.00 0.00 Initial account balance + + >>> run('accounts list savings') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 119.00 119.00 Initial account balance + +If we transfer money between accounts, that will add a transaction to +each account: + + >>> run('accounts transfer 50.50 savings checking For check 115') + Transferred 50.50 from savings (new balance 68.50) to checking (new balance 50.50) + + >>> run('accounts list checking') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 50.50 50.50 For check 115 + 2015-08-09 0.00 0.00 Initial account balance + + >>> run('accounts list savings') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 -50.50 68.50 For check 115 + 2015-08-09 119.00 119.00 Initial account balance + +When check 115 clears we can do: + + >>> run('accounts check 115 checking 50.50 Vet bill') + Check 115 for 50.50 debited from checking (new balance 0.00) + + >>> run('accounts list checking') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 -50.50 0.00 Check 115: Vet bill + 2015-08-09 50.50 50.50 For check 115 + 2015-08-09 0.00 0.00 Initial account balance + +Next we can deposit funds into one of the accounts: + + >>> run('accounts deposit savings 30.00 Monday paycheck') + 30.00 added to savings (new balance 98.50) + + >>> run('accounts list savings') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 30.00 98.50 Monday paycheck + 2015-08-09 -50.50 68.50 For check 115 + 2015-08-09 119.00 119.00 Initial account balance + +Conversely, we can withdraw funds: + + >>> run('accounts withdraw savings 15.00 lunch money') + 15.00 withdrawn from savings (new balance 83.50) + + >>> run('accounts list savings') + Date Amount Balance Memo + ---------- ------- ------- ---- + 2015-08-09 -15.00 83.50 Lunch money + 2015-08-09 30.00 98.50 Monday paycheck + 2015-08-09 -50.50 68.50 For check 115 + 2015-08-09 119.00 119.00 Initial account balance + +(Aside: it would be nice to have the account name in the previous command +have a default value that could be established by an 'accounts set +default-checking checking' command so that we could just type 'accounts +check 115 50.50 Vet bill'), but that is a bit complicated to code with +argparse so we won't bother with doing that for this demo program.) + + + + + +Cleanup: + >>> if os.path.exists(PMEM_FILE): + ... os.remove(PMEM_FILE) +