Challenge 2: Command-Line Inventory Manager
Objectives
By the end of this chapter, you should be able to:
- Combine abstract base classes, recursion, logging, and command-line arguments in one program
- Model a nested, tree-shaped structure and process it recursively
💡 Why this matters: Challenge 1 was a single flat collection of contacts. Real inventory (categories inside categories, items of genuinely different types) is closer to how data actually looks in practice. This challenge is your chance to reach for abstract base classes, recursion, and a real command-line interface all at once, outside of an isolated example.
The Project
Build a command-line Inventory Manager for a small shop: it tracks items organized into nested categories (like Electronics > Computers > Laptops), supports different kinds of items with different behavior, persists everything to a JSON file, logs every action, and is driven entirely from the command line, with no input() prompts.
Core Requirements
InventoryItem (abstract base class)
- An
ABCwithname,quantity, andunit_price - A
@propertycalledtotal_valuethat returnsquantity * unit_price - An
@abstractmethodcalledcategory_label()that subclasses must implement
At Least Two InventoryItem Subclasses
For example, PhysicalItem (adds weight_kg, category_label() returns "Physical") and DigitalItem (adds download_size_mb, category_label() returns "Digital").
Custom Exceptions
ItemNotFoundError(Exception): raised when a lookup can’t find an item with the given nameOutOfStockError(Exception): raised when trying to sell more than the availablequantity
Inventory
- Stores items inside a nested category tree: a dictionary where each category has a list of items and a dictionary of subcategories:
{
"Electronics": {
"items": [...],
"subcategories": {
"Computers": {"items": [...], "subcategories": {}}
}
}
}
add_item(category_path, item): adds an item to a category, creating any missing categories along the way (category_pathmight be a list like["Electronics", "Computers"]). For example, afterinv.add_item(["Electronics", "Computers"], laptop)on an empty inventory,inv.treeshould look like{"Electronics": {"items": [], "subcategories": {"Computers": {"items": [laptop], "subcategories": {}}}}}.find_item(name): searches recursively through every category and subcategory, returning the item or raisingItemNotFoundErrorsell_item(name, amount): finds the item, raisesOutOfStockErrorifamount > item.quantity, otherwise reduces the quantityprint_tree(): recursively prints the whole category tree, indenting each level of nesting (see the sample output below)
Persistence and Logging
save(path)andload(path): save and load the whole tree to a JSON file, accepting apathlib.Path(or plain string) filename- Use
logging(notprint()) to record every add, sell, and error at an appropriate level, for examplelogging.info()for a successful add or sale, andlogging.error()whenItemNotFoundErrororOutOfStockErroris caught
Command-Line Interface
Use argparse so the tool is driven entirely from the command line, with no input() calls:
python3 inventory.py add "Laptop" --category Electronics Computers --quantity 5 --price 999.99
python3 inventory.py sell "Laptop" --amount 2
python3 inventory.py list
Each subcommand should print or log a short confirmation. A full session might look like this:
$ python3 inventory.py add "Laptop" --category Electronics Computers --quantity 5 --price 999.99
INFO:root:Added Laptop to Electronics > Computers
$ python3 inventory.py sell "Laptop" --amount 2
INFO:root:Sold 2 of Laptop
$ python3 inventory.py list
[Electronics]
[Computers]
- Laptop (Physical, qty 3)
$ python3 inventory.py sell "Laptop" --amount 100
ERROR:root:Only 3 of Laptop left
For that last case, catch OutOfStockError (and ItemNotFoundError) in your CLI dispatch code, log the error, print a short message, and exit with a non-zero status code (sys.exit(1)) instead of letting a raw traceback reach the terminal.
Suggested Structure
import argparse
import json
import logging
from abc import ABC, abstractmethod
from pathlib import Path
logging.basicConfig(level=logging.INFO)
class ItemNotFoundError(Exception):
pass
class OutOfStockError(Exception):
pass
class InventoryItem(ABC):
def __init__(self, name, quantity, unit_price):
self.name = name
self.quantity = quantity
self.unit_price = unit_price
@property
def total_value(self):
return self.quantity * self.unit_price
@abstractmethod
def category_label(self):
pass
class PhysicalItem(InventoryItem):
def __init__(self, name, quantity, unit_price, weight_kg):
super().__init__(name, quantity, unit_price)
self.weight_kg = weight_kg
def category_label(self):
return "Physical"
class Inventory:
def __init__(self):
self.tree = {}
def _find_recursive(self, node, name):
for item in node.get("items", []):
if item.name == name:
return item
for subcategory in node.get("subcategories", {}).values():
found = self._find_recursive(subcategory, name)
if found:
return found
return None
def find_item(self, name):
# self.tree is a dict of {category_name: node}, not a node itself,
# so wrap it as the "subcategories" of an implicit, item-less root
item = self._find_recursive({"items": [], "subcategories": self.tree}, name)
if item is None:
raise ItemNotFoundError(f"No item named {name!r}")
return item
def sell_item(self, name, amount):
item = self.find_item(name)
if amount > item.quantity:
raise OutOfStockError(f"Only {item.quantity} of {name} left")
item.quantity -= amount
logging.info(f"Sold {amount} of {name}")
def print_tree(self, node=None, indent=0):
node = node if node is not None else {"items": [], "subcategories": self.tree}
for item in node.get("items", []):
print(" " * indent + f"- {item.name} ({item.category_label()}, qty {item.quantity})")
for name, subcategory in node.get("subcategories", {}).items():
print(" " * indent + f"[{name}]")
self.print_tree(subcategory, indent + 1)
Fill in add_item, JSON save/load, and the argparse wiring yourself. That’s most of the actual design work in this project.
Bonus Extensions
- Add a
total_inventory_value()method that recursively sumstotal_valueacross every item in every category and subcategory. - Add a
low_stock_items(threshold)method (recursive again) that returns every item at or below a given quantity, anywhere in the tree. - Add a
--format json/--format csvoption to thelistcommand, exporting the current flat list of items either way.
Try It
- Build
InventoryItemand at least two subclasses, confirmInventoryItem()on its own raisesTypeError, and confirmtotal_valuecomputes correctly. - Build
Inventorywith nested categories at least two levels deep, and confirmfind_itemandprint_treeboth work recursively across every level. - Wire up
argparsesoadd,sell, andlistall work as real command-line invocations, not just function calls inside a script. - Trigger
ItemNotFoundErrorandOutOfStockErroron purpose, and confirm both are logged rather than crashing the whole program.
Recap
You’ve now modeled a genuinely nested data structure and processed it recursively, enforced a contract across item types with abc, and driven the whole thing from real command-line arguments instead of input(). That’s a noticeably more advanced shape of program than Challenge 1’s flat contact list.
Next challenge: a Web Data Collector, pulling in scraping, regular expressions, and context managers.