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.