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

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 decimal_to_fraction(value: float, tolerance: float = 0.05) -> str: 

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

94 

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 

98 

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) 

104 

105 # Split into whole and fractional parts 

106 whole = int(value) 

107 frac = value - whole 

108 

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

110 if frac < tolerance: 

111 return str(whole) if whole > 0 else "0" 

112 

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

114 if frac > (1 - tolerance): 

115 return str(whole + 1) 

116 

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 

125 

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 

134 

135 # Build the result 

136 if frac_str: 

137 if whole > 0: 

138 return f"{whole} {frac_str}" 

139 return frac_str 

140 

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

145 

146 

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

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

149 

150 Converts impractical decimal quantities to readable fractions for 

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

152 measurements (grams, ml) as-is. 

153 

154 Args: 

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

156 

157 Returns: 

158 Tidied string like "2/3 cups flour" 

159 

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 

170 

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

175 

176 if not match: 

177 return ingredient 

178 

179 number_str = match.group(1) 

180 rest = match.group(2) 

181 

182 # Try to parse the number 

183 try: 

184 number = float(number_str) 

185 except ValueError: 

186 return ingredient 

187 

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

191 

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

198 

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

200 fraction_str = decimal_to_fraction(number) 

201 

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

203 

204 

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

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

207 

208 Args: 

209 ingredients: List of ingredient strings 

210 

211 Returns: 

212 List with tidied quantities 

213 """ 

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

← Back to Dashboard