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/__init__.py +234 -0
- wright/allergens.py +342 -0
- wright/costing.py +584 -0
- wright/errors.py +79 -0
- wright/loader.py +303 -0
- wright/macros.py +251 -0
- wright/matching.py +230 -0
- wright/models.py +879 -0
- wright/planning.py +833 -0
- wright/pricing.py +77 -0
- wright/py.typed +0 -0
- wright/session.py +140 -0
- wright/supply.py +393 -0
- wright/units.py +149 -0
- wright_core-0.1.0.dist-info/METADATA +384 -0
- wright_core-0.1.0.dist-info/RECORD +18 -0
- wright_core-0.1.0.dist-info/WHEEL +4 -0
- wright_core-0.1.0.dist-info/licenses/LICENSE +21 -0
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]))
|