commit c4e3cd3c37ee163f0be48a8290ef8cbcf334f313
parent 638708bbc373add37a3eb5238d83a25f7346c820
Author: Chris Roberts <chris.roberts@learningunix.net>
Date: Fri, 3 Jul 2026 06:00:15 -0500
Build initial bot: SQLite schema, CRUD, merge logic, and command handlers
Implements the SPEC.md initial scope: recipes/ingredients/staples/grocery
list tables with cascade delete, quantity-aware merge logic (sum on
matching name+unit, keep separate otherwise), and all seven bot commands.
Also drops the nut-free constraint (single-user household, not needed)
and pins python-telegram-bot to v22+ after finding v20.x is broken on
Python 3.14.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat:
12 files changed, 705 insertions(+), 17 deletions(-)
diff --git a/.env.example b/.env.example
@@ -0,0 +1,2 @@
+TELEGRAM_BOT_TOKEN=
+ANTON_DB_PATH=anton.db
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,7 @@
+.env
+*.db
+__pycache__/
+*.pyc
+.venv/
+venv/
+.pytest_cache/
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -6,22 +6,22 @@ A self-hosted Telegram bot for tracking recipes, managing a weekly staples
list, and generating grocery lists. Runs as a long-polling service in an
LXC/VM on Proxmox, alongside the rest of the homelab stack.
-## Dietary Constraints (hard requirements, not preferences)
+## Dietary Constraints
- **Ketogenic**: recipes/ingredients should be tagged `keto` automatically
based on carb content where determinable, but always allow manual override.
-- **Nut-free**: this is an allergy, not a preference. Any ingredient matching
- the nut list below must hard-block the recipe from being marked nut-free,
- and the bot should warn (not silently tag) when an ingredient is ambiguous
- (e.g. "may contain tree nuts").
- - Nut list: almond, walnut, pecan, cashew, pistachio, hazelnut, macadamia,
- brazil nut, pine nut, peanut (technically a legume but treat as a nut
- here). Keep this list in a single constant, not duplicated across files.
+
+Nut-free tracking was considered and deliberately dropped (2026-07-02):
+single-user household, user doesn't eat nuts, so nut-containing recipes are
+very unlikely to enter the system in the first place. Not worth the schema/
+logic complexity for this tool.
## Tech Stack
- Python 3.11+
-- `python-telegram-bot` (async, v20+) — long polling, no public webhook needed
+- `python-telegram-bot` (async, v22+) — long polling, no public webhook needed.
+ v20.x is broken on Python 3.14 (AttributeError building `Updater`,
+ confirmed 2026-07-03) — pin to v22+ if the system Python is 3.14.
- SQLite via `sqlite3` stdlib or `sqlmodel` if an ORM is wanted — keep it
simple, this doesn't need SQLAlchemy's full weight
- `systemd` service file for deployment (pattern-match the existing
@@ -29,7 +29,7 @@ LXC/VM on Proxmox, alongside the rest of the homelab stack.
## Data Model
-- `recipes(id, name, servings, instructions, source, is_keto, is_nut_free)`
+- `recipes(id, name, servings, instructions, source, is_keto)`
- `ingredients(id, recipe_id, name, quantity, unit, category)`
- `staples(id, name, default_quantity, unit, category)`
- `grocery_list_items(id, name, quantity, unit, category, source)`
@@ -73,7 +73,7 @@ LXC/VM on Proxmox, alongside the rest of the homelab stack.
- Web UI — SQLite backend should make this easy to bolt on later if wanted
- Multi-user support — this is a single-household tool
-- Nutrition/macro calculation beyond keto/nut-free tagging
+- Nutrition/macro calculation beyond keto tagging
## Testing
diff --git a/SPEC.md b/SPEC.md
@@ -3,8 +3,8 @@
## Overview
A Telegram bot, self-hosted on the homelab, for tracking recipes with
-dietary tags (keto, nut-free), maintaining a weekly staples list, and
-generating merged grocery lists on demand.
+dietary tags (keto), maintaining a weekly staples list, and generating
+merged grocery lists on demand.
## Why Telegram over Slack
@@ -57,10 +57,9 @@ See `CLAUDE.md` for full schema. Summary:
## Dietary Safety Notes
-Nut-free is treated as an allergy constraint, not a preference — the bot
-should never silently mark something nut-free if any ingredient is
-ambiguous. Keto tagging can be looser (auto-suggested, manually
-correctable).
+Nut-free tracking was dropped as a requirement (single-user household,
+user doesn't eat nuts, so nut recipes are unlikely to enter the system).
+Keto tagging is auto-suggested and manually correctable.
## Future Ideas (not in initial build)
diff --git a/anton-bot.service b/anton-bot.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=Anton Recipe & Grocery Telegram Bot
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+# Placeholders below -- adjust to match your actual LXC layout. Not
+# verified against your existing homelab systemd unit conventions.
+User=anton
+WorkingDirectory=/opt/anton
+EnvironmentFile=/opt/anton/.env
+ExecStart=/opt/anton/venv/bin/python /opt/anton/bot.py
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
diff --git a/bot.py b/bot.py
@@ -0,0 +1,239 @@
+"""Telegram bot entrypoint: command handlers wired to db.py / parsing.py."""
+from __future__ import annotations
+
+import logging
+import os
+import random
+
+from dotenv import load_dotenv
+from telegram import Update
+from telegram.ext import Application, CommandHandler, ContextTypes
+
+import db
+import parsing
+from models import get_connection, init_db
+
+load_dotenv()
+
+TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+ADDRECIPE_HELP = (
+ "Paste a recipe after /addrecipe, e.g.:\n\n"
+ "Name: Beef Chili\n"
+ "Servings: 4\n"
+ "Source: https://example.com\n"
+ "Keto: yes\n"
+ "Ingredients:\n"
+ "1 | lb | ground beef | meat\n"
+ "1 | cup | diced tomatoes | pantry\n"
+ "Instructions:\n"
+ "Brown the beef, add tomatoes, simmer.\n\n"
+ "Keto: is optional -- omit it to auto-suggest from ingredients."
+)
+
+
+def _conn():
+ conn = get_connection()
+ init_db(conn)
+ return conn
+
+
+async def addrecipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ _, _, body = update.message.text.partition("\n")
+ if not body.strip():
+ await update.message.reply_text(ADDRECIPE_HELP)
+ return
+
+ try:
+ parsed = parsing.parse_recipe_text(body)
+ except ValueError as exc:
+ await update.message.reply_text(f"Couldn't parse that recipe: {exc}")
+ return
+
+ conn = _conn()
+ try:
+ recipe_id = db.add_recipe(
+ conn,
+ name=parsed["name"],
+ servings=parsed["servings"],
+ instructions=parsed["instructions"],
+ source=parsed["source"],
+ is_keto=parsed["is_keto"],
+ )
+ for ing in parsed["ingredients"]:
+ db.add_ingredient(
+ conn,
+ recipe_id=recipe_id,
+ name=ing["name"],
+ quantity=ing["quantity"],
+ unit=ing["unit"],
+ category=ing["category"],
+ )
+ finally:
+ conn.close()
+
+ keto_note = "keto" if parsed["is_keto"] else "not flagged keto"
+ await update.message.reply_text(
+ f"Added '{parsed['name']}' with {len(parsed['ingredients'])} "
+ f"ingredient(s) ({keto_note})."
+ )
+
+
+async def recipes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ tag = context.args[0] if context.args else None
+ conn = _conn()
+ try:
+ rows = db.list_recipes(conn, tag=tag)
+ finally:
+ conn.close()
+
+ if not rows:
+ await update.message.reply_text("No recipes found.")
+ return
+
+ lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows]
+ await update.message.reply_text("\n".join(lines))
+
+
+async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ conn = _conn()
+ try:
+ if context.args and context.args[0].lower() == "add":
+ name = " ".join(context.args[1:]).strip()
+ if not name:
+ await update.message.reply_text("Usage: /staples add <item>")
+ return
+ db.add_staple(conn, name=name, default_quantity=None, unit=None, category=None)
+ await update.message.reply_text(f"Added staple: {name}")
+ return
+
+ if context.args and context.args[0].lower() == "remove":
+ name = " ".join(context.args[1:]).strip()
+ if not name:
+ await update.message.reply_text("Usage: /staples remove <item>")
+ return
+ db.remove_staple(conn, name)
+ await update.message.reply_text(f"Removed staple: {name}")
+ return
+
+ rows = db.list_staples(conn)
+ finally:
+ conn.close()
+
+ if not rows:
+ await update.message.reply_text("No staples set.")
+ return
+
+ lines = [f"- {r['name']}" for r in rows]
+ await update.message.reply_text("\n".join(lines))
+
+
+async def grocerylist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ _, _, rest = update.message.text.partition(" ")
+ recipe_names = [n.strip() for n in rest.split(",") if n.strip()]
+
+ conn = _conn()
+ try:
+ items = [
+ parsing.Item(
+ name=s["name"], quantity=s["default_quantity"],
+ unit=s["unit"], category=s["category"], source="staple",
+ )
+ for s in db.list_staples(conn)
+ ]
+
+ not_found = []
+ for rn in recipe_names:
+ recipe = db.get_recipe_by_name(conn, rn)
+ if not recipe:
+ not_found.append(rn)
+ continue
+ for ing in db.list_ingredients_for_recipe(conn, recipe["id"]):
+ items.append(parsing.Item(
+ name=ing["name"], quantity=ing["quantity"],
+ unit=ing["unit"], category=ing["category"], source="recipe",
+ ))
+
+ merged = parsing.merge_ingredients(items)
+
+ db.clear_grocery_list(conn)
+ for item in merged:
+ db.add_grocery_item(
+ conn, name=item.name, quantity=item.quantity,
+ unit=item.unit, category=item.category, source=item.source,
+ )
+ finally:
+ conn.close()
+
+ lines = [f"- {(item.quantity or '')} {(item.unit or '')} {item.name}".strip() for item in merged]
+ reply = "\n".join(lines) if lines else "Nothing to list."
+ if not_found:
+ reply += f"\n\n(Recipe(s) not found: {', '.join(not_found)})"
+ await update.message.reply_text(reply)
+
+
+async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ _, _, item_name = update.message.text.partition(" ")
+ item_name = item_name.strip()
+ if not item_name:
+ await update.message.reply_text("Usage: /add <item>")
+ return
+
+ conn = _conn()
+ try:
+ db.add_grocery_item(
+ conn, name=item_name, quantity=None, unit=None,
+ category=None, source="freeform",
+ )
+ finally:
+ conn.close()
+ await update.message.reply_text(f"Added '{item_name}' to the grocery list.")
+
+
+async def clearlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ conn = _conn()
+ try:
+ db.clear_grocery_list(conn)
+ finally:
+ conn.close()
+ await update.message.reply_text("Grocery list cleared.")
+
+
+async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ tag = context.args[0] if context.args else None
+ conn = _conn()
+ try:
+ rows = db.list_recipes(conn, tag=tag)
+ finally:
+ conn.close()
+
+ if not rows:
+ await update.message.reply_text("No recipes to choose from.")
+ return
+
+ choice = random.choice(rows)
+ await update.message.reply_text(f"How about: {choice['name']}?")
+
+
+def main() -> None:
+ conn = _conn()
+ conn.close()
+
+ app = Application.builder().token(TOKEN).build()
+ app.add_handler(CommandHandler("addrecipe", addrecipe_cmd))
+ app.add_handler(CommandHandler("recipes", recipes_cmd))
+ app.add_handler(CommandHandler("staples", staples_cmd))
+ app.add_handler(CommandHandler("grocerylist", grocerylist_cmd))
+ app.add_handler(CommandHandler("add", add_cmd))
+ app.add_handler(CommandHandler("clearlist", clearlist_cmd))
+ app.add_handler(CommandHandler("random", random_cmd))
+
+ logger.info("Starting bot (long polling)...")
+ app.run_polling()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/db.py b/db.py
@@ -0,0 +1,103 @@
+"""CRUD functions for recipes, ingredients, staples, and grocery list items."""
+from __future__ import annotations
+
+import sqlite3
+
+
+# --- recipes ---
+
+def add_recipe(conn, name, servings, instructions, source, is_keto) -> int:
+ cur = conn.execute(
+ "INSERT INTO recipes (name, servings, instructions, source, is_keto) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (name, servings, instructions, source, int(is_keto)),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def get_recipe(conn, recipe_id: int) -> sqlite3.Row | None:
+ return conn.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
+
+
+def get_recipe_by_name(conn, name: str) -> sqlite3.Row | None:
+ return conn.execute(
+ "SELECT * FROM recipes WHERE name = ? COLLATE NOCASE", (name,)
+ ).fetchone()
+
+
+def list_recipes(conn, tag: str | None = None) -> list[sqlite3.Row]:
+ if tag == "keto":
+ return conn.execute("SELECT * FROM recipes WHERE is_keto = 1").fetchall()
+ return conn.execute("SELECT * FROM recipes").fetchall()
+
+
+def delete_recipe(conn, recipe_id: int) -> None:
+ # Ingredients cascade-delete automatically (see models.py schema / PRAGMA).
+ conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
+ conn.commit()
+
+
+# --- ingredients ---
+
+def add_ingredient(conn, recipe_id, name, quantity, unit, category) -> int:
+ cur = conn.execute(
+ "INSERT INTO ingredients (recipe_id, name, quantity, unit, category) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (recipe_id, name, quantity, unit, category),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def list_ingredients_for_recipe(conn, recipe_id: int) -> list[sqlite3.Row]:
+ return conn.execute(
+ "SELECT * FROM ingredients WHERE recipe_id = ?", (recipe_id,)
+ ).fetchall()
+
+
+# --- staples ---
+
+def add_staple(conn, name, default_quantity, unit, category) -> int:
+ cur = conn.execute(
+ "INSERT INTO staples (name, default_quantity, unit, category) "
+ "VALUES (?, ?, ?, ?) "
+ "ON CONFLICT(name) DO UPDATE SET "
+ "default_quantity=excluded.default_quantity, "
+ "unit=excluded.unit, category=excluded.category",
+ (name, default_quantity, unit, category),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def remove_staple(conn, name: str) -> None:
+ conn.execute("DELETE FROM staples WHERE name = ? COLLATE NOCASE", (name,))
+ conn.commit()
+
+
+def list_staples(conn) -> list[sqlite3.Row]:
+ return conn.execute("SELECT * FROM staples ORDER BY category, name").fetchall()
+
+
+# --- grocery list ---
+
+def add_grocery_item(conn, name, quantity, unit, category, source) -> int:
+ cur = conn.execute(
+ "INSERT INTO grocery_list_items (name, quantity, unit, category, source) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (name, quantity, unit, category, source),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def list_grocery_items(conn) -> list[sqlite3.Row]:
+ return conn.execute(
+ "SELECT * FROM grocery_list_items ORDER BY category, name"
+ ).fetchall()
+
+
+def clear_grocery_list(conn) -> None:
+ conn.execute("DELETE FROM grocery_list_items")
+ conn.commit()
diff --git a/models.py b/models.py
@@ -0,0 +1,56 @@
+"""SQLite connection handling and schema definition."""
+import os
+import sqlite3
+
+DB_PATH = os.environ.get("ANTON_DB_PATH", "anton.db")
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS recipes (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ servings INTEGER,
+ instructions TEXT,
+ source TEXT,
+ is_keto INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS ingredients (
+ id INTEGER PRIMARY KEY,
+ recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ quantity TEXT,
+ unit TEXT,
+ category TEXT
+);
+
+CREATE TABLE IF NOT EXISTS staples (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ default_quantity TEXT,
+ unit TEXT,
+ category TEXT
+);
+
+CREATE TABLE IF NOT EXISTS grocery_list_items (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ quantity TEXT,
+ unit TEXT,
+ category TEXT,
+ source TEXT NOT NULL CHECK (source IN ('staple', 'recipe', 'freeform'))
+);
+"""
+
+
+def get_connection(db_path: str = DB_PATH) -> sqlite3.Connection:
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ # SQLite parses the "REFERENCES ... ON DELETE CASCADE" in the schema but
+ # does NOT enforce foreign keys by default -- must opt in per connection.
+ conn.execute("PRAGMA foreign_keys = ON")
+ return conn
+
+
+def init_db(conn: sqlite3.Connection) -> None:
+ conn.executescript(SCHEMA)
+ conn.commit()
diff --git a/parsing.py b/parsing.py
@@ -0,0 +1,173 @@
+"""Quantity parsing, ingredient merging, and /addrecipe text parsing."""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from fractions import Fraction
+
+# Rough keyword heuristic for keto auto-suggestion -- there's no nutrition
+# database backing this, it's just a carb-keyword check. Always manually
+# overridable via the "Keto:" field in /addrecipe.
+HIGH_CARB_KEYWORDS = {
+ "sugar", "flour", "rice", "pasta", "bread", "potato", "corn", "oats",
+ "honey", "syrup",
+}
+
+
+def parse_quantity(raw: str | None) -> Fraction | None:
+ """Parse '1', '1/2', or '1 1/2' into a Fraction.
+
+ Returns None for anything that isn't a plain number/fraction (e.g.
+ "a pinch", "to taste") -- callers must treat those as non-mergeable
+ rather than guessing a value.
+ """
+ if not raw:
+ return None
+ raw = raw.strip()
+ if not raw:
+ return None
+
+ total = Fraction(0)
+ matched_any = False
+ for part in raw.split():
+ if re.fullmatch(r"\d+/\d+", part):
+ num, den = part.split("/")
+ total += Fraction(int(num), int(den))
+ matched_any = True
+ elif re.fullmatch(r"\d+(\.\d+)?", part):
+ total += Fraction(part)
+ matched_any = True
+ else:
+ return None
+ return total if matched_any else None
+
+
+def format_quantity(value: Fraction) -> str:
+ """Format a Fraction back to display form, e.g. Fraction(3, 2) -> '1 1/2'."""
+ whole, rem = divmod(value.numerator, value.denominator)
+ if rem == 0:
+ return str(whole)
+ if whole == 0:
+ return f"{rem}/{value.denominator}"
+ return f"{whole} {rem}/{value.denominator}"
+
+
+@dataclass
+class Item:
+ name: str
+ quantity: str | None
+ unit: str | None
+ category: str | None
+ source: str # 'staple' | 'recipe' | 'freeform'
+
+
+def merge_ingredients(items: list[Item]) -> list[Item]:
+ """Merge items by (normalized name, unit).
+
+ Matching name+unit pairs have their quantities summed when both parse
+ as numbers. Mismatched units, or quantities that don't parse (e.g. "a
+ pinch"), are kept as separate line items rather than guessing a
+ conversion -- per CLAUDE.md's merge rules.
+ """
+ merged: dict[tuple[str, str | None], Item] = {}
+ passthrough: list[Item] = []
+
+ for item in items:
+ key_name = item.name.strip().lower()
+ key_unit = (item.unit or "").strip().lower() or None
+ qty = parse_quantity(item.quantity)
+
+ if qty is None:
+ passthrough.append(item)
+ continue
+
+ key = (key_name, key_unit)
+ if key in merged:
+ existing = merged[key]
+ existing_qty = parse_quantity(existing.quantity)
+ merged[key] = Item(
+ name=existing.name,
+ quantity=format_quantity(existing_qty + qty),
+ unit=existing.unit,
+ category=existing.category,
+ source=existing.source,
+ )
+ else:
+ merged[key] = item
+
+ return list(merged.values()) + passthrough
+
+
+def suggest_keto(ingredient_names: list[str]) -> bool:
+ """Flag False if any ingredient name contains a high-carb keyword."""
+ for name in ingredient_names:
+ lname = name.lower()
+ if any(keyword in lname for keyword in HIGH_CARB_KEYWORDS):
+ return False
+ return True
+
+
+def parse_recipe_text(text: str) -> dict:
+ """Parse the structured text format expected after /addrecipe:
+
+ Name: <name>
+ Servings: <n>
+ Source: <text or url>
+ Keto: yes|no (optional -- omit to auto-suggest)
+ Ingredients:
+ <quantity> | <unit> | <name> | <category>
+ ...
+ Instructions:
+ <free text, one or more lines>
+ """
+ fields = {"name": None, "servings": None, "source": None, "is_keto": None}
+ ingredients: list[dict] = []
+ instructions_lines: list[str] = []
+ section = None
+
+ for line in text.splitlines():
+ stripped = line.strip()
+ lower = stripped.lower()
+
+ if lower.startswith("name:"):
+ fields["name"] = stripped.split(":", 1)[1].strip()
+ elif lower.startswith("servings:"):
+ value = stripped.split(":", 1)[1].strip()
+ fields["servings"] = int(value) if value.isdigit() else None
+ elif lower.startswith("source:"):
+ fields["source"] = stripped.split(":", 1)[1].strip()
+ elif lower.startswith("keto:"):
+ value = stripped.split(":", 1)[1].strip().lower()
+ fields["is_keto"] = value in ("yes", "true", "1")
+ elif lower.startswith("ingredients:"):
+ section = "ingredients"
+ elif lower.startswith("instructions:"):
+ section = "instructions"
+ elif section == "ingredients" and stripped:
+ parts = [p.strip() for p in stripped.split("|")]
+ parts += [None] * (4 - len(parts))
+ quantity, unit, name, category = parts[:4]
+ ingredients.append({
+ "quantity": quantity or None,
+ "unit": unit or None,
+ "name": name,
+ "category": category or None,
+ })
+ elif section == "instructions" and stripped:
+ instructions_lines.append(stripped)
+
+ if not fields["name"]:
+ raise ValueError("recipe text must include a 'Name:' line")
+
+ is_keto = fields["is_keto"]
+ if is_keto is None:
+ is_keto = suggest_keto([i["name"] for i in ingredients if i["name"]])
+
+ return {
+ "name": fields["name"],
+ "servings": fields["servings"],
+ "source": fields["source"],
+ "is_keto": is_keto,
+ "ingredients": ingredients,
+ "instructions": "\n".join(instructions_lines),
+ }
diff --git a/pytest.ini b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+pythonpath = .
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,3 @@
+python-telegram-bot>=22,<23
+python-dotenv
+pytest
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
@@ -0,0 +1,84 @@
+from fractions import Fraction
+
+from parsing import Item, merge_ingredients, parse_quantity, parse_recipe_text, suggest_keto
+
+
+def test_parse_quantity_whole_number():
+ assert parse_quantity("2") == Fraction(2)
+
+
+def test_parse_quantity_fraction():
+ assert parse_quantity("1/2") == Fraction(1, 2)
+
+
+def test_parse_quantity_mixed_number():
+ assert parse_quantity("1 1/2") == Fraction(3, 2)
+
+
+def test_parse_quantity_unparseable_returns_none():
+ assert parse_quantity("a pinch") is None
+
+
+def test_merge_sums_matching_name_and_unit():
+ items = [
+ Item(name="Milk", quantity="1", unit="cup", category="dairy", source="staple"),
+ Item(name="milk", quantity="1", unit="cup", category="dairy", source="recipe"),
+ ]
+ merged = merge_ingredients(items)
+ assert len(merged) == 1
+ assert merged[0].quantity == "2"
+
+
+def test_merge_keeps_mismatched_units_separate():
+ items = [
+ Item(name="Flour", quantity="1", unit="cup", category="pantry", source="staple"),
+ Item(name="Flour", quantity="200", unit="g", category="pantry", source="recipe"),
+ ]
+ merged = merge_ingredients(items)
+ assert len(merged) == 2
+
+
+def test_merge_keeps_unparseable_quantities_separate():
+ items = [
+ Item(name="Salt", quantity="a pinch", unit=None, category="pantry", source="recipe"),
+ Item(name="Salt", quantity="a pinch", unit=None, category="pantry", source="recipe"),
+ ]
+ merged = merge_ingredients(items)
+ assert len(merged) == 2
+
+
+def test_parse_recipe_text_basic():
+ text = (
+ "Name: Test Chili\n"
+ "Servings: 4\n"
+ "Source: test\n"
+ "Keto: yes\n"
+ "Ingredients:\n"
+ "1 | lb | ground beef | meat\n"
+ "1 | cup | diced tomatoes | pantry\n"
+ "Instructions:\n"
+ "Brown the beef.\n"
+ "Simmer.\n"
+ )
+ parsed = parse_recipe_text(text)
+ assert parsed["name"] == "Test Chili"
+ assert parsed["servings"] == 4
+ assert parsed["is_keto"] is True
+ assert len(parsed["ingredients"]) == 2
+ assert "Brown the beef." in parsed["instructions"]
+
+
+def test_parse_recipe_text_missing_name_raises():
+ try:
+ parse_recipe_text("Servings: 2\n")
+ assert False, "expected ValueError"
+ except ValueError:
+ pass
+
+
+def test_suggest_keto_flags_high_carb_ingredient():
+ assert suggest_keto(["ground beef", "flour"]) is False
+
+
+def test_suggest_keto_true_when_no_high_carb_keywords():
+ assert suggest_keto(["ground beef", "cheese"]) is True