Coverage for apps / recipes / utils.py: 14%
58 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +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 decimal_to_fraction(value: float, tolerance: float = 0.05) -> str:
93 """Convert a decimal to a common fraction string.
95 Args:
96 value: The decimal value to convert (e.g., 0.5, 1.333)
97 tolerance: How close the value must be to match a fraction
99 Returns:
100 A string like "1/2", "1 1/3", or the original number if no match
101 """
102 if value <= 0:
103 return str(value)
105 # Split into whole and fractional parts
106 whole = int(value)
107 frac = value - whole
109 # If it's essentially a whole number, return it
110 if frac < tolerance:
111 return str(whole) if whole > 0 else "0"
113 # If fractional part is close to 1, round up
114 if frac > (1 - tolerance):
115 return str(whole + 1)
117 # Find the CLOSEST matching fraction (not just first within tolerance)
118 frac_str = None
119 best_diff = tolerance
120 for target, string in FRACTION_MAP:
121 diff = abs(frac - target)
122 if diff < best_diff:
123 best_diff = diff
124 frac_str = string
126 # If no match found, try Python's Fraction with limit
127 if frac_str is None:
128 try:
129 f = Fraction(frac).limit_denominator(8)
130 if f.numerator > 0 and f.denominator <= 8:
131 frac_str = f"{f.numerator}/{f.denominator}"
132 except (ValueError, ZeroDivisionError):
133 pass
135 # Build the result
136 if frac_str:
137 if whole > 0:
138 return f"{whole} {frac_str}"
139 return frac_str
141 # No fraction match - return rounded decimal
142 if whole > 0:
143 return f"{value:.2f}".rstrip("0").rstrip(".")
144 return f"{value:.2f}".rstrip("0").rstrip(".")
147def tidy_ingredient(ingredient: str) -> str:
148 """Tidy up an ingredient string by converting decimals to fractions.
150 Converts impractical decimal quantities to readable fractions for
151 appropriate units (cups, tablespoons, etc.) while leaving precise
152 measurements (grams, ml) as-is.
154 Args:
155 ingredient: An ingredient string like "0.666 cups flour"
157 Returns:
158 Tidied string like "2/3 cups flour"
160 Examples:
161 >>> tidy_ingredient("0.5 cup sugar")
162 "1/2 cup sugar"
163 >>> tidy_ingredient("1.333 cups flour")
164 "1 1/3 cups flour"
165 >>> tidy_ingredient("225g butter")
166 "225g butter" # Left as-is (metric)
167 """
168 if not ingredient:
169 return ingredient
171 # Pattern to match a number (possibly decimal) at the start or after spaces
172 # Captures: (number)(optional space)(rest of string)
173 pattern = r"^(\d+\.?\d*)\s*(.*)$"
174 match = re.match(pattern, ingredient.strip())
176 if not match:
177 return ingredient
179 number_str = match.group(1)
180 rest = match.group(2)
182 # Try to parse the number
183 try:
184 number = float(number_str)
185 except ValueError:
186 return ingredient
188 # Check if this looks like a precise unit (should keep decimal)
189 rest_lower = rest.lower()
190 first_word = rest_lower.split()[0] if rest_lower.split() else ""
192 # If it's a decimal unit, keep as-is but clean up excessive precision
193 if first_word in DECIMAL_UNITS:
194 # Just round to reasonable precision for display
195 if number == int(number):
196 return f"{int(number)} {rest}"
197 return f"{number:.1f} {rest}".replace(".0 ", " ")
199 # For fraction units or unknown units, convert to fraction
200 fraction_str = decimal_to_fraction(number)
202 return f"{fraction_str} {rest}".strip()
205def tidy_quantities(ingredients: list[str]) -> list[str]:
206 """Tidy all ingredient quantities in a list.
208 Args:
209 ingredients: List of ingredient strings
211 Returns:
212 List with tidied quantities
213 """
214 return [tidy_ingredient(ing) for ing in ingredients]