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

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", 

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} 

47 

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} 

75 

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] 

90 

91 

92def _split_whole_and_fraction(value: float, tolerance: float) -> tuple[int, float] | None: 

93 """Split value into whole and fractional parts, handling edge cases. 

94 

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 

105 

106 

107def _find_closest_fraction(frac: float, tolerance: float) -> str | None: 

108 """Find the closest common fraction string within tolerance. 

109 

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 

117 

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 

125 

126 

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

132 

133 

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

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

136 

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 

140 

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) 

146 

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" 

151 

152 whole, frac = parts 

153 frac_str = _find_closest_fraction(frac, tolerance) 

154 return _format_fraction_result(whole, frac_str, value) 

155 

156 

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

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

159 

160 Converts impractical decimal quantities to readable fractions for 

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

162 measurements (grams, ml) as-is. 

163 

164 Args: 

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

166 

167 Returns: 

168 Tidied string like "2/3 cups flour" 

169 

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 

180 

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

185 

186 if not match: 

187 return ingredient 

188 

189 number_str = match.group(1) 

190 rest = match.group(2) 

191 

192 # Try to parse the number 

193 try: 

194 number = float(number_str) 

195 except ValueError: 

196 return ingredient 

197 

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

201 

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

208 

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

210 fraction_str = decimal_to_fraction(number) 

211 

212 return f"{fraction_str} {rest}".strip() 

213 

214 

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

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

217 

218 Args: 

219 ingredients: List of ingredient strings 

220 

221 Returns: 

222 List with tidied quantities 

223 """ 

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

← Back to Dashboard