Supermarket Story

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:

  • the requested quantity is not in stock (OutOfStock exception)
  • the shopper has insuffient funds to cover the purchase (NoMoney exception)

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...


------- Buy Event 1 -------
Uh oh, out of stock!
Shopper Kirby:
 Wallet 980.04;
 Basket {'Snicker-Snacks': 2, "Polly's Peanuts": 2}
Inventory on hand: 
  1: Snicker-Snacks
  Price: 5.99
  Qty  : 8

  2: Polly's Peanuts
  Price: 3.99
  Qty  : 8

  3: Dr. Soap
  Price: 4.99
  Qty  : 10

------- Buy Event 2 -------
Shopper Kirby:
 Wallet 930.14;
 Basket {'Snicker-Snacks': 2, "Polly's Peanuts": 2, 'Dr. Soap': 10}
Inventory on hand: 
  1: Snicker-Snacks
  Price: 5.99
  Qty  : 8

  2: Polly's Peanuts
  Price: 3.99
  Qty  : 8

  3: Dr. Soap
  Price: 4.99
  Qty  : 0

SuperMarket with cash: 69.86

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]:
930.14

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).