wright-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wright/costing.py ADDED
@@ -0,0 +1,584 @@
1
+ """Cost calculation logic — pure functions, no file I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from decimal import Decimal
7
+
8
+ import pint
9
+
10
+ from wright.errors import (
11
+ IngredientNotFoundError,
12
+ RecipeCostErrors,
13
+ UnitConversionError,
14
+ )
15
+ from wright.matching import ItemMatcher, find_matching_purchases
16
+ from wright.models import (
17
+ DensityData,
18
+ Ingredient,
19
+ IngredientCost,
20
+ Material,
21
+ PriceRange,
22
+ PurchasedItem,
23
+ Recipe,
24
+ RecipeCost,
25
+ )
26
+ from wright.units import (
27
+ DISCRETE_UNITS,
28
+ PINCH_UNITS,
29
+ WEIGHT_UNITS,
30
+ are_compatible,
31
+ parse_quantity,
32
+ )
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Unit conversion helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ # Standard volume constants
39
+ _ML_PER_TSP = 4.92892
40
+ _ML_PER_TBSP = 14.7868
41
+ _ML_PER_CUP = 236.588
42
+ _ML_PER_FLOZ = 29.5735
43
+
44
+
45
+ def convert_with_density(
46
+ ingredient_name: str,
47
+ quantity: float,
48
+ from_unit: str,
49
+ to_unit: str,
50
+ density_data: DensityData,
51
+ ) -> float | None:
52
+ """Try to convert quantity using density data.
53
+
54
+ Supports two types of conversions:
55
+ 1. *liquids*: density in g/ml (e.g., lemon juice: 1.03 g/ml).
56
+ 2. *volume_weights*: direct g per volume unit (e.g., cinnamon: 2.6 g/tsp).
57
+
58
+ Args:
59
+ ingredient_name: Name of the ingredient (case-insensitive lookup).
60
+ quantity: Amount to convert.
61
+ from_unit: Source unit (e.g., ``"g"``).
62
+ to_unit: Target unit (e.g., ``"tsp"``).
63
+ density_data: Dictionary with optional ``"liquids"`` and
64
+ ``"volume_weights"`` sections.
65
+
66
+ Returns:
67
+ Converted quantity, or ``None`` if no conversion is available.
68
+ """
69
+ volume_weights = density_data.get("volume_weights", {})
70
+ liquids = density_data.get("liquids", {})
71
+
72
+ from_unit_lower = from_unit.lower()
73
+ to_unit_lower = to_unit.lower()
74
+
75
+ # ── Liquids (g/ml density) ─────────────────────────────────────────────
76
+
77
+ density = liquids.get(ingredient_name)
78
+ if density is None:
79
+ # Case-insensitive fallback
80
+ for key, value in liquids.items():
81
+ if key.lower() == ingredient_name.lower():
82
+ density = value
83
+ break
84
+
85
+ if density is not None:
86
+ # g → volume via density
87
+ if from_unit_lower in {"g", "gram", "grams"}:
88
+ ml = quantity / density
89
+ if to_unit_lower in {"tsp", "teaspoon"}:
90
+ return ml / _ML_PER_TSP
91
+ if to_unit_lower in {"tbsp", "tablespoon"}:
92
+ return ml / _ML_PER_TBSP
93
+ if to_unit_lower in {"cup", "cups"}:
94
+ return ml / _ML_PER_CUP
95
+ if to_unit_lower in {"ml", "milliliter", "milliliters"}:
96
+ return ml
97
+ if to_unit_lower in {"floz", "fl oz", "fluid ounce", "fluid ounces"}:
98
+ return ml / _ML_PER_FLOZ
99
+
100
+ # volume → g via density
101
+ elif to_unit_lower in {"g", "gram", "grams"}:
102
+ ml: float | None = None
103
+ if from_unit_lower in {"tsp", "teaspoon"}:
104
+ ml = quantity * _ML_PER_TSP
105
+ elif from_unit_lower in {"tbsp", "tablespoon"}:
106
+ ml = quantity * _ML_PER_TBSP
107
+ elif from_unit_lower in {"cup", "cups"}:
108
+ ml = quantity * _ML_PER_CUP
109
+ elif from_unit_lower in {"ml", "milliliter", "milliliters"}:
110
+ ml = quantity
111
+ elif from_unit_lower in {"floz", "fl oz", "fluid ounce", "fluid ounces"}:
112
+ ml = quantity * _ML_PER_FLOZ
113
+
114
+ if ml is not None:
115
+ return ml * density
116
+
117
+ # ── Volume weights (direct g per volume unit) ──────────────────────────
118
+
119
+ conversions = volume_weights.get(ingredient_name)
120
+ if conversions is None:
121
+ for key in volume_weights:
122
+ if key.lower() == ingredient_name.lower():
123
+ conversions = volume_weights[key]
124
+ break
125
+
126
+ if conversions is None:
127
+ return None
128
+
129
+ # g → volume
130
+ if from_unit_lower in {"g", "gram", "grams"}:
131
+ if to_unit_lower in {"tsp", "teaspoon"}:
132
+ g_per = conversions.get("tsp")
133
+ if g_per:
134
+ return quantity / g_per
135
+ elif to_unit_lower in {"tbsp", "tablespoon"}:
136
+ g_per = conversions.get("tbsp")
137
+ if g_per:
138
+ return quantity / g_per
139
+ elif to_unit_lower in {"cup", "cups"}:
140
+ g_per = conversions.get("cup")
141
+ if g_per:
142
+ return quantity / g_per
143
+
144
+ # volume → weight
145
+ volume_from: float | None = None
146
+ if from_unit_lower in {"tsp", "teaspoon"}:
147
+ g_per = conversions.get("tsp")
148
+ if g_per:
149
+ volume_from = quantity * g_per
150
+ elif from_unit_lower in {"tbsp", "tablespoon"}:
151
+ g_per = conversions.get("tbsp")
152
+ if g_per:
153
+ volume_from = quantity * g_per
154
+ elif from_unit_lower in {"cup", "cups"}:
155
+ g_per = conversions.get("cup")
156
+ if g_per:
157
+ volume_from = quantity * g_per
158
+
159
+ if volume_from is not None and to_unit_lower in WEIGHT_UNITS:
160
+ if to_unit_lower in {"g", "gram", "grams"}:
161
+ return volume_from
162
+ try:
163
+ return float(parse_quantity(volume_from, "g").to(to_unit_lower).magnitude)
164
+ except Exception:
165
+ pass
166
+
167
+ return None
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Ingredient costing
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def calculate_ingredient_cost(
176
+ material: Material,
177
+ purchase: PurchasedItem,
178
+ *,
179
+ density_data: DensityData | None = None,
180
+ converter: Callable[[Material, PurchasedItem, DensityData], Decimal | None]
181
+ | None = None,
182
+ ureg: pint.UnitRegistry | None = None,
183
+ ) -> Decimal:
184
+ """Calculate the cost of a BOM item based on a purchase item's price.
185
+
186
+ Handles unit conversion between BOM units and purchase units.
187
+ For discrete units (each, packet), uses direct multiplication.
188
+ For pinch units with non-discrete purchase units, estimates ~0.25 tsp.
189
+
190
+ Args:
191
+ material: The BOM item to cost.
192
+ purchase: The purchase item to use for pricing.
193
+ density_data: Optional density data for unit conversion.
194
+ converter: Optional custom cost function
195
+ ``(material, purchase, density_data) -> Decimal | None``.
196
+ Called first; if it returns a ``Decimal``, that value is used.
197
+ If it returns ``None``, falls through to the built-in cascade.
198
+
199
+ Returns:
200
+ The cost of the material amount.
201
+
202
+ Raises:
203
+ UnitConversionError: If units cannot be converted.
204
+ """
205
+ density_data = density_data or {}
206
+
207
+ # Allow a custom converter to intercept before the built-in cascade
208
+ if converter is not None:
209
+ result = converter(material, purchase, density_data)
210
+ if result is not None:
211
+ return result
212
+
213
+ ing_unit_lower = material.unit.lower()
214
+ groc_unit_lower = purchase.unit.lower()
215
+
216
+ # Both are discrete — direct count multiplication
217
+ if ing_unit_lower in DISCRETE_UNITS and groc_unit_lower in DISCRETE_UNITS:
218
+ cost_per_item = purchase.price / Decimal(str(purchase.quantity))
219
+ return cost_per_item * Decimal(str(material.quantity))
220
+
221
+ # Pinch — estimate as ~0.25 tsp
222
+ if ing_unit_lower in PINCH_UNITS:
223
+ if are_compatible("tsp", purchase.unit, ureg=ureg):
224
+ groc_in_tsp = (
225
+ parse_quantity(purchase.quantity, purchase.unit, ureg=ureg)
226
+ .to("tsp")
227
+ .magnitude
228
+ )
229
+ price_per_tsp = purchase.price / Decimal(str(groc_in_tsp))
230
+ pinch_in_tsp = Decimal("0.25") * Decimal(str(material.quantity))
231
+ return price_per_tsp * pinch_in_tsp
232
+ else:
233
+ return Decimal("0.01") * Decimal(str(material.quantity))
234
+
235
+ # Same unit string — simple ratio (handles unregistered units like "box", "jar")
236
+ if ing_unit_lower == groc_unit_lower:
237
+ price_per_unit = purchase.price / Decimal(str(purchase.quantity))
238
+ return price_per_unit * Decimal(str(material.quantity))
239
+
240
+ # Unit-compatible — pint handles it
241
+ if are_compatible(material.unit, purchase.unit, ureg=ureg):
242
+ try:
243
+ ing_qty = parse_quantity(material.quantity, material.unit, ureg=ureg)
244
+ ing_in_groc_units = ing_qty.to(purchase.unit).magnitude
245
+ price_per_unit = purchase.price / Decimal(str(purchase.quantity))
246
+ return price_per_unit * Decimal(str(ing_in_groc_units))
247
+ except pint.DimensionalityError as err:
248
+ raise UnitConversionError(
249
+ material.unit, purchase.unit, material.name
250
+ ) from err
251
+
252
+ # Incompatible but material has an equivalent — try that
253
+ equiv_qty = material.equivalent_quantity
254
+ equiv_unit = material.equivalent_unit
255
+ if (
256
+ equiv_qty is not None
257
+ and equiv_unit is not None
258
+ and are_compatible(equiv_unit, purchase.unit, ureg=ureg)
259
+ ):
260
+ try:
261
+ Material(
262
+ name=material.name,
263
+ quantity=equiv_qty,
264
+ unit=equiv_unit,
265
+ )
266
+ e_qty = parse_quantity(equiv_qty, equiv_unit, ureg=ureg)
267
+ in_groc = e_qty.to(purchase.unit).magnitude
268
+ price_per_unit = purchase.price / Decimal(str(purchase.quantity))
269
+ return price_per_unit * Decimal(str(in_groc))
270
+ except Exception:
271
+ pass
272
+
273
+ # Not directly compatible — try density conversion
274
+ converted = convert_with_density(
275
+ material.name,
276
+ material.quantity,
277
+ material.unit,
278
+ purchase.unit,
279
+ density_data,
280
+ )
281
+
282
+ if converted is not None:
283
+ price_per_unit = purchase.price / Decimal(str(purchase.quantity))
284
+ return price_per_unit * Decimal(str(converted))
285
+
286
+ raise UnitConversionError(material.unit, purchase.unit, material.name)
287
+
288
+
289
+ def calculate_ingredient_cost_range(
290
+ material: Material,
291
+ purchases: Iterable[PurchasedItem],
292
+ *,
293
+ density_data: DensityData | None = None,
294
+ ) -> IngredientCost:
295
+ """Calculate the cost range for a material across multiple purchase sources.
296
+
297
+ Args:
298
+ material: The BOM item to cost.
299
+ purchases: Matching purchase items (output of
300
+ :func:`find_matching_purchases`).
301
+ density_data: Optional density data for unit conversion.
302
+
303
+ Returns:
304
+ ``IngredientCost`` with price range and source information.
305
+
306
+ Raises:
307
+ UnitConversionError: If *none* of the purchase items can be converted
308
+ to the material's unit.
309
+ """
310
+ density_data = density_data or {}
311
+
312
+ costs: list[Decimal] = []
313
+ sources: list[str] = []
314
+
315
+ for g in purchases:
316
+ try:
317
+ cost = calculate_ingredient_cost(material, g, density_data=density_data)
318
+ costs.append(cost)
319
+ source = g.store or "unknown"
320
+ if hasattr(g, "brand") and getattr(g, "brand", None):
321
+ source = f"{source} {g.brand}"
322
+ sources.append(source)
323
+ except UnitConversionError:
324
+ continue
325
+
326
+ if not costs:
327
+ # All unit conversions failed — pick the first purchase for the error
328
+ first = next(iter(purchases), None)
329
+ raise UnitConversionError(
330
+ material.unit,
331
+ first.unit if first else "unknown",
332
+ material.name,
333
+ )
334
+
335
+ return IngredientCost(
336
+ ingredient=Ingredient(
337
+ name=material.name,
338
+ quantity=material.quantity,
339
+ unit=material.unit,
340
+ require_tags=material.require_tags,
341
+ equivalent_quantity=material.equivalent_quantity,
342
+ equivalent_unit=material.equivalent_unit,
343
+ byproduct=material.byproduct,
344
+ product_ref=material.product_ref,
345
+ ),
346
+ price_range=PriceRange(min_price=min(costs), max_price=max(costs)),
347
+ sources=sources,
348
+ )
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Gram conversion
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ def convert_ingredient_to_grams(
357
+ material: Material,
358
+ *,
359
+ raise_on_error: bool = True,
360
+ ureg: pint.UnitRegistry | None = None,
361
+ ) -> float:
362
+ """Return the gram quantity for a material.
363
+
364
+ For packet units, uses ``equivalent_quantity`` (e.g. 1 packet = 8 g).
365
+ For gram units, uses quantity directly.
366
+ For other weight units, converts via pint.
367
+
368
+ Args:
369
+ material: The BOM item to resolve to grams.
370
+ raise_on_error: If ``True`` (default), raises ``UnitConversionError``
371
+ on failure. If ``False``, returns ``0.0`` silently.
372
+
373
+ Returns:
374
+ Gram quantity, or ``0.0`` when unresolvable and *raise_on_error* is ``False``.
375
+
376
+ Raises:
377
+ UnitConversionError: If the unit cannot be resolved to grams and
378
+ *raise_on_error* is ``True``.
379
+ """
380
+ unit_lower = material.unit.lower()
381
+
382
+ if unit_lower in {"packet", "packets"}:
383
+ if material.equivalent_quantity is None:
384
+ if raise_on_error:
385
+ raise UnitConversionError(material.unit, "g", material.name)
386
+ return 0.0
387
+ return material.equivalent_quantity
388
+
389
+ try:
390
+ return float(parse_quantity(material.quantity, unit_lower).to("g").magnitude)
391
+ except Exception as err:
392
+ if raise_on_error:
393
+ raise UnitConversionError(material.unit, "g", material.name) from err
394
+ return 0.0
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Recipe costing (with recursive product_ref support)
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ def _cost_recipe_inner(
403
+ recipe: Recipe,
404
+ purchases: Iterable[PurchasedItem],
405
+ density_data: DensityData,
406
+ recipe_index: Mapping[str, Recipe],
407
+ visited: frozenset[str],
408
+ *,
409
+ matcher: ItemMatcher,
410
+ ) -> tuple[list[IngredientCost], PriceRange]:
411
+ """Cost a recipe's ingredients, resolving ``product_ref`` recursively.
412
+
413
+ Returns ``(ingredient_costs, total_price_range)``.
414
+
415
+ Raises:
416
+ RecipeCostErrors: If any ingredients cannot be matched, converted,
417
+ or if a cycle is detected among ``product_ref`` references.
418
+ """
419
+ if recipe.name in visited:
420
+ cycle = " → ".join([*visited, recipe.name])
421
+ raise RecipeCostErrors([ValueError(f"Recipe cycle detected: {cycle}")])
422
+
423
+ ingredient_costs: list[IngredientCost] = []
424
+ errors: list[
425
+ IngredientNotFoundError | UnitConversionError | RecipeCostErrors | ValueError
426
+ ] = []
427
+ total_min = Decimal("0")
428
+ total_max = Decimal("0")
429
+
430
+ for ingredient in recipe.all_ingredients:
431
+ if ingredient.byproduct:
432
+ continue
433
+ if ingredient.quantity == 0:
434
+ continue
435
+
436
+ try:
437
+ if ingredient.product_ref is not None:
438
+ # ── Recursive: cost the referenced sub-recipe per gram ──
439
+ ref_name = ingredient.product_ref
440
+ sub_recipe = recipe_index.get(ref_name)
441
+ if sub_recipe is None:
442
+ raise IngredientNotFoundError(ref_name)
443
+
444
+ _sub_costs, sub_total = _cost_recipe_inner(
445
+ sub_recipe,
446
+ purchases,
447
+ density_data,
448
+ recipe_index,
449
+ visited | {recipe.name},
450
+ matcher=matcher,
451
+ )
452
+
453
+ if (
454
+ sub_recipe.net_weight_grams is None
455
+ or sub_recipe.net_weight_grams <= 0
456
+ ):
457
+ raise RecipeCostErrors([
458
+ ValueError(
459
+ f"Sub-recipe '{ref_name}' has no "
460
+ "net_weight_grams — cannot compute "
461
+ "per-gram cost for product_ref."
462
+ )
463
+ ])
464
+
465
+ yield_dec = Decimal(str(sub_recipe.net_weight_grams))
466
+ per_gram = PriceRange(
467
+ min_price=sub_total.min_price / yield_dec,
468
+ max_price=sub_total.max_price / yield_dec,
469
+ )
470
+
471
+ grams_used = convert_ingredient_to_grams(ingredient)
472
+ grams_dec = Decimal(str(grams_used))
473
+
474
+ cost = IngredientCost(
475
+ ingredient=ingredient,
476
+ price_range=PriceRange(
477
+ min_price=per_gram.min_price * grams_dec,
478
+ max_price=per_gram.max_price * grams_dec,
479
+ ),
480
+ sources=[f"{sub_recipe.name} (sub-recipe, {grams_used:.1f}g)"],
481
+ )
482
+ else:
483
+ # ── Standard: match to purchase ──────────────────────────
484
+ matching = matcher(ingredient, purchases)
485
+ cost = calculate_ingredient_cost_range(
486
+ ingredient, matching, density_data=density_data
487
+ )
488
+
489
+ except (
490
+ IngredientNotFoundError,
491
+ UnitConversionError,
492
+ RecipeCostErrors,
493
+ ) as e:
494
+ errors.append(e)
495
+ continue
496
+
497
+ ingredient_costs.append(cost)
498
+ total_min += cost.price_range.min_price
499
+ total_max += cost.price_range.max_price
500
+
501
+ if errors:
502
+ raise RecipeCostErrors(errors)
503
+
504
+ return ingredient_costs, PriceRange(min_price=total_min, max_price=total_max)
505
+
506
+
507
+ def calculate_recipe_cost(
508
+ recipe: Recipe,
509
+ purchases: Iterable[PurchasedItem],
510
+ *,
511
+ density_data: DensityData | None = None,
512
+ recipe_index: Mapping[str, Recipe] | None = None,
513
+ matcher: ItemMatcher | None = None,
514
+ ) -> RecipeCost:
515
+ """Calculate the full cost breakdown for a recipe.
516
+
517
+ Resolves ingredients to purchase items. When an ingredient has
518
+ ``product_ref`` set, the function looks up the referenced recipe in
519
+ *recipe_index* and recursively costs it — supporting arbitrary nesting
520
+ depths with cycle detection.
521
+
522
+ Args:
523
+ recipe: The recipe to cost.
524
+ purchases: Available purchase price data (any ``PurchasedItem``).
525
+ density_data: Optional density data for unit conversion.
526
+ recipe_index: Optional mapping of recipe name → ``Recipe`` for
527
+ resolving ``product_ref`` references.
528
+ matcher: Optional custom matching function. Defaults to
529
+ :func:`find_matching_purchases` (exact name + tag filter).
530
+
531
+ Returns:
532
+ ``RecipeCost`` with ingredient-level breakdown and totals.
533
+
534
+ Raises:
535
+ RecipeCostErrors: If any ingredients cannot be matched, converted,
536
+ or if a cycle is detected.
537
+ """
538
+ density_data = density_data or {}
539
+ recipe_index = recipe_index or {}
540
+
541
+ ingredient_costs, total_range = _cost_recipe_inner(
542
+ recipe,
543
+ purchases,
544
+ density_data,
545
+ recipe_index,
546
+ frozenset(),
547
+ matcher=matcher or find_matching_purchases,
548
+ )
549
+
550
+ # Per-serving costs
551
+ min_s, max_s = recipe._servings_bounds()
552
+ min_per_serving = total_range.min_price / Decimal(str(max_s))
553
+ max_per_serving = total_range.max_price / Decimal(str(min_s))
554
+
555
+ return RecipeCost(
556
+ recipe_name=recipe.name,
557
+ ingredient_costs=ingredient_costs,
558
+ total_cost_range=total_range,
559
+ cost_per_serving_range=PriceRange(
560
+ min_price=min_per_serving, max_price=max_per_serving
561
+ ),
562
+ )
563
+
564
+
565
+ def get_top_cost_drivers(
566
+ recipe_cost: RecipeCost,
567
+ n: int = 5,
568
+ ) -> list[IngredientCost]:
569
+ """Return the top N ingredients by cost midpoint, descending.
570
+
571
+ Args:
572
+ recipe_cost: A fully calculated ``RecipeCost``.
573
+ n: How many top drivers to return (default 5).
574
+
575
+ Returns:
576
+ List of ``IngredientCost`` sorted by price midpoint descending,
577
+ capped at *n*.
578
+ """
579
+ sorted_costs = sorted(
580
+ recipe_cost.ingredient_costs,
581
+ key=lambda ic: ic.price_range.midpoint,
582
+ reverse=True,
583
+ )
584
+ return sorted_costs[:n]
wright/errors.py ADDED
@@ -0,0 +1,79 @@
1
+ """Custom exceptions for the wright package."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RecipeCoreError(Exception):
7
+ """Base exception for all wright errors."""
8
+
9
+
10
+ class IngredientNotFoundError(RecipeCoreError):
11
+ """Raised when an ingredient cannot be matched to any grocery item."""
12
+
13
+ def __init__(self, ingredient_name: str, require_tags: list[str] | None = None):
14
+ self.ingredient_name = ingredient_name
15
+ self.require_tags = require_tags or []
16
+
17
+ tags_msg = ""
18
+ if self.require_tags:
19
+ tags_msg = f" with tags {self.require_tags}"
20
+
21
+ message = f"No grocery item found for '{ingredient_name}'{tags_msg}."
22
+ super().__init__(message)
23
+
24
+
25
+ class RecipeLoadError(RecipeCoreError):
26
+ """Raised when a recipe file cannot be loaded or parsed."""
27
+
28
+ def __init__(self, path: str, reason: str):
29
+ self.path = path
30
+ self.reason = reason
31
+ message = f"Failed to load recipe from '{path}': {reason}"
32
+ super().__init__(message)
33
+
34
+
35
+ class PurchaseLoadError(RecipeCoreError):
36
+ """Raised when a grocery file cannot be loaded or parsed."""
37
+
38
+ def __init__(self, path: str, reason: str):
39
+ self.path = path
40
+ self.reason = reason
41
+ message = f"Failed to load grocery data from '{path}': {reason}"
42
+ super().__init__(message)
43
+
44
+
45
+ class UnitConversionError(RecipeCoreError):
46
+ """Raised when unit conversion between ingredient and grocery units fails."""
47
+
48
+ def __init__(self, from_unit: str, to_unit: str, ingredient_name: str):
49
+ self.from_unit = from_unit
50
+ self.to_unit = to_unit
51
+ self.ingredient_name = ingredient_name
52
+ message = (
53
+ f"Cannot convert '{from_unit}' to '{to_unit}' for ingredient "
54
+ f"'{ingredient_name}'. Units are incompatible."
55
+ )
56
+ super().__init__(message)
57
+
58
+
59
+ class RecipeCostErrors(RecipeCoreError):
60
+ """Raised when one or more ingredients in a recipe could not be costed.
61
+
62
+ Collects all ingredient errors instead of stopping at the first failure,
63
+ so the user can see everything that needs to be fixed in one run.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ errors: list[
69
+ IngredientNotFoundError
70
+ | UnitConversionError
71
+ | RecipeCostErrors
72
+ | ValueError
73
+ ],
74
+ ):
75
+ self.errors = errors
76
+ count = len(errors)
77
+ header = f"{count} ingredient(s) could not be resolved:"
78
+ lines = [f" - {e}" for e in errors]
79
+ super().__init__("\n".join([header, *lines]))