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)