import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import pandas as pd
import re
import os
import requests
from bs4 import BeautifulSoup
import webbrowser
from io import StringIO
# -------------------------
# Configuration & Helpers
# -------------------------
PRICE_DB = "prices.csv" # local prices DB: columns -> item,price,unit (unit: kg or piece)
DEFAULT_PRICE_DATA = """item,price,unit
flour,40,kg
sugar,55,kg
milk,60,l
egg,6,piece
butter,450,kg
salt,20,kg
olive oil,600,litre
rice,60,kg
onion,40,kg
garlic,400,kg
"""
# Unit conversions to grams or ml (approximate / generic)
# you can refine per-ingredient or add density based conversions
UNIT_TO_GRAMS = {
"g": 1,
"gram": 1,
"grams": 1,
"kg": 1000,
"kilogram": 1000,
"kilograms": 1000,
"mg": 0.001,
"lb": 453.592,
"oz": 28.3495,
# common kitchen volume to grams approximations (generic)
"cup": 240, # ml ~ grams for water-like density; adapt per-ingredient
"cups": 240,
"tbsp": 15,
"tablespoon": 15,
"tsp": 5,
"teaspoon": 5,
"ml": 1, # ml ~ grams for water-like density
"l": 1000,
"litre": 1000,
"liter": 1000,
"piece": None, # piece handled separately
"pcs": None,
"pc": None
}
# Normalize function
def normalize_item_name(name):
return re.sub(r'[^a-z0-9 ]', '', name.lower()).strip()
# -------------------------
# Price DB functions
# -------------------------
def ensure_price_db_exists():
if not os.path.exists(PRICE_DB):
with open(PRICE_DB, "w", encoding="utf-8") as f:
f.write(DEFAULT_PRICE_DATA)
def load_price_db():
ensure_price_db_exists()
df = pd.read_csv(PRICE_DB)
# normalize item column to match lookups
df['item_norm'] = df['item'].apply(lambda x: normalize_item_name(str(x)))
return df
def save_price_db(df):
df = df.copy()
if 'item_norm' in df.columns:
df = df.drop(columns=['item_norm'])
df.to_csv(PRICE_DB, index=False)
# -------------------------
# Parsing ingredient lines
# -------------------------
# Accept lines like:
# 2 cups flour
# 150 g sugar
# 1.5 tbsp olive oil
# 3 eggs
ING_LINE_REGEX = re.compile(r'^\s*(?P<qty>[\d/.]+)\s*(?P<unit>[a-zA-Z]+)?\s*(?P<item>.+)$')
def parse_quantity(q):
"""Parse numeric quantities (support fractions like 1/2)."""
try:
if '/' in q:
parts = q.split('/')
if len(parts) == 2:
return float(parts[0]) / float(parts[1])
return float(q)
except Exception:
return None
def parse_ingredient_line(line):
m = ING_LINE_REGEX.match(line)
if not m:
return None
qty_raw = m.group('qty')
unit = m.group('unit') or ""
item = m.group('item')
qty = parse_quantity(qty_raw)
if qty is None:
return None
return {"qty": qty, "unit": unit.lower(), "item": item.strip()}
# -------------------------
# Cost calculation
# -------------------------
def compute_cost_for_row(row, prices_df):
"""
row: dict with keys qty, unit, item
prices_df: DataFrame with columns item, price, unit
returns (cost, price_used, reason)
"""
item_norm = normalize_item_name(row['item'])
# find best match in prices
match = prices_df[prices_df['item_norm'] == item_norm]
if match.empty:
# try partial matching (contains)
match = prices_df[prices_df['item_norm'].str.contains(item_norm.split()[0])]
if not match.empty:
# pick first match
m = match.iloc[0]
price = float(m['price'])
price_unit = str(m['unit']).lower()
# If price_unit is kg, convert qty/unit to kg
if row['unit'] in UNIT_TO_GRAMS and UNIT_TO_GRAMS[row['unit']] is not None:
grams = row['qty'] * UNIT_TO_GRAMS[row['unit']]
if price_unit in ('kg', 'kilogram', 'kilograms'):
kg = grams / 1000.0
cost = kg * price
return cost, price, f"price per kg ({price_unit})"
elif price_unit in ('l','litre','liter','ml'):
# assume ml ~ grams for simplicity
liters = grams / 1000.0
cost = liters * price
return cost, price, f"price per litre ({price_unit})"
elif price_unit in ('g','gram','grams'):
cost = (grams/1.0) * price
return cost, price, f"price per gram ({price_unit})"
else:
# unknown price unit, fallback
return None, price, f"unknown price unit: {price_unit}"
else:
# piece-like units
if price_unit in ('piece','pc','pcs'):
# qty is number of pieces
cost = row['qty'] * price
return cost, price, "price per piece"
elif price_unit in ('kg','kilogram','kg'):
# if unit is piece but price is per kg, we cannot convert; ask user
return None, price, "needs manual conversion (piece vs kg)"
else:
return None, price, f"unhandled conversion: {row['unit']} -> {price_unit}"
else:
return None, None, "no price found"
# -------------------------
# Online scraping stub (adapt for your local grocery site)
# -------------------------
def fetch_online_price_stub(item_name):
"""
Placeholder demo using BeautifulSoup.
This function is intentionally generic and will NOT work out-of-the-box for a real grocery site.
To adapt:
1. Choose a grocery site (that permits scraping).
2. Inspect search result HTML and find the selector that contains price.
3. Update SEARCH_URL and selector below.
4. Handle pagination / blocking / headers and respect robots.txt.
Example (pseudo):
SEARCH_URL = f"https://yourgrocer.example/search?q={item_name_encoded}"
r = requests.get(SEARCH_URL, headers={...})
soup = BeautifulSoup(r.text, "html.parser")
price_tag = soup.select_one(".price-class")
price_text = price_tag.get_text()
parse price_text to float and return.
For demo: we'll return None and a helpful search url so the user can confirm manually.
"""
# Provide a helpful web search link so user can check and paste price
query = requests.utils.requote_uri(item_name + " price")
search_url = f"https://www.google.com/search?q={query}"
return None, f"Please check prices manually: {search_url}"
# -------------------------
# GUI Application
# -------------------------
class RecipeCostApp:
def __init__(self, root):
self.root = root
self.root.title("Recipe Cost Estimator")
self.root.geometry("900x700")
# Load price DB
self.prices = load_price_db()
# Top frame: recipe input
top = ttk.Frame(root)
top.pack(fill='both', padx=10, pady=6)
ttk.Label(top, text="Paste recipe (one ingredient per line):", font=("Arial", 11)).pack(anchor='w')
self.recipe_text = tk.Text(top, height=10)
self.recipe_text.pack(fill='x')
btn_frame = ttk.Frame(top)
btn_frame.pack(fill='x', pady=6)
ttk.Button(btn_frame, text="Load Prices CSV", command=self.load_prices_csv).pack(side='left', padx=4)
ttk.Button(btn_frame, text="Save Prices CSV", command=self.save_prices_csv).pack(side='left', padx=4)
ttk.Button(btn_frame, text="Estimate Cost", command=self.estimate_cost).pack(side='left', padx=4)
ttk.Button(btn_frame, text="Clear", command=lambda: self.recipe_text.delete("1.0", "end")).pack(side='left', padx=4)
# Middle: results table and edit area
mid = ttk.Frame(root)
mid.pack(fill='both', expand=True, padx=10, pady=6)
left_mid = ttk.Frame(mid)
left_mid.pack(side='left', fill='both', expand=True)
ttk.Label(left_mid, text="Cost Breakdown:", font=("Arial", 11)).pack(anchor='w')
self.tree = ttk.Treeview(left_mid, columns=("qty", "unit", "item", "price_used", "cost", "note"), show='headings')
for c in ("qty", "unit", "item", "price_used", "cost", "note"):
self.tree.heading(c, text=c.capitalize())
self.tree.column(c, width=110, anchor='center')
self.tree.pack(fill='both', expand=True)
right_mid = ttk.Frame(mid, width=320)
right_mid.pack(side='right', fill='y', padx=6)
ttk.Label(right_mid, text="Edit / Add Price (selected item):", font=("Arial", 11)).pack(anchor='w', pady=4)
ttk.Label(right_mid, text="Item name:").pack(anchor='w')
self.price_item_entry = ttk.Entry(right_mid)
self.price_item_entry.pack(fill='x', pady=2)
ttk.Label(right_mid, text="Price (numeric):").pack(anchor='w')
self.price_value_entry = ttk.Entry(right_mid)
self.price_value_entry.pack(fill='x', pady=2)
ttk.Label(right_mid, text="Unit (kg/piece/litre):").pack(anchor='w')
self.price_unit_entry = ttk.Entry(right_mid)
self.price_unit_entry.pack(fill='x', pady=2)
ttk.Button(right_mid, text="Save Price", command=self.save_price_from_entries).pack(pady=6)
ttk.Button(right_mid, text="Fetch Online Price (open search)", command=self.fetch_online_for_selected).pack(pady=6)
ttk.Button(right_mid, text="Export Breakdown CSV", command=self.export_breakdown_csv).pack(pady=6)
# bottom: totals
bottom = ttk.Frame(root)
bottom.pack(fill='x', padx=10, pady=8)
self.total_label = ttk.Label(bottom, text="Total Cost: ₹0.00", font=("Arial", 12, "bold"))
self.total_label.pack(anchor='e')
# Bind selection
self.tree.bind("<<TreeviewSelect>>", self.on_row_select)
# internal
self.last_breakdown = [] # list of dicts
# -------------------------
# UI Callbacks
# -------------------------
def load_prices_csv(self):
path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"), ("All files", "*.*")])
if not path:
return
try:
df = pd.read_csv(path)
if 'item' not in df.columns or 'price' not in df.columns or 'unit' not in df.columns:
messagebox.showerror("Error", "CSV must have columns: item,price,unit")
return
save_price_db(df)
self.prices = load_price_db()
messagebox.showinfo("Loaded", f"Prices loaded and saved to {PRICE_DB}")
except Exception as e:
messagebox.showerror("Error", f"Failed to load file: {e}")
def save_prices_csv(self):
save_price_db(self.prices)
messagebox.showinfo("Saved", f"Prices saved to {PRICE_DB}")
def estimate_cost(self):
# Clear tree
for r in self.tree.get_children():
self.tree.delete(r)
text = self.recipe_text.get("1.0", "end").strip()
if not text:
messagebox.showwarning("Input", "Please paste recipe ingredient lines")
return
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
parsed = []
for ln in lines:
p = parse_ingredient_line(ln)
if p:
parsed.append(p)
else:
parsed.append({"qty": None, "unit": "", "item": ln})
prices_df = self.prices.copy()
breakdown = []
total = 0.0
for row in parsed:
if row['qty'] is None:
cost, price_used, note = None, None, "Could not parse quantity"
else:
cost, price_used, note = compute_cost_for_row(row, prices_df)
if cost is None:
note = note or "price missing or conversion needed"
cost_display = ""
else:
cost_display = f"{cost:.2f}"
total += cost
price_used_display = "" if price_used is None else str(price_used)
breakdown_entry = {
"qty": row.get('qty'),
"unit": row.get('unit'),
"item": row.get('item'),
"price_used": price_used_display,
"cost": cost_display,
"note": note
}
breakdown.append(breakdown_entry)
self.tree.insert("", "end", values=(breakdown_entry['qty'], breakdown_entry['unit'],
breakdown_entry['item'], breakdown_entry['price_used'],
breakdown_entry['cost'], breakdown_entry['note']))
self.total_label.config(text=f"Total Cost: ₹{total:.2f}")
self.last_breakdown = breakdown
def on_row_select(self, event):
sel = self.tree.selection()
if not sel:
return
vals = self.tree.item(sel[0], 'values')
item_name = vals[2]
# prefill edit fields
self.price_item_entry.delete(0, 'end')
self.price_item_entry.insert(0, item_name)
# try to fill existing price if present
norm = normalize_item_name(item_name)
m = self.prices[self.prices['item_norm'] == norm]
if not m.empty:
row = m.iloc[0]
self.price_value_entry.delete(0, 'end')
self.price_value_entry.insert(0, str(row['price']))
self.price_unit_entry.delete(0, 'end')
self.price_unit_entry.insert(0, str(row['unit']))
else:
self.price_value_entry.delete(0, 'end')
self.price_unit_entry.delete(0, 'end')
def save_price_from_entries(self):
item = self.price_item_entry.get().strip()
price = self.price_value_entry.get().strip()
unit = self.price_unit_entry.get().strip()
if not item or not price or not unit:
messagebox.showwarning("Input", "Please supply item, price, and unit")
return
try:
price_f = float(price)
except:
messagebox.showerror("Input", "Price must be numeric")
return
norm = normalize_item_name(item)
df = self.prices
if (df['item_norm'] == norm).any():
idx = df[df['item_norm'] == norm].index[0]
df.at[idx, 'price'] = price_f
df.at[idx, 'unit'] = unit
df.at[idx, 'item'] = item
df.at[idx, 'item_norm'] = norm
else:
new_row = {'item': item, 'price': price_f, 'unit': unit, 'item_norm': norm}
self.prices = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)
save_price_db(self.prices)
messagebox.showinfo("Saved", "Price saved to local DB")
self.estimate_cost() # refresh
def fetch_online_for_selected(self):
sel = self.tree.selection()
if not sel:
messagebox.showwarning("Select", "Select an ingredient row first")
return
vals = self.tree.item(sel[0], 'values')
item_name = vals[2]
price, hint = fetch_online_price_stub(item_name)
if price is not None:
# automatically fill fields
self.price_item_entry.delete(0,'end'); self.price_item_entry.insert(0, item_name)
self.price_value_entry.delete(0,'end'); self.price_value_entry.insert(0, str(price))
self.price_unit_entry.delete(0,'end'); self.price_unit_entry.insert(0, 'kg')
messagebox.showinfo("Fetched", f"Fetched price: {price}")
else:
# open browser with search link or show hint
if hint:
webbrowser.open(hint)
messagebox.showinfo("Manual lookup", f"Opened browser to help find price.\n\n{hint}")
else:
messagebox.showinfo("No results", "No price found online (please enter manually)")
def export_breakdown_csv(self):
if not self.last_breakdown:
messagebox.showwarning("No data", "Please estimate cost first")
return
df = pd.DataFrame(self.last_breakdown)
path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files","*.csv")])
if not path:
return
df.to_csv(path, index=False)
messagebox.showinfo("Exported", f"Breakdown exported to {path}")
# -------------------------
# Run
# -------------------------
def main():
ensure_price_db_exists()
root = tk.Tk()
app = RecipeCostApp(root)
root.mainloop()
if __name__ == "__main__":
main()
No comments:
Post a Comment