Lets think about a Market simulation wherein the Inventory is a separate object.
Distilling inventory into its own type (Inventory class) suggests how we could later distill other functions within a store. Suppose we need to change those prices. Perhaps we'll need a customer service desk the sells postage stamps. Our model could become much more intricate and detailed.
We might have our cost centers or profit centers, depending on circumstance and terminology. If we have a chain of stores, they might all be reading from and writing to a centralized database, which brings up glossary topics such as "ACID compliance".
Lets leave all that to the imagination and concentrate just on an Inventory. While learning Python, we have no need to clutter the vista with unnecessary complexity.
In [1]:
class Inventory:
"""
Supermarket brings an Inventory instance on board upon
initialization, increments / decrements items, reads
and writes to json file. Does not track cash.
"""
def __init__(self, the_file):
self.storage = the_file
with open(the_file, 'r') as warehouse:
self.wares = json.load(warehouse)
def save_items(self):
with open(self.storage, 'w') as warehouse:
json.dump(self.wares, warehouse, indent=4)
def remove_item(self, item, qty):
if qty > self.wares[item][1]:
raise OutOfStock
self.wares[item][1] -= qty
def add_item(self, item, qty):
self.wares[item][1] += qty
def __str__(self): # enhancement since version 2
line = ""
for idx, item in enumerate(self.wares.items(), start=1):
line = line + "\n" + \
"{:3}: {}\n".format(idx, item[0]) + \
" Price: {}\n".format(item[1][0]) + \
" Qty : {}\n".format(item[1][1])
return line
def __repr__(self):
return "Inventory('{}')" % self.storage
def test_data():
"""
10 of everything keeps it simple, however
feel free to take over this section. Add
whatever products needed for the simulations.
"""
stuff = {
"Snicker-Snacks": [5.99, 10],
"Polly's Peanuts": [3.99, 10],
"Dr. Soap": [4.99, 10]}
with open("the_stuff.json", 'w') as warehouse:
json.dump(stuff, warehouse, indent=4)
We're given some test data, for convenience, consisting of current price and quantity on hand. Typically we maintain a testing version of our product, and a production version. The latter will have to grapple with a more current and complete set of data.
On creation (initialization), a Supermarket type object (below) will instantiate a corresponding Inventory (above) and use wares
as an active dictionary whereby "quantities on hand" might be changed.
No provision is made for changing prices, e.g. 5.99 in the case of Snicker-Snacks. Those would be methods a developer might add for the next version.
We could imagine a Clerk type with the job of changing price tags on items. We could also imagine an Ordering department, and Payroll.
Lets keep leaving all that to the imagination, as our focus here is more the grammar of Python than the guts of a fully functional SuperMarket.
Here's a Supermarket type, with some customized Exceptions it might be concerned with:
In [2]:
import json
class NoMoney(Exception):
pass
class OutOfStock(Exception):
pass
class SuperMarket:
"""
Persists buyable items in a json file.
Initializes with 0 cash
"""
def __init__(self):
self.inventory = Inventory("the_stuff.json")
# self.clerk = Clerk() <- suggestive
self.cash = 0 # not persisted through inventory
# in a future version, clerk might handle cash
def buy(self, shopper, item, how_many):
"""
remove money from shopper wallet, add qty of item
to basket, abort if customer short on cash
"""
if item in self.inventory.wares: # check keys
price = self.inventory.wares[item][0]
try:
# might have to undo
self.inventory.remove_item(item, how_many)
shopper.add_item(item, price, how_many)
except NoMoney:
# undo removing item from inventory:
# put the item back if unable to purchase
# this is the bug fix <------------------
# since version 2.
# print("Restoring item")
self.inventory.add_item(item, how_many)
raise # re-raise exception
except OutOfStock:
# print("Don't have enough in stock")
raise
else:
# we get this far only if no exceptions
self.cash += round(price * how_many, 2)
def close(self):
"""
write json file
"""
self.inventory.save_items()
def __repr__(self):
return "SuperMarket with cash: {}".format(self.cash)
Clearly the buy
method is the focus of this type. The initializer merely reads in a JSON file and turns that in to a Python dictionary, self.wares
.
A buy (a transaction) features one shopper, an item to buy (subtracted from self.inventory.wares
, add to shopper.basket
), and a how many (units, integer number or quantity). Money also changes hands, decrementing shopper.wallet
and incrementing self.cash
.
Items might be sold by weight and floating point quantities might be imagined. We stick to integers here. We also allow floating point for monetary transactions, whereas decimal.Decimal
might be used.
During a buy event, two exceptions might be expected:
Finally, it's time for our Shopper type:
In [3]:
class Shopper:
def __init__(self, name, budget):
self.name = name
self.basket = { }
self.wallet = budget # budgeted allowance
def add_item(self, item, price, qty):
"""
add qty of item to basket and pay, if money available
we shouldn't get here unless the quantity was on hand,
per how Supermarket.buy works.
"""
if self.wallet - qty * price < 0:
# the calling buy method will need to put the
# item back into inventory, not a feature in
# version 2 i.e. this is a bug fix.
raise NoMoney
# Pythonic or too tricky? We debated on edu-sig
# I'd say idiomatic among some Pythonistas
# if initializing item, there's nothing to get yet
# so default to 0 and add incoming quantity
self.basket[item] = self.basket.get(item, 0) + qty
# should work fine because we've already checked
# ahead at the self.wallet amount for affordability
self.wallet -= qty * price
def __repr__(self):
return "Shopper {}:\n Wallet {};\n Basket {}".format(
self.name, self.wallet, self.basket)
Now we're ready for a simulation. The simulator has to know what to do in case exceptions get raised.
In [4]:
def simulation():
test_data() # initialize the_stuff.json
kirby = Shopper("Kirby", 1000)
market = SuperMarket()
print("------- Buy Event 1 -------")
try:
market.buy(kirby, "Snicker-Snacks", 2)
market.buy(kirby, "Polly's Peanuts", 2)
market.buy(kirby, "Dr. Soap", 100) # triggers exception
except NoMoney:
pass
print("Uh oh, out of money!")
except OutOfStock:
pass
print("Uh oh, out of stock!")
print(kirby)
print("Inventory on hand:", market.inventory)
print("------- Buy Event 2 -------")
# kirby didn't get away with buying that much Dr. Soap, but
# the first two transactions got through. Undetered, kirby
# tries again to buy the soap. We expect all the soap to be
# gone, with 8 left of the other two items. We had 10 of
# everything to start.
try:
market.buy(kirby, "Dr. Soap", 10) # should be OK this time
except NoMoney:
pass
print("Uh oh, out of money!")
except OutOfStock:
pass
print("Uh oh, out of stock!")
print(kirby)
print("Inventory on hand:", market.inventory)
market.close()
print(market)
The simulation takes a Shopper through two "buying sprees". We're not sure which will buy will abort, if any. We could allow only one buy at a time.
The Dr. Soap transaction, asking for 1000, does not go through, but then the customer comes back and shops for 10 of that same item, and this should work.
Lets see...
In [5]:
simulation() # finally, lets DO something...
Does the shopper's original allowance, minus what's in the Supermarket's cash account, match what's currently in the shopper's wallet?
In [6]:
1000 - 69.86
Out[6]:
Tests such as the above might be gathered up as unittests. See if you have test_shopping.py
, which is designed to be matched with shopping_v2.py (in which case a test will fail) as well as shopping_v3.py (which should pass all the tests).