Embercode
✕
Object-Oriented ProgrammingLesson 17 of 17

50 min read

Inventory mini project in Python

Why this matters

The earlier lessons in this module each taught one tool: classes and __init__, encapsulation, invariants, composition, operator overloading, and exceptions. This lesson uses several of them together in one small program. Writing the pieces together shows how they fit, and why each class should be tested before the others depend on it.

The program is a stock system. Stock means the goods a shop has on hand. Each product has a code called a SKU (stock keeping unit), such as A100. The system tracks how many of each product there are, what they are worth, and which ones are running low.

A unit test is a small program that checks one piece of code by itself, using known inputs and an expected result. If the item class has a bug, the test for the item class fails and points at the item class. Without that test, the bug may appear only in a printed report at the end, where it is hard to trace back.

The commands in this lesson were run with Python 3.14. The tests use unittest, which is part of the standard library, so no install is needed. The times after Ran and the file paths in tracebacks depend on your machine, so yours may differ.

The plan

The system has three classes.

  • Item holds one product: its SKU, its name, its price in cents and its quantity.
  • Store holds many items. It finds an item by its SKU, restocks it and sells from it.
  • Report turns a list of items into text: a table sorted by value, a total line and a list of low-stock items.

The dependencies run one way. Store uses Item. Report also uses Item, but it takes a plain list of items instead of a Store. That detail matters for testing. The report test needs only items, so it runs without building a store.

Each module gets its own test file, and each test file imports only the modules it needs. test_item.py uses only item. test_store.py uses store and item. test_report.py uses report and item.

The project folder looks like this:

stock/
  item.py
  store.py
  report.py
  test_item.py
  test_store.py
  test_report.py
  demo.py

The module names are the file names without .py. A file called item.py is imported with from item import Item. The names must match exactly, including capital letters, because Linux file names are case-sensitive.

Step 1: The Item class

Item is the base of the program. Both other classes depend on it, so it has the most checks.

Prices are stored as a whole number of cents. A price of 2.50 dollars is stored as 250. Computers store decimal numbers in binary, so a decimal such as 0.1 cannot be stored exactly. In Python, 0.1 + 0.2 prints 0.30000000000000004. Whole cents avoid this problem, because integer arithmetic is exact.

Here is the class.

class Item:
    def __init__(self, sku, name, price_cents, quantity):
        if sku is None or sku.strip() == "":
            raise ValueError("sku must not be blank")
        if name is None or name.strip() == "":
            raise ValueError("name must not be blank")
        if price_cents < 0:
            raise ValueError(f"price must not be negative, got {price_cents}")
        if quantity < 0:
            raise ValueError(f"quantity must not be negative, got {quantity}")
        self._sku = sku.strip()
        self._name = name.strip()
        self._price_cents = price_cents
        self._quantity = quantity
 
    @property
    def sku(self):
        return self._sku
 
    @property
    def name(self):
        return self._name
 
    @property
    def price_cents(self):
        return self._price_cents
 
    @property
    def quantity(self):
        return self._quantity
 
    def add_stock(self, amount):
        if amount <= 0:
            raise ValueError(f"amount to add must be positive, got {amount}")
        self._quantity += amount
 
    def remove_stock(self, amount):
        if amount <= 0:
            raise ValueError(f"amount to remove must be positive, got {amount}")
        if amount > self._quantity:
            raise RuntimeError(
                f"only {self._quantity} of {self._sku} in stock, cannot remove {amount}")
        self._quantity -= amount
 
    def value_cents(self):
        return self._price_cents * self._quantity
 
    def __str__(self):
        return f"{self._sku} {self._name} x{self._quantity}"

Three points about this class.

The attributes _sku, _name and _price_cents are set once, in __init__, and they have only read-only properties. Nothing outside the class can change them. Only _quantity changes after the object is built, and it changes only through add_stock and remove_stock. Every other part of the program has to go through those two methods.

Each method checks its input before it changes anything. The program uses two kinds of error, and they mean different things. ValueError means the value passed in is wrong, for example a negative amount. RuntimeError here means the value is fine, but the current state does not allow the action. Removing 8 items when 7 are left is an example. The error type tells the reader which kind of problem happened.

Python integers have no fixed size, so price_cents * quantity never wraps around to a wrong negative number. A Java program needs an extra step to catch that. In Python the multiplication is always exact.

Testing Item alone

Before writing the other classes, test Item by itself. The standard unittest module provides a base class with checks such as assertEqual, assertIn and assertRaises. A check that fails stops the test and prints what went wrong.

import unittest
 
from item import Item
 
 
class ItemTest(unittest.TestCase):
    def test_starts_with_trimmed_name_and_value(self):
        item = Item("A100", "  Desk lamp  ", 2500, 4)
        self.assertEqual(item.name, "Desk lamp")
        self.assertEqual(item.quantity, 4)
        self.assertEqual(item.value_cents(), 10000)
 
    def test_add_and_remove_change_quantity(self):
        item = Item("A100", "Desk lamp", 2500, 4)
        item.add_stock(6)
        self.assertEqual(item.quantity, 10)
        item.remove_stock(3)
        self.assertEqual(item.quantity, 7)
 
    def test_cannot_remove_more_than_stock(self):
        item = Item("A100", "Desk lamp", 2500, 7)
        with self.assertRaises(RuntimeError):
            item.remove_stock(8)
        self.assertEqual(item.quantity, 7)
 
    def test_bad_values_are_rejected(self):
        with self.assertRaises(ValueError):
            Item(" ", "x", 100, 1)
        with self.assertRaises(ValueError):
            Item("A1", "x", -1, 1)
        with self.assertRaises(ValueError):
            Item("A1", "x", 100, -1)
        item = Item("A1", "x", 100, 1)
        with self.assertRaises(ValueError):
            item.add_stock(0)
        with self.assertRaises(ValueError):
            item.remove_stock(-2)
 
 
if __name__ == "__main__":
    unittest.main()

Run the test from the stock folder:

python3 -m unittest test_item

The output is:

....
----------------------------------------------------------------------
Ran 4 tests in 0.000s
 
OK

Each dot is a test that passed. The command does not need store.py or report.py, because test_item.py does not import them. That is the point of testing one class alone. If Store had a bug, this run would not be affected.

The line if __name__ == "__main__": runs the tests when the file is run directly. The python3 -m unittest command finds the tests by itself, so the line is optional in this project, but it lets you run a test file on its own with python3 test_item.py.

Step 2: The Store class

Store keeps items in a dictionary. A dictionary is a collection that finds a value from a key. Here the key is the SKU, a string such as A100, and the value is the Item with that SKU. Looking up an item by SKU takes about one step, however many items the store holds.

Python dictionaries keep the order in which items were added, so the list printed later comes out in the same order every time.

The method find returns None when the SKU is missing. The method require raises an error with a message that names the SKU. The choice between them is the same as the choice in the exceptions lesson: find is for a missing SKU that is a normal answer, and require is for a missing SKU that is a mistake.

class Store:
    def __init__(self):
        self._items_by_sku = {}
 
    def __len__(self):
        return len(self._items_by_sku)
 
    def add(self, item):
        if item is None:
            raise ValueError("item is required")
        if item.sku in self._items_by_sku:
            raise ValueError(f"duplicate SKU: {item.sku}")
        self._items_by_sku[item.sku] = item
 
    def find(self, sku):
        return self._items_by_sku.get(sku)
 
    def require(self, sku):
        item = self._items_by_sku.get(sku)
        if item is None:
            raise KeyError(f"no item with SKU {sku}")
        return item
 
    def restock(self, sku, amount):
        self.require(sku).add_stock(amount)
 
    def sell(self, sku, amount):
        self.require(sku).remove_stock(amount)
 
    def sell_all(self, order):
        for sku, wanted in order.items():
            item = self.require(sku)
            if wanted <= 0:
                raise ValueError(f"order quantity must be positive, got {wanted}")
            if wanted > item.quantity:
                raise RuntimeError(
                    f"only {item.quantity} of {item.sku} in stock, order needs {wanted}")
        for sku, wanted in order.items():
            self.require(sku).remove_stock(wanted)
 
    def low_stock(self, limit):
        return [item for item in self._items_by_sku.values() if item.quantity <= limit]
 
    def items(self):
        return tuple(self._items_by_sku.values())

Some points about the class.

add refuses a duplicate SKU. Without that check, a second item with the same SKU would silently replace the first one in the dictionary, and the old quantity would disappear. The check keeps each SKU tied to exactly one item.

__len__ lets the caller write len(store), which is the Python way to ask how many items a store holds.

items returns a tuple, which is a read-only copy of the items. A caller who tries to add to that tuple gets an AttributeError, so the store's own collection cannot be changed from outside. The copy protects the collection, not the items. The Item objects inside the tuple are the same objects the store holds, so a caller can still change a quantity through add_stock or remove_stock. That is intended, because those methods have their own checks.

low_stock uses a list comprehension, which is a loop written on one line to build a list. It reads as "the items whose quantity is at most the limit", and it builds a new list.

Testing Store alone

The store test uses real Item objects, because Store is built on them. It does not use Report. The method setUp runs before each test and builds a fresh store, so one test cannot change what another test sees.

import unittest
 
from item import Item
from store import Store
 
 
class StoreTest(unittest.TestCase):
    def setUp(self):
        self.store = Store()
        self.store.add(Item("A100", "Desk lamp", 2500, 4))
        self.store.add(Item("B200", "Notebook", 300, 0))
 
    def test_duplicate_sku_is_rejected(self):
        with self.assertRaises(ValueError):
            self.store.add(Item("A100", "Other", 100, 1))
        self.assertEqual(len(self.store), 2)
 
    def test_find_and_require(self):
        self.assertIsNotNone(self.store.find("A100"))
        self.assertIsNone(self.store.find("ZZZ"))
        with self.assertRaises(KeyError):
            self.store.require("ZZZ")
 
    def test_restock_and_sell(self):
        self.store.restock("B200", 5)
        self.assertEqual(self.store.require("B200").quantity, 5)
        self.store.sell("A100", 1)
        self.assertEqual(self.store.require("A100").quantity, 3)
 
    def test_failed_order_changes_nothing(self):
        order = {"A100": 2, "B200": 99}
        with self.assertRaises(RuntimeError):
            self.store.sell_all(order)
        self.assertEqual(self.store.require("A100").quantity, 4)
 
    def test_low_stock_and_read_only_items(self):
        low = self.store.low_stock(3)
        self.assertEqual([item.sku for item in low], ["B200"])
        items = self.store.items()
        with self.assertRaises(AttributeError):
            items.append(Item("C300", "Pen", 150, 1))
        self.assertEqual(len(self.store), 2)
 
 
if __name__ == "__main__":
    unittest.main()
python3 -m unittest test_store
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
 
OK

Example: an order that fails as a whole

The method sell_all is the hardest part of Store, so it gets a closer look.

Suppose a customer orders 2 desk lamps and 99 notebooks, and the store has only 4 lamps and 0 notebooks. If the method sold the lamps first and then found that the notebooks were short, the lamps would be gone and the order would be half done. The customer would receive part of the order, and the stock would be wrong.

The method avoids this by working in two passes. The first loop checks every line of the order against the stock, and raises an error if any line cannot be filled. Nothing has changed yet at this point. The second loop removes the stock. It runs only when the first loop found no problem.

def sell_all(self, order):
    for sku, wanted in order.items():
        item = self.require(sku)
        if wanted > item.quantity:
            raise RuntimeError(f"only {item.quantity} of {sku} in stock")
    for sku, wanted in order.items():
        self.require(sku).remove_stock(wanted)

The second loop calls remove_stock, which checks again. That is safe. The first loop already proved that every line fits, so the second loop never raises in the normal case.

The test test_failed_order_changes_nothing checks the failing case. The order asks for 99 notebooks when none are in stock. The test confirms the error is raised and that the lamp count stays at 4. A failed order leaves the store as it was.

Step 3: The Report class

Report reads a list of items and produces text. It does not change any item. Its initializer takes the low-stock limit, which is the quantity at or below which an item counts as low.

def money(cents):
    return f"{cents // 100}.{cents % 100:02d}"
 
 
class Report:
    def __init__(self, low_stock_limit):
        if low_stock_limit < 0:
            raise ValueError(f"low stock limit must not be negative, got {low_stock_limit}")
        self._low_stock_limit = low_stock_limit
 
    def render(self, items):
        if items is None:
            raise ValueError("items are required")
        by_value = sorted(items, key=lambda item: (-item.value_cents(), item.sku))
 
        lines = [f"{'SKU':<8} {'Name':<14} {'Qty':>5} {'Value':>10}"]
        total = 0
        for item in by_value:
            lines.append(
                f"{item.sku:<8} {item.name:<14} {item.quantity:>5} {money(item.value_cents()):>10}")
            total += item.value_cents()
        lines.append(f"{'':<8} {'Total':<14} {'':>5} {money(total):>10}")
 
        low = [item.sku for item in by_value if item.quantity <= self._low_stock_limit]
        lines.append("Low stock: " + (", ".join(low) if low else "none"))
        return "\n".join(lines) + "\n"

The method render does three things in order.

The sorted call uses a key function. The key is a tuple: the negative value first, so the most valuable item comes first, and then the SKU. The SKU breaks ties, so two items with the same value always appear in the same order. The sorted function returns a new list. The list the caller passed in is not reordered.

The f-strings (text that starts with f before the quote, with values in braces) build a table. The format <8 means text padded to 8 characters and aligned left. >5 means a whole number padded to 5 characters and aligned right. Using a fixed width for each column keeps the table lined up even when the names have different lengths.

The money function turns a count of cents into dollars and cents. cents // 100 gives the whole dollars, because // drops the remainder. cents % 100 gives the leftover cents. The format 02d pads a one-digit number with a zero, so 5 cents prints as 05.

Testing Report alone

Report takes a list of Item objects, so its test does not need Store. The test builds a list directly.

import unittest
 
from item import Item
from report import Report
 
 
class ReportTest(unittest.TestCase):
    def setUp(self):
        self.items = [
            Item("A100", "Desk lamp", 2500, 4),
            Item("B200", "Notebook", 300, 0),
            Item("C300", "Pen", 150, 10),
        ]
        self.report = Report(3)
 
    def test_rows_are_sorted_by_value(self):
        text = self.report.render(self.items)
        self.assertLess(text.index("A100"), text.index("C300"))
        self.assertLess(text.index("C300"), text.index("B200"))
 
    def test_total_and_low_stock(self):
        text = self.report.render(self.items)
        self.assertIn("115.00", text)
        self.assertIn("Low stock: B200", text)
 
    def test_empty_report(self):
        text = self.report.render([])
        self.assertIn("0.00", text)
        self.assertIn("Low stock: none", text)
 
    def test_bad_inputs_are_rejected(self):
        with self.assertRaises(ValueError):
            Report(-1)
        with self.assertRaises(ValueError):
            self.report.render(None)
 
 
if __name__ == "__main__":
    unittest.main()

The order check uses text.index, which returns the position of the first match in the text. If the position of A100 is smaller than the position of C300, then A100 is printed first. This tests the order without comparing the whole table character by character.

python3 -m unittest test_report
....
----------------------------------------------------------------------
Ran 4 tests in 0.001s
 
OK

Putting the three together

The demo builds a store, changes it, and prints a report. It runs the real modules together, so it shows whether they work as a group after each one passed its own tests.

from item import Item
from report import Report
from store import Store
 
store = Store()
store.add(Item("A100", "Desk lamp", 2500, 4))
store.add(Item("B200", "Notebook", 300, 0))
store.add(Item("C300", "Pen", 150, 10))
 
store.restock("B200", 12)
store.sell("A100", 1)
store.sell("C300", 4)
 
report = Report(3)
print(report.render(store.items()), end="")

Run all three test files, then the demo, from the stock folder:

python3 -m unittest test_item test_store test_report
python3 demo.py
.............
----------------------------------------------------------------------
Ran 13 tests in 0.001s
 
OK
SKU      Name             Qty      Value
A100     Desk lamp          3      75.00
B200     Notebook          12      36.00
C300     Pen                6       9.00
         Total                    120.00
Low stock: A100

The numbers can be checked by hand. The lamp price is 25.00 and there are 3 left, so its value is 75.00. The notebooks are 3.00 each, and there are 12, so 36.00. The pens are 1.50 each, and there are 6, so 9.00. The total is 120.00. The low-stock limit is 3, and the lamp has exactly 3, so it is listed.

Common mistakes

Assigning to a read-only property inside __init__

Suppose the constructor sets the property name instead of the attribute, as in self.name = name.strip(). The property has no setter, so Python raises an error at that line. The fix is to assign to the attribute with the underscore, self._name = name.strip(), as the Item class does.

AttributeError: property 'name' of 'Item' object has no setter

The error names the property and the class, and it points at the line in __init__. Read the message, then check which name the assignment uses.

Leaving out a check in a method

If the check if amount > self._quantity is removed from remove_stock, the code still runs. Calling remove_stock(8) on an item with 7 left then does not raise, and the quantity goes negative. The test catches it. Here is the output with the check removed:

..F.
======================================================================
FAIL: test_cannot_remove_more_than_stock (test_item.ItemTest.test_cannot_remove_more_than_stock)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_item.py", line 22, in test_cannot_remove_more_than_stock
    with self.assertRaises(RuntimeError):
         ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
AssertionError: RuntimeError not raised

The message names the test that failed, and the traceback points at the line in the test that makes the call. The file path depends on where you run the test. Read the test name first, because it usually tells you what to fix.

Using assert instead of unittest

Python has an assert statement. It works in a quick script, but Python skips every assert line when it runs with the -O option. A check written only with assert can then pass without checking anything. The next program shows the difference.

quantity = -5
assert quantity >= 0, "quantity is negative: " + str(quantity)
print("assert line passed or was skipped")
python3 assert_demo.py
python3 -O assert_demo.py
Traceback (most recent call last):
  File "assert_demo.py", line 2, in <module>
    assert quantity >= 0, "quantity is negative: " + str(quantity)
           ^^^^^^^^^^^^^
AssertionError: quantity is negative: -5
assert line passed or was skipped

The first run stops with the message. The second run, with -O, skips the assertion and prints the last line, so the check did nothing. The unittest checks in this lesson always run, whatever options are used.

Money as a float

A float cannot store 0.1 exactly, so sums of prices drift. The file below shows it.

quantity = 0.1 + 0.2
print(quantity)
0.30000000000000004

That is why the prices are whole cents. A total of 9.00 is stored as 900, and every sum stays exact.

Importing a module with the wrong name

A module is found by its file name. If the file is widget.py and the code says import gadget, Python cannot find the module.

import gadget
ModuleNotFoundError: No module named 'gadget'

Rename the file, or change the import, so the two names match. In this project each module is named after the class it holds, in lowercase, so item.py holds Item.

Practice

  1. Add a method is_low_stock(limit) to Item. It returns True when the quantity is at most limit. Write one check, for an item with quantity 2 and a limit of 2, that the method returns True.

Show the answer

Add this method to Item:

def is_low_stock(self, limit):
    return self._quantity <= limit

The check is self.assertTrue(Item("A100", "Desk lamp", 2500, 2).is_low_stock(2)) in a test class. The test passes with item.py and the test file.

  1. Add a method total_value_cents() to Store. It returns the sum of value_cents() for every item in the store.

Show the answer

def total_value_cents(self):
    return sum(item.value_cents() for item in self._items_by_sku.values())

The generator expression adds the value of each item. A store with a lamp worth 5000 cents and a notebook pack worth 1500 cents returns 6500.

  1. Add a method remove(sku) to Store. It deletes the item with that SKU. If the SKU is unknown, it raises KeyError.

Show the answer

def remove(self, sku):
    self.require(sku)
    del self._items_by_sku[sku]

The call to require does the check. It raises KeyError when the SKU is missing, so the del line runs only for a known item.

  1. Write a check that a successful sell_all reduces every line of the order. Use a store with a lamp at quantity 3 and a notebook at quantity 5, and an order for 2 lamps and 5 notebooks.

Show the answer

from item import Item
from store import Store
 
shop = Store()
shop.add(Item("A100", "Desk lamp", 2500, 3))
shop.add(Item("B200", "Notebook", 300, 5))
shop.sell_all({"A100": 2, "B200": 5})
 
assert shop.require("A100").quantity == 1, "A100 went from 3 to 1"
assert shop.require("B200").quantity == 0, "B200 went from 5 to 0"
print("sell_all checks passed")

Both assert lines pass, and the program prints sell_all checks passed. In a real test file, write the same two checks with self.assertEqual, so they run even under -O.

  1. The function below looks for an item, then sells from it. What error appears when the SKU is unknown, and how should the line be changed?
def sell(store, sku, amount):
    item = store.find(sku)
    item.remove_stock(amount)

Show the answer

When the SKU is unknown, find returns None, and calling a method on None fails. The real output from running this code with the SKU ZZZ is below. The file name and line numbers depend on your file.

Traceback (most recent call last):
  File "sell.py", line 9, in <module>
    sell(Store(), "ZZZ", 1)
    ~~~~^^^^^^^^^^^^^^^^^^^
  File "sell.py", line 7, in sell
    item.remove_stock(amount)
    ^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'remove_stock'

The message does not name the SKU. Replace the first line with item = store.require(sku). require raises KeyError with the message no item with SKU ZZZ, which says what went wrong.

Summary

  • The system has three modules: item holds a product, store holds many items, and report turns a list of items into text.
  • Put the checks in __init__ and in every method that changes an attribute, so a bad value is refused where it enters.
  • Test each module with only the modules it needs, so a failing test points at the module that is broken.
  • Use unittest for tests, because assert is skipped when Python runs with -O.
  • Return a tuple from a method when callers must not change the collection, and keep money in whole cents.
Check yourself: Cleanup and project (8 questions) →Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Context managers and the with statement