Coverage for apps / recipes / utils.py: 14%
58 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +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', 'cups',
12 'tablespoon', 'tablespoons', 'tbsp',
13 'teaspoon', 'teaspoons', 'tsp',
14 'pint', 'pints',
15 'quart', 'quarts',
16 'gallon', 'gallons',
17 # Countable items (no unit or implied whole)
18 'piece', 'pieces',
19 'slice', 'slices',
20 'clove', 'cloves',
21 'sprig', 'sprigs',
22 'bunch', 'bunches',
23 'head', 'heads',
24 'stalk', 'stalks',
25 'can', 'cans',
26 'package', 'packages',
27 'stick', 'sticks',
28}
30# Units where decimals should be kept (precise measurements)
31DECIMAL_UNITS = {
32 # Metric weight
33 'g', 'gram', 'grams',
34 'kg', 'kilogram', 'kilograms',
35 # Metric volume
36 'ml', 'milliliter', 'milliliters',
37 'l', 'liter', 'liters', 'litre', 'litres',
38 # Imperial weight (often used precisely)
39 'oz', 'ounce', 'ounces',
40 'lb', 'lbs', 'pound', 'pounds',
41}
43# Common decimal to fraction mappings (tolerance-based matching)
44FRACTION_MAP = [
45 (1/8, '1/8'),
46 (1/6, '1/6'),
47 (1/4, '1/4'),
48 (1/3, '1/3'),
49 (3/8, '3/8'),
50 (1/2, '1/2'),
51 (5/8, '5/8'),
52 (2/3, '2/3'),
53 (3/4, '3/4'),
54 (5/6, '5/6'),
55 (7/8, '7/8'),
56]
59def decimal_to_fraction(value: float, tolerance: float = 0.05) -> str:
60 """Convert a decimal to a common fraction string.
62 Args:
63 value: The decimal value to convert (e.g., 0.5, 1.333)
64 tolerance: How close the value must be to match a fraction
66 Returns:
67 A string like "1/2", "1 1/3", or the original number if no match
68 """
69 if value <= 0:
70 return str(value)
72 # Split into whole and fractional parts
73 whole = int(value)
74 frac = value - whole
76 # If it's essentially a whole number, return it
77 if frac < tolerance:
78 return str(whole) if whole > 0 else '0'
80 # If fractional part is close to 1, round up
81 if frac > (1 - tolerance):
82 return str(whole + 1)
84 # Find the CLOSEST matching fraction (not just first within tolerance)
85 frac_str = None
86 best_diff = tolerance
87 for target, string in FRACTION_MAP:
88 diff = abs(frac - target)
89 if diff < best_diff:
90 best_diff = diff
91 frac_str = string
93 # If no match found, try Python's Fraction with limit
94 if frac_str is None:
95 try:
96 f = Fraction(frac).limit_denominator(8)
97 if f.numerator > 0 and f.denominator <= 8:
98 frac_str = f'{f.numerator}/{f.denominator}'
99 except (ValueError, ZeroDivisionError):
100 pass
102 # Build the result
103 if frac_str:
104 if whole > 0:
105 return f'{whole} {frac_str}'
106 return frac_str
108 # No fraction match - return rounded decimal
109 if whole > 0:
110 return f'{value:.2f}'.rstrip('0').rstrip('.')
111 return f'{value:.2f}'.rstrip('0').rstrip('.')
114def tidy_ingredient(ingredient: str) -> str:
115 """Tidy up an ingredient string by converting decimals to fractions.
117 Converts impractical decimal quantities to readable fractions for
118 appropriate units (cups, tablespoons, etc.) while leaving precise
119 measurements (grams, ml) as-is.
121 Args:
122 ingredient: An ingredient string like "0.666 cups flour"
124 Returns:
125 Tidied string like "2/3 cups flour"
127 Examples:
128 >>> tidy_ingredient("0.5 cup sugar")
129 "1/2 cup sugar"
130 >>> tidy_ingredient("1.333 cups flour")
131 "1 1/3 cups flour"
132 >>> tidy_ingredient("225g butter")
133 "225g butter" # Left as-is (metric)
134 """
135 if not ingredient:
136 return ingredient
138 # Pattern to match a number (possibly decimal) at the start or after spaces
139 # Captures: (number)(optional space)(rest of string)
140 pattern = r'^(\d+\.?\d*)\s*(.*)$'
141 match = re.match(pattern, ingredient.strip())
143 if not match:
144 return ingredient
146 number_str = match.group(1)
147 rest = match.group(2)
149 # Try to parse the number
150 try:
151 number = float(number_str)
152 except ValueError:
153 return ingredient
155 # Check if this looks like a precise unit (should keep decimal)
156 rest_lower = rest.lower()
157 first_word = rest_lower.split()[0] if rest_lower.split() else ''
159 # If it's a decimal unit, keep as-is but clean up excessive precision
160 if first_word in DECIMAL_UNITS:
161 # Just round to reasonable precision for display
162 if number == int(number):
163 return f'{int(number)} {rest}'
164 return f'{number:.1f} {rest}'.replace('.0 ', ' ')
166 # For fraction units or unknown units, convert to fraction
167 fraction_str = decimal_to_fraction(number)
169 return f'{fraction_str} {rest}'.strip()
172def tidy_quantities(ingredients: list[str]) -> list[str]:
173 """Tidy all ingredient quantities in a list.
175 Args:
176 ingredients: List of ingredient strings
178 Returns:
179 List with tidied quantities
180 """
181 return [tidy_ingredient(ing) for ing in ingredients]