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 ADDED
@@ -0,0 +1,234 @@
1
+ """wright — Pure library for recipe modeling, cost calculation, and planning.
2
+
3
+ Data-source agnostic. Populate models from YAML, JSON, a database, or pure Python.
4
+ Subclass to add domain-specific metadata (pricing, translations, etc.).
5
+ """
6
+
7
+ from importlib.metadata import version as _version
8
+
9
+ try:
10
+ __version__ = _version("wright-core")
11
+ except Exception:
12
+ __version__ = "0.0.0"
13
+
14
+ from wright.allergens import (
15
+ DEFAULT_DAIRY_KEYS,
16
+ DEFAULT_GLUTEN_KEYS,
17
+ DEFAULT_NON_VEGAN_KEYS,
18
+ detect_allergens,
19
+ detect_allergens_from_names,
20
+ detect_dietary_properties,
21
+ )
22
+ from wright.costing import (
23
+ calculate_ingredient_cost,
24
+ calculate_ingredient_cost_range,
25
+ calculate_recipe_cost,
26
+ convert_ingredient_to_grams,
27
+ convert_with_density,
28
+ get_top_cost_drivers,
29
+ )
30
+ from wright.errors import (
31
+ IngredientNotFoundError,
32
+ PurchaseLoadError,
33
+ RecipeCoreError,
34
+ RecipeCostErrors,
35
+ RecipeLoadError,
36
+ UnitConversionError,
37
+ )
38
+ from wright.loader import (
39
+ list_recipe_files,
40
+ load_base_recipe,
41
+ load_density_data,
42
+ load_nutrition_registry,
43
+ load_purchases,
44
+ load_supplies,
45
+ load_yaml_file,
46
+ )
47
+ from wright.macros import calculate_recipe_macros
48
+ from wright.matching import (
49
+ ItemMatcher,
50
+ ItemPicker,
51
+ PinnedPurchases,
52
+ chain,
53
+ cheapest_picker,
54
+ compatible_unit_recent_picker,
55
+ find_matching_purchases,
56
+ first_picker,
57
+ match_all_ingredients,
58
+ pinned_picker,
59
+ recent_picker,
60
+ )
61
+ from wright.models import (
62
+ DEFAULT_CATEGORY_RULES,
63
+ Assembly,
64
+ BaseIngredient,
65
+ BaseRecipe,
66
+ CategoryRule,
67
+ Component,
68
+ DensityData,
69
+ FoodRecord,
70
+ Ingredient,
71
+ IngredientCost,
72
+ MacroPerServing,
73
+ Material,
74
+ NutritionInfo,
75
+ NutritionRegistry,
76
+ PriceRange,
77
+ Purchase,
78
+ PurchasedItem,
79
+ Recipe,
80
+ RecipeComponent,
81
+ RecipeCost,
82
+ RecipeMacros,
83
+ ServingRange,
84
+ Servings,
85
+ VolumeWeightConversions,
86
+ categorize_item,
87
+ )
88
+ from wright.planning import (
89
+ IngredientGroup,
90
+ MenuAnalysis,
91
+ ShoppingItemWithCost,
92
+ ShoppingList,
93
+ analyze_menu,
94
+ calculate_item_costs,
95
+ calculate_shopping_list_cost,
96
+ estimate_total_items,
97
+ format_quantity,
98
+ generate_shopping_list,
99
+ group_shopping_items,
100
+ normalize_metric,
101
+ normalize_volume_to_ml,
102
+ normalize_volume_us,
103
+ )
104
+ from wright.pricing import (
105
+ margin_price,
106
+ multiplier_price,
107
+ per_serving_price,
108
+ )
109
+ from wright.session import (
110
+ ProductionItem,
111
+ ProductionRun,
112
+ convert_name_to_filename,
113
+ )
114
+ from wright.supply import (
115
+ Stock,
116
+ SupplyItem,
117
+ )
118
+ from wright.units import (
119
+ DISCRETE_UNITS,
120
+ PINCH_UNITS,
121
+ VOLUME_UNITS,
122
+ WEIGHT_UNITS,
123
+ are_compatible,
124
+ parse_quantity,
125
+ ureg,
126
+ )
127
+
128
+ __all__ = [
129
+ # Version
130
+ "__version__",
131
+ # Models
132
+ "Assembly",
133
+ "BaseIngredient",
134
+ "BaseRecipe",
135
+ "CategoryRule",
136
+ "Component",
137
+ "DEFAULT_CATEGORY_RULES",
138
+ "DensityData",
139
+ "FoodRecord",
140
+ "Ingredient",
141
+ "IngredientCost",
142
+ "MacroPerServing",
143
+ "Material",
144
+ "NutritionInfo",
145
+ "NutritionRegistry",
146
+ "PriceRange",
147
+ "Purchase",
148
+ "PurchasedItem",
149
+ "Recipe",
150
+ "RecipeComponent",
151
+ "RecipeCost",
152
+ "RecipeMacros",
153
+ "ServingRange",
154
+ "Servings",
155
+ "VolumeWeightConversions",
156
+ "categorize_item",
157
+ # Errors
158
+ "IngredientNotFoundError",
159
+ "PurchaseLoadError",
160
+ "RecipeCoreError",
161
+ "RecipeCostErrors",
162
+ "RecipeLoadError",
163
+ "UnitConversionError",
164
+ # Units
165
+ "DISCRETE_UNITS",
166
+ "PINCH_UNITS",
167
+ "VOLUME_UNITS",
168
+ "WEIGHT_UNITS",
169
+ "are_compatible",
170
+ "parse_quantity",
171
+ "ureg",
172
+ # Matching
173
+ "chain",
174
+ "cheapest_picker",
175
+ "compatible_unit_recent_picker",
176
+ "find_matching_purchases",
177
+ "first_picker",
178
+ "ItemMatcher",
179
+ "ItemPicker",
180
+ "match_all_ingredients",
181
+ "pinned_picker",
182
+ "PinnedPurchases",
183
+ "recent_picker",
184
+ # Costing
185
+ "calculate_ingredient_cost",
186
+ "calculate_ingredient_cost_range",
187
+ "calculate_recipe_cost",
188
+ "convert_ingredient_to_grams",
189
+ "convert_with_density",
190
+ "get_top_cost_drivers",
191
+ # Pricing
192
+ "margin_price",
193
+ "multiplier_price",
194
+ "per_serving_price",
195
+ # Nutrition & Macros
196
+ "calculate_recipe_macros",
197
+ # Allergens
198
+ "DEFAULT_DAIRY_KEYS",
199
+ "DEFAULT_GLUTEN_KEYS",
200
+ "DEFAULT_NON_VEGAN_KEYS",
201
+ "detect_allergens",
202
+ "detect_allergens_from_names",
203
+ "detect_dietary_properties",
204
+ # Session
205
+ "ProductionItem",
206
+ "ProductionRun",
207
+ "convert_name_to_filename",
208
+ # Planning
209
+ "analyze_menu",
210
+ "calculate_item_costs",
211
+ "calculate_shopping_list_cost",
212
+ "estimate_total_items",
213
+ "format_quantity",
214
+ "generate_shopping_list",
215
+ "group_shopping_items",
216
+ "IngredientGroup",
217
+ "MenuAnalysis",
218
+ "ShoppingItemWithCost",
219
+ "ShoppingList",
220
+ "normalize_metric",
221
+ "normalize_volume_to_ml",
222
+ "normalize_volume_us",
223
+ # Loader
224
+ "list_recipe_files",
225
+ "load_base_recipe",
226
+ "load_density_data",
227
+ "load_nutrition_registry",
228
+ "load_purchases",
229
+ "load_supplies",
230
+ "load_yaml_file",
231
+ # Supply
232
+ "Stock",
233
+ "SupplyItem",
234
+ ]
wright/allergens.py ADDED
@@ -0,0 +1,342 @@
1
+ """Allergen and dietary badge detection — pure functions, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from wright.models import Ingredient, Recipe
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Badge configuration
11
+ # ---------------------------------------------------------------------------
12
+
13
+ BADGE_DISPLAY: dict[str, str] = {
14
+ "vegan": "VEGAN",
15
+ "gluten-free": "GLUTEN-FREE",
16
+ "dairy-free": "DAIRY-FREE",
17
+ "nut-free": "NUT-FREE",
18
+ "soy-free": "SOY-FREE",
19
+ "organic": "ORGANIC",
20
+ "local": "LOCAL",
21
+ "no-refined-sugar": "NO REFINED SUGAR",
22
+ }
23
+
24
+ BADGE_IMPLIES: dict[str, set[str]] = {
25
+ "vegan": {"dairy-free"},
26
+ }
27
+
28
+
29
+ # ── Default dietary keyword sets (English food vocabulary) ──────────────────
30
+
31
+ DEFAULT_NON_VEGAN_KEYS: frozenset[str] = frozenset({
32
+ "egg",
33
+ "honey",
34
+ "gelatin",
35
+ "lard",
36
+ "meat",
37
+ "chicken",
38
+ "beef",
39
+ "pork",
40
+ "fish",
41
+ "shrimp",
42
+ "anchovy",
43
+ "milk",
44
+ "cream",
45
+ "butter",
46
+ "cheese",
47
+ "yogurt",
48
+ "sour cream",
49
+ "cream cheese",
50
+ "whey",
51
+ "casein",
52
+ })
53
+ """Default ingredient-name keywords that disqualify vegan."""
54
+
55
+ DEFAULT_DAIRY_KEYS: frozenset[str] = frozenset({
56
+ "milk",
57
+ "cream",
58
+ "butter",
59
+ "cheese",
60
+ "yogurt",
61
+ "sour cream",
62
+ "cream cheese",
63
+ "whey",
64
+ "casein",
65
+ })
66
+ """Default ingredient-name keywords that disqualify dairy-free."""
67
+
68
+ DEFAULT_GLUTEN_KEYS: frozenset[str] = frozenset({
69
+ "flour",
70
+ "wheat",
71
+ "barley",
72
+ "rye",
73
+ "spelt",
74
+ "semolina",
75
+ })
76
+ """Default ingredient-name keywords that disqualify gluten-free."""
77
+
78
+ _WHEAT_ALLERGEN_KEYS: frozenset[str] = frozenset({"flour", "wheat"})
79
+ """Allergen-map keys suppressed for gluten-free ingredients."""
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Allergen detection
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def detect_allergens(
88
+ recipe: Recipe,
89
+ allergy_map: dict[str, str],
90
+ *,
91
+ ingredient_properties: Callable[[Ingredient], frozenset[str]] | None = None,
92
+ ) -> list[str]:
93
+ """Detect allergens present in a recipe's ingredients.
94
+
95
+ Consult the *ingredient_properties* callback (if provided) per
96
+ ingredient to suppress wheat/dairy keyword matches when the
97
+ ingredient is known to be gluten-free or vegan.
98
+
99
+ Args:
100
+ recipe: The recipe to scan.
101
+ allergy_map: Mapping of lowercase keyword → display name
102
+ (e.g. ``{"milk": "Milk", "wheat": "Wheat"}``).
103
+ ingredient_properties: Optional callback
104
+ ``(ingredient) -> frozenset[str]`` for authoritative dietary
105
+ properties (e.g. ``{"vegan", "gluten-free"}``).
106
+
107
+ Returns:
108
+ Sorted list of allergen display names.
109
+ """
110
+ found: set[str] = set()
111
+
112
+ for ingredient in recipe.all_ingredients:
113
+ if ingredient.byproduct:
114
+ continue
115
+
116
+ name_lower = ingredient.name.lower()
117
+
118
+ props: frozenset[str] = frozenset()
119
+ if ingredient_properties is not None:
120
+ props = ingredient_properties(ingredient)
121
+
122
+ gf = "gluten-free" in props
123
+ vegan = "vegan" in props
124
+
125
+ for key, allergy in allergy_map.items():
126
+ if allergy in found:
127
+ continue
128
+
129
+ if key in _WHEAT_ALLERGEN_KEYS and gf and key != "wheat":
130
+ continue
131
+ if key == "wheat" and gf and "wheat" not in name_lower:
132
+ continue
133
+
134
+ if key in DEFAULT_DAIRY_KEYS and vegan:
135
+ continue
136
+
137
+ if key == "cream" and "cream of tartar" in name_lower:
138
+ continue
139
+
140
+ if key in name_lower:
141
+ found.add(allergy)
142
+
143
+ return sorted(found)
144
+
145
+
146
+ def detect_allergens_from_names(
147
+ ingredient_names: list[str],
148
+ allergy_map: dict[str, str],
149
+ *,
150
+ ingredient_properties_for_name: Callable[[str], frozenset[str]] | None = None,
151
+ ) -> list[str]:
152
+ """Detect allergens from a plain list of ingredient name strings.
153
+
154
+ Useful when working with flat ingredient lists rather than structured
155
+ ``Recipe`` objects.
156
+
157
+ Args:
158
+ ingredient_names: List of ingredient name strings.
159
+ allergy_map: Mapping of lowercase keyword → display name.
160
+ ingredient_properties_for_name: Optional callback
161
+ ``(name) -> frozenset[str]`` for authoritative dietary properties.
162
+
163
+ Returns:
164
+ Sorted list of allergen display names.
165
+ """
166
+ found: set[str] = set()
167
+
168
+ for name in ingredient_names:
169
+ name_lower = name.lower()
170
+
171
+ props: frozenset[str] = frozenset()
172
+ if ingredient_properties_for_name is not None:
173
+ props = ingredient_properties_for_name(name)
174
+
175
+ gf = "gluten-free" in props
176
+ vegan = "vegan" in props
177
+
178
+ for key, allergy in allergy_map.items():
179
+ if allergy in found:
180
+ continue
181
+
182
+ if key in _WHEAT_ALLERGEN_KEYS and gf and key != "wheat":
183
+ continue
184
+ if key == "wheat" and gf and "wheat" not in name_lower:
185
+ continue
186
+
187
+ if key in DEFAULT_DAIRY_KEYS and vegan:
188
+ continue
189
+
190
+ if key == "cream" and "cream of tartar" in name_lower:
191
+ continue
192
+
193
+ if key in name_lower:
194
+ found.add(allergy)
195
+
196
+ return sorted(found)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Badge detection
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def _resolve_badges(
205
+ raw_badges: list[str],
206
+ badge_display: dict[str, str] | None = None,
207
+ badge_implies: dict[str, set[str]] | None = None,
208
+ ) -> list[str]:
209
+ """Normalize badge slugs, suppress redundant ones, return display labels."""
210
+ _display = badge_display or BADGE_DISPLAY
211
+ _implies = badge_implies or BADGE_IMPLIES
212
+ normalized = [b.lower().strip() for b in raw_badges]
213
+ suppressed: set[str] = set()
214
+ for badge in normalized:
215
+ for redundant in _implies.get(badge, set()):
216
+ suppressed.add(redundant)
217
+
218
+ result: list[str] = []
219
+ for badge in normalized:
220
+ if badge not in suppressed:
221
+ result.append(_display.get(badge, badge.upper()))
222
+ return result
223
+
224
+
225
+ def detect_dietary_properties(
226
+ recipe: Recipe,
227
+ *,
228
+ ingredient_properties: Callable[[Ingredient], frozenset[str]] | None = None,
229
+ non_vegan_keys: frozenset[str] | None = None,
230
+ dairy_keys: frozenset[str] | None = None,
231
+ gluten_keys: frozenset[str] | None = None,
232
+ badge_display: dict[str, str] | None = None,
233
+ badge_implies: dict[str, set[str]] | None = None,
234
+ ) -> list[str]:
235
+ """Derive dietary/quality display badges from a recipe's ingredients.
236
+
237
+ A badge is awarded only when ALL non-byproduct ingredients qualify.
238
+
239
+ Supported built-in badges (in display order):
240
+ - ``VEGAN`` -- no animal products detected.
241
+ - ``DAIRY-FREE`` -- no dairy detected (suppressed when VEGAN present).
242
+ - ``GLUTEN-FREE`` -- no gluten/wheat detected.
243
+
244
+ Additional badges (e.g. ``keto``, ``paleo``) are supported via the
245
+ *ingredient_properties* callback -- any property key returned by the
246
+ callback is tracked the same way.
247
+
248
+ **Detection priority per ingredient**:
249
+
250
+ 1. If *ingredient_properties* returns a frozenset containing the property
251
+ name, that is authoritative (``True``).
252
+ 2. If *ingredient_properties* returns a non-empty frozenset without the
253
+ property, the ingredient is skipped for that property.
254
+ 3. If *ingredient_properties* returns ``frozenset()`` (or the callback
255
+ is ``None``), keyword disqualification is used: any ingredient name
256
+ matching *non_vegan_keys* / *dairy_keys* / *gluten_keys* disqualifies
257
+ the corresponding badge.
258
+
259
+ Args:
260
+ recipe: The recipe to scan.
261
+ ingredient_properties: Optional callback ``(ingredient) -> frozenset[str]``
262
+ that returns dietary properties for an ingredient from an
263
+ external data source (e.g. purchase tags, brand database).
264
+ non_vegan_keys: Ingredient-name keywords that disqualify vegan.
265
+ Defaults to the built-in English food vocabulary.
266
+ dairy_keys: Ingredient-name keywords that disqualify dairy-free.
267
+ Defaults to the built-in set.
268
+ gluten_keys: Ingredient-name keywords that disqualify gluten-free.
269
+ Defaults to the built-in set.
270
+ badge_display: Mapping of badge slug → display label.
271
+ Defaults to :data:`BADGE_DISPLAY`.
272
+ badge_implies: Mapping of badge slug → set of redundant badges.
273
+ Defaults to :data:`BADGE_IMPLIES`.
274
+
275
+ Returns:
276
+ List of display strings (e.g. ``["VEGAN", "GLUTEN-FREE"]``).
277
+ """
278
+ _non_vegan = non_vegan_keys or DEFAULT_NON_VEGAN_KEYS
279
+ _dairy = dairy_keys or DEFAULT_DAIRY_KEYS
280
+ _gluten = gluten_keys or DEFAULT_GLUTEN_KEYS
281
+ _badge_display = badge_display or BADGE_DISPLAY
282
+ _badge_implies = badge_implies or BADGE_IMPLIES
283
+
284
+ all_callback_keys: set[str] = set()
285
+
286
+ props: dict[str, bool] = {"vegan": True, "dairy-free": True, "gluten-free": True}
287
+
288
+ for ing in recipe.all_ingredients:
289
+ if ing.byproduct:
290
+ continue
291
+ name_lower = ing.name.lower()
292
+
293
+ cb_props: frozenset[str] = frozenset()
294
+ if ingredient_properties is not None:
295
+ cb_props = ingredient_properties(ing)
296
+ all_callback_keys.update(cb_props)
297
+
298
+ # Vegan
299
+ if cb_props:
300
+ pass
301
+ else:
302
+ for key in _non_vegan:
303
+ if key in name_lower:
304
+ props["vegan"] = False
305
+ break
306
+
307
+ # Dairy-free
308
+ if cb_props:
309
+ pass
310
+ else:
311
+ for key in _dairy:
312
+ if key in name_lower:
313
+ props["dairy-free"] = False
314
+ break
315
+
316
+ # Gluten-free
317
+ if cb_props:
318
+ pass
319
+ else:
320
+ for key in _gluten:
321
+ if key in name_lower:
322
+ props["gluten-free"] = False
323
+ break
324
+
325
+ # Additional properties from callback only (no keyword fallback)
326
+ for extra_key in all_callback_keys - {"vegan", "dairy-free", "gluten-free"}:
327
+ if extra_key not in props:
328
+ props[extra_key] = True
329
+ if not (cb_props and extra_key in cb_props):
330
+ props[extra_key] = False
331
+
332
+ raw_badges: list[str] = []
333
+ for prop_name in [
334
+ "vegan",
335
+ "dairy-free",
336
+ "gluten-free",
337
+ *sorted(all_callback_keys - {"vegan", "dairy-free", "gluten-free"}),
338
+ ]:
339
+ if props.get(prop_name, False):
340
+ raw_badges.append(prop_name)
341
+
342
+ return _resolve_badges(raw_badges, _badge_display, _badge_implies)