Functional Weave
Code in Python

hospitality.recipe-scale@1.0.0

impl/python.py

3,893 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from typing import List, Sequence, Tuple

from .hospitality_recipe_scale_types import RecipeQuantity
from .units_convert import convert_units  ← from units.convert ^1.0.0 · built alongside by fune

_DECIMAL = re.compile(r"^[0-9]+(\.[0-9]+)?$")
_METRIC = {
    "mg": ("g", "kg"),
    "g": ("g", "kg"),
    "kg": ("g", "kg"),
    "mL": ("mL", "L"),
    "L": ("mL", "L"),
}


def _parse_decimal(text: str) -> Tuple[int, int]:
    whole, _, fraction = text.partition(".")
    return int(whole + fraction), 10 ** len(fraction)


def _half_up(numerator: int, denominator: int) -> int:
    return (numerator * 2 + denominator) // (denominator * 2)


def _format_scaled(value: int, decimals: int) -> str:
    unit = 10**decimals
    whole = str(value // unit)
    if decimals == 0:
        return whole
    fraction = str(value % unit).rjust(decimals, "0").rstrip("0")
    return whole if fraction == "" else "%s.%s" % (whole, fraction)


def _check_portions(name: str, value: object) -> None:
    if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 10000:
        raise ValueError("%s must be a whole number from 1 to 10000, received %s" % (name, value))


def scale_recipe(ingredients: Sequence[RecipeQuantity], from_portions: int, to_portions: int) -> List[RecipeQuantity]:
    """Scale a recipe from one number of portions to another.

    The scaling is exact (decimal text times a fraction), and each quantity is
    rounded once, to the precision a kitchen weighs to: whole grams or
    millilitres from 10 up, one decimal place from 1 to 10, two below 1.
    Metric amounts move between g and kg, mL and L, at 1000. Counted items
    (each) round up, since half an egg short is short. Any other unit keeps its
    name and is rounded to two decimal places.
    """
    _check_portions("fromPortions", from_portions)
    _check_portions("toPortions", to_portions)
    result: List[RecipeQuantity] = []
    for ingredient in ingredients:
        name, quantity, unit = ingredient.name, ingredient.quantity, ingredient.unit
        if not isinstance(quantity, str) or not _DECIMAL.match(quantity):
            raise ValueError('quantity must be a non-negative decimal like "12.5", received "%s" for "%s"' % (quantity, name))
        whole, _, fraction = quantity.partition(".")
        trimmed_fraction = fraction.rstrip("0")
        if len((whole + trimmed_fraction).lstrip("0")) > 15 or len(trimmed_fraction) > 15:
            raise ValueError(
                'quantity "%s" for "%s" has too many digits: at most 15 significant digits and 15 decimal places'
                % (quantity, name)
            )
        if unit == "":
            raise ValueError('unit must not be empty for "%s"' % (name,))

        if unit in _METRIC:
            small, large = _METRIC[unit]
            n, scale = _parse_decimal(convert_units(quantity, unit, small, 12))
            num = n * to_portions
            den = scale * from_portions
            decimals = 2 if num < den else 1 if num < den * 10 else 0
            rounded = _half_up(num * 10**decimals, den)
            if decimals == 0 and rounded >= 1000:
                result.append(RecipeQuantity(name=name, quantity=convert_units(str(rounded), small, large, 3), unit=large))
            else:
                result.append(RecipeQuantity(name=name, quantity=_format_scaled(rounded, decimals), unit=small))
            continue
        # Trailing zeros dropped, so "1.000000000000000000000" stays small.
        n, scale = _parse_decimal(whole if trimmed_fraction == "" else "%s.%s" % (whole, trimmed_fraction))
        num = n * to_portions
        den = scale * from_portions
        if unit == "each":
            result.append(RecipeQuantity(name=name, quantity=str((num + den - 1) // den), unit=unit))
        else:
            result.append(RecipeQuantity(name=name, quantity=_format_scaled(_half_up(num * 100, den), 2), unit=unit))
    return result