Matching & Planning¶
Matching ingredients to purchases¶
The default matcher does exact name matching. Compose pickers with chain():
from wright import chain, pinned_picker, cheapest_picker, calculate_shopping_list_cost
picker = chain(pinned_picker({"Rolled Oats": my_brand}), cheapest_picker)
items = calculate_shopping_list_cost(shopping, groceries, picker=picker)
Available pickers:
| Picker | Behavior |
|---|---|
first_picker |
First matching purchase, any store |
cheapest_picker |
Lowest price among matches |
recent_picker |
Most recent purchase date |
compatible_unit_recent_picker |
Most recent with compatible units (default) |
pinned_picker |
Override specific ingredients, fall through to next |
chain |
Compose multiple pickers in sequence |
Inject a custom name matcher:
def fuzzy_matcher(ingredient, groceries):
exact = [g for g in groceries if g.name == ingredient.name]
if exact:
return [g for g in exact if g.matches_requirements(ingredient.require_tags)]
return [
g
for g in groceries
if ingredient.name.lower() in g.name.lower()
and g.matches_requirements(ingredient.require_tags)
]
cost = calculate_recipe_cost(recipe, groceries, matcher=fuzzy_matcher)
Planning a production run¶
from datetime import date
from wright import ProductionRun, ProductionItem, generate_shopping_list
session = ProductionRun(
date=date(2026, 6, 20),
production=[ProductionItem(assembly="Lemon Cake", quantity=3)],
target_dates=[date(2026, 6, 20)],
)
shopping = generate_shopping_list(session, recipes)
# → ShoppingList(.date, .production_summary, .groups, .all_items)
# .all_items → list[SupplyItem] each with .name, .quantity, .unit, .tags
Unit consistency during accumulation¶
When the same ingredient appears in different units across recipes (e.g. cups in one, grams in another), wright normalizes volume units to ml before summing. Mixing incompatible units (ml + g) is refused rather than silently producing a meaningless total:
from wright import generate_shopping_list, IncompatibleUnitsError
try:
generate_shopping_list(session, recipes, on_incompatible="raise")
except IncompatibleUnitsError as e:
print(e) # names the ingredient and the two clashing units
on_incompatible="raise"— raiseIncompatibleUnitsError(recommended when your caller can convert units itself)on_incompatible="add"— default, legacy behavior of summing the raw numbers (kept for backward compatibility; produces a meaningless total)
The better fix is a density-aware volume_normalizer, which receives
the ingredient name so it can convert volume → weight per ingredient:
def density_normalizer(quantity, unit, *, name=""):
"""Convert cups/tbsp/tsp to grams using per-ingredient density data."""
grams = my_density_lookup(name, quantity, unit) # your data
if grams is not None:
return grams, "g"
return quantity, unit # fall back to wright's default
shopping = generate_shopping_list(
session,
recipes,
volume_normalizer=density_normalizer,
on_incompatible="raise",
)
Zero-quantity materials (e.g. "to taste" placeholders) are skipped
during accumulation and never pollute the accumulator's unit.
Enriching with costs¶
from wright import calculate_shopping_list_cost
items = calculate_shopping_list_cost(shopping, groceries)
for item in items:
print(f"{item.name}: ${item.total_cost}")
total = sum(i.total_cost for i in items if i.total_cost is not None)
Each MaterialCost carries:
| Attribute | Description |
|---|---|
.item |
The underlying SupplyItem |
.name |
Item name (flat access — delegates to .item.name) |
.quantity |
Item quantity (flat access — delegates to .item.quantity) |
.unit |
Item unit (flat access — delegates to .item.unit) |
.tags |
Item tags (flat access — delegates to .item.tags) |
.total_cost |
Cost for the required quantity (Decimal or None) |
.store |
Where it was purchased |
.price_per_unit |
Unit price from the purchase record |
.price_unit |
Unit of the price (e.g. "g", "lb") |
.purchase_date |
When the price was recorded |
Backward compatibility: ShoppingItemWithCost is maintained as an alias of
MaterialCost. Both names refer to the same class.
Menu analysis¶
from wright import analyze_menu
menu = analyze_menu(
[ProductionItem(assembly="Lemon Cake", quantity=3)],
recipes,
groceries,
)
# → MenuAnalysis(.total_cost, .items, .top_drivers, .missing_ingredients)
print(f"Total cost: ${menu.total_cost}")
for item in menu.top_drivers:
print(f" {item.name}: ${item.total_cost} ({menu.cost_share(item):.0%})")
Categorization¶
Group shopping list items by store aisle or kitchen station:
from wright import categorize_item, CategoryRule
rules = [
CategoryRule(category="Dry Goods", priority=0, keywords=["flour", "sugar", "rice"]),
CategoryRule(
category="Dairy & Eggs", priority=1, keywords=["butter", "milk", "cream", "egg"]
),
CategoryRule(
category="Produce", priority=2, keywords=["spinach", "apple", "lemon"]
),
]
for item in shopping.all_items:
cat = categorize_item(item.name, rules=rules)
print(f" [{cat}] {item.name}")
Rules are applied in priority order (lowest number first). First keyword match
wins. Use DEFAULT_CATEGORY_RULES for grocery-store categories out of the box.