Skip to content

CLI Recipe Management App

Python Development with OOP and ORMs

Building a command-line interface (CLI) application can be a rewarding way to automate tasks or manage data effectively. In this blog, we’ll explore a Python-based Recipe Management CLI app that demonstrates the power of object-oriented programming (OOP) and object-relational mappers (ORMs). This app manages recipes, categories, and their relationships.

Why OOP and ORMs Shine in CLI Development

Object-Oriented Programming (OOP)

OOP enables us to structure our code in a way that mirrors real-world objects. For example, in our recipe app:

  • Categories represent groups of recipes (e.g., Breakfast, Desserts).
  • Recipes are individual items with properties like name and ingredients.

Each of these concepts can be encapsulated as a class, providing clarity and maintainability:

  • Encapsulation: Groups related data and functionality, making code easier to debug and extend.
  • Reusability: Classes can be reused in other parts of the app or even in other projects.
  • Abstraction: Hides internal implementation details, exposing only what's necessary.

A Look Under the Hood: The Recipe Management CLI

This application includes the following key features:

  • Adding, updating, deleting, and viewing recipes.
  • Organizing recipes into categories.
  • Persisting data with SQLite.

Here’s how I built it step by step.

The CLI Flow

At the heart of the app is cli.py, which manages user input and navigates between menus. Each menu corresponds to specific operations, like managing categories or viewing recipe details.

def menu_main():
    print("MAIN MENU")
    print("Please choose an option:")
    print("0. Exit program")
    print("1. View recipes")

    choice = input("> ")
    if choice == "0":
        exit_program()
    elif choice == "1":
        menu_categories()

The menu system allows users to seamlessly transition between viewing, adding, or updating recipes. This modular approach keeps the CLI user-friendly and code maintainable.

As an added readability feature, I wrote a utility method to clear the console, so it doesn't get cluttered with old menus.

def clear_console():
    # for windows
    if name == "nt":
        _ = system("cls")

    # for mac and linux (here, os.name is 'posix')
    else:
        _ = system("clear")
Note

This console clearing functionality doesn't work in some IDE terminals.

Organizing with OOP: Category and Recipe

OOP shines in our implementation of Category and Recipe. Each class encapsulates the data and operations relevant to its domain.

The Category Class

The Category class represents recipe groups. It manages its recipes, saves them to the database, and retrieves them when needed.

category.py
from typing import List


class Category:
    connection = None

    def __init__(self, name, recipes=None, category_id: int = -1):
        if recipes is None:
            recipes = []
        self._name = name
        self._recipes = recipes
        self._id = category_id

    @staticmethod
    def set_connection(connection):
        Category.connection = connection

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value: str):
        if not isinstance(value, str):
            raise TypeError("name must be a string")
        self._name = value

    @property
    def recipes(self):
        return self._recipes

    @recipes.setter
    def recipes(self, value: list):
        if not isinstance(value, list):
            raise TypeError("recipes must be a list")
        self._recipes = value

    @property
    def id(self):
        return self._id

    @id.setter
    def id(self, value: int):
        if not isinstance(value, int) or value < 0:
            raise TypeError("id must be a integer and greater than 0")
        self._id = value

    def add_recipe(self, recipe):
        from recipe import Recipe
        if not isinstance(recipe, Recipe):
            raise TypeError("recipe must be a Recipe")
        self._recipes.append(recipe)

    def remove_recipe(self, recipe):
        from recipe import Recipe
        if not isinstance(recipe, Recipe):
            raise TypeError("recipe must be a Recipe")
        self._recipes.remove(recipe)

    def remove_recipe_by_name(self, name):
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        self._recipes = [recipe for recipe in self._recipes if
                         recipe.name != name]

    def get_associated_recipes(self):
        """
        Retrieves all recipes associated with this category from the database.
        """
        if not Category.connection:
            raise ConnectionError("Database connection is not set.")

        cursor = Category.connection.cursor()
        cursor.execute(
            "SELECT id, name, ingredients FROM recipes WHERE category_id = (SELECT id FROM categories WHERE name = ?)",
            (self._name,)
        )
        rows = cursor.fetchall()

        from recipe import Recipe
        self._recipes = [Recipe(name=row[1], ingredients=row[2], category=self)
                         for row in rows]
        return self._recipes

    def save(self):
        """
        Saves the category to the database. If the category already exists, it updates it.
        Additionally, saves all associated recipes to the database.
        """
        if not Category.connection:
            raise ConnectionError("Database connection is not set.")

        cursor = Category.connection.cursor()

        # Save the category
        cursor.execute(
            "INSERT OR IGNORE INTO categories (name) VALUES (?)",
            (self._name,)
        )
        # Fetch the category ID (after insertion or if it already exists)
        cursor.execute("SELECT id FROM categories WHERE name = ?",
                       (self._name,))
        category_id = cursor.fetchone()[0]

        # Save each associated recipe
        from recipe import Recipe
        for recipe in self._recipes:
            if not isinstance(recipe, Recipe):
                raise TypeError(
                    "All associated recipes must be instances of Recipe.")

            cursor.execute(
                """
                INSERT OR REPLACE INTO recipes (name, ingredients, category_id)
                VALUES (?, ?, ?)
                """,
                (recipe.name, recipe.ingredients, category_id)
            )

        # Commit the changes to the database
        Category.connection.commit()

    def delete(self):
        """
        Deletes the category from the database.
        """
        Category.delete_by_name(self._name)

    @staticmethod
    def get_all():
        """
        Retrieves all categories from the database along with their associated recipes.
        """
        if not Category.connection:
            raise ConnectionError("Database connection is not set.")

        cursor = Category.connection.cursor()

        # Fetch all categories
        cursor.execute("SELECT id, name FROM categories")
        category_rows = cursor.fetchall()

        categories: List[Category] = []
        for category_row in category_rows:
            category: Category = Category._fetch_category_with_recipes(
                category_id=category_row[0])
            categories.append(category)

        return categories

    @staticmethod
    def get_by_name(category_name: str):
        """
        Retrieves a category by its name from the database, along with its associated recipes.
        """
        if not isinstance(category_name, str):
            raise TypeError("category_name must be a string")

        return Category._fetch_category_with_recipes(
            category_name=category_name)

    @staticmethod
    def get_by_id(category_id: int):
        """
        Retrieves a category by its ID from the database, along with its associated recipes.
        """
        if not isinstance(category_id, int):
            raise TypeError("category_id must be an integer")

        return Category._fetch_category_with_recipes(category_id=category_id)

    @staticmethod
    def delete_by_name(category_name: str):
        """
        Deletes a category by its name and all its associated recipes from the database.
        """
        if not Category.connection:
            raise ConnectionError("Database connection is not set.")

        if not isinstance(category_name, str):
            raise TypeError("category_name must be a string")

        cursor = Category.connection.cursor()

        # Fetch the category by name to get its ID
        cursor.execute("SELECT id FROM categories WHERE name = ?",
                       (category_name,))
        category_row = cursor.fetchone()

        if not category_row:
            raise ValueError("Category not found")

        category_id = category_row[0]

        # Delete all associated recipes
        cursor.execute("DELETE FROM recipes WHERE category_id = ?",
                       (category_id,))

        # Delete the category
        cursor.execute("DELETE FROM categories WHERE id = ?", (category_id,))

        # Commit the changes to the database
        Category.connection.commit()

    @staticmethod
    def _fetch_category_with_recipes(category_id: int = None,
                                     category_name: str = None):
        """
        Fetches a category by its ID or name along with its associated recipes.
        Either category_id or category_name must be provided.
        """
        if not Category.connection:
            raise ConnectionError("Database connection is not set.")

        if category_id is None and category_name is None:
            raise ValueError(
                "Either category_id or category_name must be provided")

        cursor = Category.connection.cursor()

        # Fetch the category based on provided argument
        if category_id:
            cursor.execute("SELECT id, name FROM categories WHERE id = ?",
                           (category_id,))
        else:
            cursor.execute("SELECT id, name FROM categories WHERE name = ?",
                           (category_name,))

        category_row = cursor.fetchone()
        if not category_row:
            return None  # Category not found

        category_id, category_name = category_row

        # Fetch associated recipes
        cursor.execute(
            "SELECT id, name, ingredients FROM recipes WHERE category_id = ?",
            (category_id,)
        )
        recipe_rows = cursor.fetchall()

        from recipe import Recipe

        # Create Recipe objects for each associated recipe
        recipes: List[Recipe] = [
            Recipe(name=row[1], ingredients=row[2], category=None) for row in
            recipe_rows
        ]

        # Create the Category object
        category: Category = Category(name=category_name, recipes=recipes,
                                      category_id=category_id)

        # Update each recipe's `category` reference to the new Category object
        for recipe in recipes:
            recipe.category = category

        return category

The Recipe Class

The Recipe class models individual recipes. It interacts with its associated category and encapsulates operations like saving and deleting.

recipe.py
class Recipe:
    connection = None

    def __init__(self, name: str, ingredients: str, category,
                 recipe_id: int = -1):
        self._name = name
        self._ingredients = ingredients
        self._category = category
        self._id = recipe_id

    @staticmethod
    def set_connection(connection):
        Recipe.connection = connection

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("name must be a string")
        self._name = value

    @property
    def ingredients(self):
        return self._ingredients

    @ingredients.setter
    def ingredients(self, value):
        if not isinstance(value, str):
            raise TypeError("ingredients must be a string")
        self._ingredients = value

    @property
    def category(self):
        return self._category

    @category.setter
    def category(self, value):
        from category import Category
        if not isinstance(value, Category):
            raise TypeError("category must be a Category")
        self._category = value

    @property
    def id(self):
        return self._id

    @id.setter
    def id(self, value):
        if not isinstance(value, int) or value < 0:
            raise TypeError("id must be a int greater than 0")
        self._id = value

    def save(self):
        """
        Saves the recipe to the database. If the recipe already exists, it updates it.
        """
        if not Recipe.connection:
            raise ConnectionError("Database connection is not set.")

        cursor = Recipe.connection.cursor()

        # Insert or update the recipe
        cursor.execute(
            """
            INSERT OR REPLACE INTO recipes (name, ingredients, category_id)
            VALUES (?, ?, ?)
            """,
            (self._name, self._ingredients, self._category.id)
        )

        # Commit the changes to the database
        Recipe.connection.commit()

    def delete(self):
        """
        Deletes the recipe from the database.
        """
        if not Recipe.connection:
            raise ConnectionError("Database connection is not set.")

        cursor = Recipe.connection.cursor()

        # Delete the recipe by its name
        cursor.execute("DELETE FROM recipes WHERE name = ?", (self._name,))

        # Commit the changes to the database
        Recipe.connection.commit()

Both classes interact seamlessly with SQLite using a shared database connection.

Persisting Data with SQLite and ORMs

To store recipes and categories, we use SQLite. While raw SQL queries could suffice, integrating ORM-like behavior in our classes improves scalability and readability. The save and delete methods in Category and Recipe handle database interactions directly, abstracting away SQL details.

Creating Tables

Before performing operations, we ensure the database schema exists. The create_tables function handles this during app initialization:

def create_tables(connection):
    connection.execute("""
        CREATE TABLE IF NOT EXISTS categories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE
        );
    """)
    connection.execute("""
        CREATE TABLE IF NOT EXISTS recipes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            ingredients TEXT NOT NULL,
            category_id INTEGER,
            FOREIGN KEY (category_id) REFERENCES categories(id)
        );
    """)
    connection.commit()

Querying and Persisting Data

Saving a category involves not just storing its details but also associating it with recipes:

def save(self):
    cursor = Category.connection.cursor()
    cursor.execute("INSERT OR IGNORE INTO categories (name) VALUES (?)",
                   (self.name,))
    for recipe in self.recipes:
        cursor.execute("""
            INSERT OR REPLACE INTO recipes (name, ingredients, category_id)
            VALUES (?, ?, ?)
        """, (recipe.name, recipe.ingredients, self.id))
    Category.connection.commit()

Interactions Made Easy with Menus

The CLI handles user input elegantly, allowing users to interact with categories and recipes without directly dealing with database commands. For instance, adding a recipe is as simple as:

def menu_add_recipe(category=None):
    new_name = input("Enter recipe name: ")
    new_ingredients = input("Enter ingredients: ")
    new_category = category or Category(input("Enter category name: "))

    recipe = Recipe(new_name, new_ingredients, new_category)
    new_category.add_recipe(recipe)
    new_category.save()

The app automatically saves the new recipe and its associated category, abstracting away the database complexity.

The Power of Abstraction

OOP and ORM principles elevate the app’s structure and functionality:

  • Separation of Concerns: CLI menus handle user interactions, while the
  • Category and Recipe classes focus on business logic.
  • Reusability: The modular design makes it easy to extend functionality, like adding a search feature or exporting recipes.
  • Scalability: Adding more entities, like tags or ratings, would require minimal changes.

Conclusion

The Recipe Management CLI app demonstrates how Python’s OOP features and database integration simplify even complex tasks. By using classes to encapsulate behavior and SQLite for persistence, we’ve built an app that’s easy to use, extend, and maintain. Whether you're managing recipes or tackling a more ambitious project, these principles will serve you well.