Coverage for apps / recipes / utils.py: 84%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""Utility functions for recipe processing."""
3import re
4from fractions import Fraction
7# Units where decimals should be converted to fractions
8# These are "practical" units where fractions are more readable
9FRACTION_UNITS = {
10 # Volume (US customary)
11 "cup",
12 "cups",
13 "tablespoon",
14 "tablespoons",
15 "tbsp",
16 "teaspoon",
17 "teaspoons",
18 "tsp",
19 "pint",
20 "pints",
21 "quart",
22 "quarts",
23 "gallon",
24 "gallons",
25 # Countable items (no unit or implied whole)
26 "piece",
27 "pieces",
28 "slice",
29 "slices",
30 "clove",
31 "cloves",
32 "sprig",
33 "sprigs",
34 "bunch",
35 "bunches",
36 "head",
37 "heads",
38 "stalk",
39 "stalks",
40 "can",
41 "cans",
42 "package",
43 "packages",
44 "stick",
45 "sticks",
46}
48# Units where decimals should be kept (precise measurements)
49DECIMAL_UNITS = {
50 # Metric weight
51 "g",
52 "gram",
53 "grams",
54 "kg",
55 "kilogram",
56 "kilograms",
57 # Metric volume
58 "ml",
59 "milliliter",
60 "milliliters",
61 "l",
62 "liter",
63 "liters",
64 "litre",
65 "litres",
66 # Imperial weight (often used precisely)
67 "oz",
68 "ounce",
69 "ounces",
70 "lb",
71 "lbs",
72 "pound",
73 "pounds",
74}
76# Common decimal to fraction mappings (tolerance-based matching)
77FRACTION_MAP = [
78 (1 / 8, "1/8"),
79 (1 / 6, "1/6"),
80 (1 / 4, "1/4"),
81 (1 / 3, "1/3"),
82 (3 / 8, "3/8"),
83 (1 / 2, "1/2"),
84 (5 / 8, "5/8"),
85 (2 / 3, "2/3"),
86 (3 / 4, "3/4"),
87 (5 / 6, "5/6"),
88 (7 / 8, "7/8"),
89]
92def _split_whole_and_fraction(value: float, tolerance: float) -> tuple[int, float] | None:
93 """Split value into whole and fractional parts, handling edge cases.
95 Returns None if the value resolves to a whole number (or rounds up to one).
96 Otherwise returns (whole_part, fractional_part).
97 """
98 whole = int(value)
99 frac = value - whole
100 if frac < tolerance:
101 return None # essentially whole
102 if frac > (1 - tolerance):
103 return None # rounds up to next whole
104 return whole, frac
107def _find_closest_fraction(frac: float, tolerance: float) -> str | None:
108 """Find the closest common fraction string within tolerance.
110 Falls back to Python's Fraction with denominator limit if no map entry
111 matches.
112 """
113 candidates = [(abs(frac - target), string) for target, string in FRACTION_MAP]
114 best_diff, best_str = min(candidates, key=lambda c: c[0])
115 if best_diff < tolerance:
116 return best_str
118 try:
119 f = Fraction(frac).limit_denominator(8)
120 if f.numerator > 0 and f.denominator <= 8:
121 return f"{f.numerator}/{f.denominator}"
122 except (ValueError, ZeroDivisionError):
123 pass
124 return None
127def _format_fraction_result(whole: int, frac_str: str | None, value: float) -> str:
128 """Combine whole number and fraction string into final display value."""
129 if frac_str:
130 return f"{whole} {frac_str}" if whole > 0 else frac_str
131 return f"{value:.2f}".rstrip("0").rstrip(".")
134def decimal_to_fraction(value: float, tolerance: float = 0.05) -> str:
135 """Convert a decimal to a common fraction string.
137 Args:
138 value: The decimal value to convert (e.g., 0.5, 1.333)
139 tolerance: How close the value must be to match a fraction
141 Returns:
142 A string like "1/2", "1 1/3", or the original number if no match
143 """
144 if value <= 0:
145 return str(value)
147 parts = _split_whole_and_fraction(value, tolerance)
148 if parts is None:
149 whole_rounded = int(value) if (value - int(value)) < tolerance else int(value) + 1
150 return str(whole_rounded) if whole_rounded > 0 else "0"
152 whole, frac = parts
153 frac_str = _find_closest_fraction(frac, tolerance)
154 return _format_fraction_result(whole, frac_str, value)
157def tidy_ingredient(ingredient: str) -> str:
158 """Tidy up an ingredient string by converting decimals to fractions.
160 Converts impractical decimal quantities to readable fractions for
161 appropriate units (cups, tablespoons, etc.) while leaving precise
162 measurements (grams, ml) as-is.
164 Args:
165 ingredient: An ingredient string like "0.666 cups flour"
167 Returns:
168 Tidied string like "2/3 cups flour"
170 Examples:
171 >>> tidy_ingredient("0.5 cup sugar")
172 "1/2 cup sugar"
173 >>> tidy_ingredient("1.333 cups flour")
174 "1 1/3 cups flour"
175 >>> tidy_ingredient("225g butter")
176 "225g butter" # Left as-is (metric)
177 """
178 if not ingredient:
179 return ingredient
181 # Pattern to match a number (possibly decimal) at the start or after spaces
182 # Captures: (number)(optional space)(rest of string)
183 pattern = r"^(\d+\.?\d*)\s*(.*)$"
184 match = re.match(pattern, ingredient.strip())
186 if not match:
187 return ingredient
189 number_str = match.group(1)
190 rest = match.group(2)
192 # Try to parse the number
193 try:
194 number = float(number_str)
195 except ValueError:
196 return ingredient
198 # Check if this looks like a precise unit (should keep decimal)
199 rest_lower = rest.lower()
200 first_word = rest_lower.split()[0] if rest_lower.split() else ""
202 # If it's a decimal unit, keep as-is but clean up excessive precision
203 if first_word in DECIMAL_UNITS:
204 # Just round to reasonable precision for display
205 if number == int(number):
206 return f"{int(number)} {rest}"
207 return f"{number:.1f} {rest}".replace(".0 ", " ")
209 # For fraction units or unknown units, convert to fraction
210 fraction_str = decimal_to_fraction(number)
212 return f"{fraction_str} {rest}".strip()
215def tidy_quantities(ingredients: list[str]) -> list[str]:
216 """Tidy all ingredient quantities in a list.
218 Args:
219 ingredients: List of ingredient strings
221 Returns:
222 List with tidied quantities
223 """
224 return [tidy_ingredient(ing) for ing in ingredients]