from typing import List, Tuple from .units_convert_data import UNITS, UnitDefinition from .units_parse_quantity_data import UNIT_ALIASES 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)