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/__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)
|