Skip to content

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" — raise IncompatibleUnitsError (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.

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.