Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions examples/accounts.py
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing wrong with what you have here. However, it is a general principle in python that in most places where you could use a variable name you can instead use an expression. This applies particularly to function arguments. You have "L1 = " followed by "print(L1)". You could instead write "print()". That is, the above two lines would become:

print("Date         Amount  Balance  Memo\n"
         "----------  -------  -------  ----")

This is how you will typically see print statements written in Python programs.

accntBalance = decimal.Decimal(0)
for x in accounts[args.account]:
accntBalance = accntBalance + x[1]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is working fine, as we discussed. There's a simpler way you could write this, though, using the 'sum' function and what's called a 'list comprehension'. There's no need to change this, but if you feel like looking those two up and figuring out how to do it you'd be learning something new.

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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string literal in Python (which is what " " is) gets compiled into a 'string object'. Your assignment statement then puts the pointer to this string object that the literal gets turned into into the name space under the name s. When you write "s.join", you are calling the 'join' method of the string object pointed to by s. But '" "' is turned into a pointer to a string object by the compiler, so you can equally well call 'join' directly on it:

" ".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])
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args.transferAmount is declared in the parser to be type decimal.Decimal. What that means is that the parser will call decimal.Decimal on whatever the user types. So calling decimal.Decimal on it here is redundant, it should already be a Decimal.

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)))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the summary we had to use the 6.2f format to make sure we got exactly two decimal places. I'm a little surprised that none of the tests failed, given that you are only using str here. It would be nice to come up with a test that did fail (had too few or too many decimal places...or better yet two tests, one for each of those cases). Since we don't have any commands that do division or multiplication, though, making a test for too many digits won't be possible, I think. And maybe the other case just doesn't fail either, so don't worry about the tests much.

elif args.subcommand == 'check':
c = " "
memo = c.join(args.memo)
memo = memo[0].upper() + memo[1:]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do this operation in several of the if cases. That makes it a good candidate to turn into a function. You'd use 'def format_memo(memo_parts):' up above the if and put the lines in the body of the function, and then in each case you can do 'memo = format_memo(args.memo)' instead of repeating your three (or two, see above) lines.

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'.")
56 changes: 56 additions & 0 deletions nvm/fake_pmemobj.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions nvm/pmemobj/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@

from .pool import open, create, MIN_POOL_SIZE, PersistentObjectPool
from .list import PersistentList
class PersistentDict(object):
pass
185 changes: 185 additions & 0 deletions tests/accounts.txt
Original file line number Diff line number Diff line change
@@ -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)