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

1"""Utility functions for recipe processing.""" 

2 

3import re 

4from fractions import Fraction 

5 

6 

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} 

29 

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} 

42 

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] 

57 

58 

59def decimal_to_fraction(value: float, tolerance: float = 0.05) -> str: 

60 """Convert a decimal to a common fraction string. 

61 

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 

65 

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) 

71 

72 # Split into whole and fractional parts 

73 whole = int(value) 

74 frac = value - whole 

75 

76 # If it's essentially a whole number, return it 

77 if frac < tolerance: 

78 return str(whole) if whole > 0 else '0' 

79 

80 # If fractional part is close to 1, round up 

81 if frac > (1 - tolerance): 

82 return str(whole + 1) 

83 

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 

92 

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 

101 

102 # Build the result 

103 if frac_str: 

104 if whole > 0: 

105 return f'{whole} {frac_str}' 

106 return frac_str 

107 

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('.') 

112 

113 

114def tidy_ingredient(ingredient: str) -> str: 

115 """Tidy up an ingredient string by converting decimals to fractions. 

116 

117 Converts impractical decimal quantities to readable fractions for 

118 appropriate units (cups, tablespoons, etc.) while leaving precise 

119 measurements (grams, ml) as-is. 

120 

121 Args: 

122 ingredient: An ingredient string like "0.666 cups flour" 

123 

124 Returns: 

125 Tidied string like "2/3 cups flour" 

126 

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 

137 

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()) 

142 

143 if not match: 

144 return ingredient 

145 

146 number_str = match.group(1) 

147 rest = match.group(2) 

148 

149 # Try to parse the number 

150 try: 

151 number = float(number_str) 

152 except ValueError: 

153 return ingredient 

154 

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 '' 

158 

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 ', ' ') 

165 

166 # For fraction units or unknown units, convert to fraction 

167 fraction_str = decimal_to_fraction(number) 

168 

169 return f'{fraction_str} {rest}'.strip() 

170 

171 

172def tidy_quantities(ingredients: list[str]) -> list[str]: 

173 """Tidy all ingredient quantities in a list. 

174 

175 Args: 

176 ingredients: List of ingredient strings 

177 

178 Returns: 

179 List with tidied quantities 

180 """ 

181 return [tidy_ingredient(ing) for ing in ingredients] 

← Back to Dashboard