CodingNic

Capstone Challenges

Challenge 2: Command-Line Inventory Manager

Capstone Challenges 60 min read

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 ABC with name, quantity, and unit_price
  • A @property called total_value that returns quantity * unit_price
  • An @abstractmethod called category_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 name
  • OutOfStockError(Exception): raised when trying to sell more than the available quantity

Inventory

  • Stores items inside a nested category tree: a dictionary where each category has a list of items and a dictionary of subcategories:
python
{
    "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_path might be a list like ["Electronics", "Computers"]). For example, after inv.add_item(["Electronics", "Computers"], laptop) on an empty inventory, inv.tree should look like {"Electronics": {"items": [], "subcategories": {"Computers": {"items": [laptop], "subcategories": {}}}}}.
  • find_item(name): searches recursively through every category and subcategory, returning the item or raising ItemNotFoundError
  • sell_item(name, amount): finds the item, raises OutOfStockError if amount > item.quantity, otherwise reduces the quantity
  • print_tree(): recursively prints the whole category tree, indenting each level of nesting (see the sample output below)

Persistence and Logging

  • save(path) and load(path): save and load the whole tree to a JSON file, accepting a pathlib.Path (or plain string) filename
  • Use logging (not print()) to record every add, sell, and error at an appropriate level, for example logging.info() for a successful add or sale, and logging.error() when ItemNotFoundError or OutOfStockError is caught

Command-Line Interface

Use argparse so the tool is driven entirely from the command line, with no input() calls:

bash
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:

text
$ 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

python
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 sums total_value across 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 csv option to the list command, exporting the current flat list of items either way.

Try It

  1. Build InventoryItem and at least two subclasses, confirm InventoryItem() on its own raises TypeError, and confirm total_value computes correctly.
  2. Build Inventory with nested categories at least two levels deep, and confirm find_item and print_tree both work recursively across every level.
  3. Wire up argparse so add, sell, and list all work as real command-line invocations, not just function calls inside a script.
  4. Trigger ItemNotFoundError and OutOfStockError on 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.