Functional Weave
Code in TypeScript

units.parse-quantity@1.0.0

impl/python.py

7,089 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.

from typing import List, Tuple

from .units_convert_data import UNITS, UnitDefinition  ← units.convert’s rule data (^1.0.0) · built alongside by fune
from .units_parse_quantity_data import UNIT_ALIASES  ← this capability’s own data, compiled from data/aliases.json into the same file by fune build
from .units_parse_quantity_types import Quantity

MAX_DIGITS = 15

# Explicit sets rather than str.isspace()/str.lower(): those differ from the
# other languages on non-ASCII input, and these must not.
_SPACE = " \t\n\r"
_DIGITS = "0123456789"
_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")


def _lower(s: str) -> str:
    return s.translate(_LOWER)


def _all_digits(s: str) -> bool:
    return len(s) > 0 and all(c in _DIGITS for c in s)


def _trim(s: str) -> str:
    return s.strip(_SPACE)


def _well_grouped(whole: str) -> bool:
    if "," not in whole:
        return _all_digits(whole)
    groups = whole.split(",")
    return 1 <= len(groups[0]) <= 3 and _all_digits(groups[0]) and all(len(g) == 3 and _all_digits(g) for g in groups[1:])


def _parse_number(written: str) -> Tuple[int, str]:
    pieces = written.split(".")
    plain = written.replace(",", "")
    plain_pieces = plain.split(".")
    plain_ok = len(plain_pieces) <= 2 and _all_digits(plain_pieces[0]) and (len(plain_pieces) == 1 or _all_digits(plain_pieces[1]))
    ok = len(pieces) <= 2 and _well_grouped(pieces[0]) and (len(pieces) == 1 or _all_digits(pieces[1]))
    if not ok:
        if "," in written and plain_ok:
            raise ValueError('bad thousands grouping in "%s"' % (written,))
        raise ValueError('malformed number "%s"' % (written,))
    whole = plain_pieces[0].lstrip("0")
    fraction = (plain_pieces[1] if len(plain_pieces) > 1 else "").rstrip("0")
    if len((whole + fraction).lstrip("0")) > MAX_DIGITS or len(fraction) > MAX_DIGITS:
        raise ValueError(
            'number "%s" has too many digits: at most 15 significant digits and 15 decimal places' % (written,)
        )
    return int(whole) if whole else 0, fraction


def _unit_by_symbol(symbol: str) -> UnitDefinition:
    for u in UNITS:
        if u.symbol == symbol:
            return u
    # Only reachable if the alias table and units.convert drift apart.
    raise ValueError('alias table names unit "%s", which units.convert does not define' % (symbol,))


def _resolve_unit(text: str) -> UnitDefinition:
    for u in UNITS:
        if u.symbol == text:
            return u
    for a in UNIT_ALIASES:
        if a.alias == text:
            if a.symbol is None:
                raise ValueError('ambiguous unit "%s": use %s' % (text, a.choices))
            return _unit_by_symbol(a.symbol)
    # Case-insensitive fallback, so "KG" and "Feet" work. Where two spellings
    # differ only in case and mean different things ("cal" and food "Cal"),
    # the ambiguity wins rather than a guess.
    lower = _lower(text)
    for a in UNIT_ALIASES:
        if a.symbol is None and _lower(a.alias) == lower:
            raise ValueError('ambiguous unit "%s": use %s' % (text, a.choices))
    symbols: List[str] = []
    for u in UNITS:
        if _lower(u.symbol) == lower and u.symbol not in symbols:
            symbols.append(u.symbol)
    for a in UNIT_ALIASES:
        if a.symbol is not None and _lower(a.alias) == lower and a.symbol not in symbols:
            symbols.append(a.symbol)
    if len(symbols) == 1:
        return _unit_by_symbol(symbols[0])
    if len(symbols) > 1:
        raise ValueError('ambiguous unit "%s": letter case matters, use %s' % (text, " or ".join(symbols)))
    raise ValueError('unknown unit "%s"' % (text,))


def _format(negative: bool, whole: int, fraction: str) -> str:
    text = str(whole) + ("." + fraction if fraction else "")
    return "-" + text if negative and (whole != 0 or fraction) else text


def parse_quantity(text: str) -> Quantity:
    """Parse a written quantity into an exact decimal value and a canonical unit.

    A compound such as "12 ft 6 in" is summed into its last unit ("150" in)
    using exact whole-number ratios, which is why mixing systems ("1 ft 2 cm")
    is refused rather than approximated.
    """
    if not isinstance(text, str):
        raise TypeError("quantity text must be a string")
    src = _trim(text)
    if src == "":
        raise ValueError("quantity text is empty")

    negative = False
    parts: List[Tuple[str, int, str, UnitDefinition]] = []
    i = 0
    n = len(src)
    while i < n:
        while i < n and src[i] in _SPACE:
            i += 1
        start = i
        if i < n and src[i] == "-":
            if parts:
                raise ValueError('only the first number may be negative: "%s"' % (src,))
            negative = True
            i += 1
        number_start = i
        while i < n and (src[i] in _DIGITS or src[i] in ",."):
            i += 1
        written = src[number_start:i]
        if written == "":
            raise ValueError('expected a number at "%s"' % (src[start:],))
        whole, fraction = _parse_number(written)
        unit_start = i
        while i < n and src[i] not in _DIGITS and src[i] != "-":
            i += 1
        unit_text = " ".join(t for t in _trim(src[unit_start:i]).replace("\t", " ").replace("\n", " ").replace("\r", " ").split(" ") if t)
        if unit_text == "":
            raise ValueError('missing unit after "%s"' % (written,))
        parts.append((written, whole, fraction, _resolve_unit(unit_text)))

    _, last_whole, last_fraction, last_unit = parts[-1]
    if len(parts) == 1:
        return Quantity(value=_format(negative, last_whole, last_fraction), unit=last_unit.symbol, dimension=last_unit.dimension)

    for k in range(1, len(parts)):
        prev = parts[k - 1][3]
        cur = parts[k][3]
        if cur.dimension != prev.dimension:
            raise ValueError(
                "cannot combine %s (%s) and %s (%s) in one quantity" % (prev.symbol, prev.dimension, cur.symbol, cur.dimension)
            )
        if cur.dimension == "temperature":
            raise ValueError('temperatures cannot be compound: "%s"' % (src,))
        if cur.symbol == prev.symbol:
            raise ValueError('unit "%s" appears twice in "%s"' % (cur.symbol, src))
        if cur.factor_numerator * prev.factor_denominator >= prev.factor_numerator * cur.factor_denominator:
            raise ValueError(
                'units must run from largest to smallest: "%s" cannot come before "%s"' % (prev.symbol, cur.symbol)
            )

    total = last_whole
    for _, whole, fraction, unit in parts[:-1]:
        if fraction:
            raise ValueError('only the last number of a compound quantity may have a fraction: "%s"' % (src,))
        num = unit.factor_numerator * last_unit.factor_denominator
        den = unit.factor_denominator * last_unit.factor_numerator
        if num % den != 0:
            raise ValueError(
                "cannot combine %s and %s exactly: 1 %s is not a whole number of %s"
                % (unit.symbol, last_unit.symbol, unit.symbol, last_unit.symbol)
            )
        total += whole * (num // den)
    return Quantity(value=_format(negative, total, last_fraction), unit=last_unit.symbol, dimension=last_unit.dimension)