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 Callable, List, Optional, Tuple
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
from .money_currency_digits import currency_digits ← from money.currency-digits ^1.0.0 · built alongside by fune
from .money_currency_digits_data import CURRENCY_DIGITS ← money.currency-digits’s rule data (^1.0.0) · built alongside by fune
from .money_parse_data import CURRENCY_SYMBOLS ← this capability’s own data, compiled from data/currency-symbols.json into the same file by fune build
from .money_parse_types import NumberStyle
MAX_SAFE = 9007199254740991
# Space, no-break space, narrow no-break space: what real documents use.
SPACES = (" ", " ", " ")
def _style_of(style: str) -> Tuple[Tuple[str, ...], str]:
if style == "comma-dot":
return (",",), "."
if style == "dot-comma":
return (".",), ","
if style == "space-comma":
return SPACES, ","
if style == "apostrophe-dot":
return ("'", "’"), "."
raise ValueError('unknown number style "%s"' % (style,))
def _is_digits(s: str) -> bool:
# Not str.isdigit: that accepts other scripts' digits and superscripts.
return s != "" and all(ch in "0123456789" for ch in s)
def _longest_symbol(matches: Callable[[str], bool]) -> Optional[str]:
"""The longest known symbol or ISO code that ``matches``, or None."""
best: Optional[str] = None
candidates = [row.symbol for row in CURRENCY_SYMBOLS] + [row.code for row in CURRENCY_DIGITS]
for symbol in candidates:
if matches(symbol) and (best is None or len(symbol) > len(best)):
best = symbol
return best
def _split_number(s: str, groups: Tuple[str, ...], decimal: str) -> Optional[Tuple[str, str]]:
"""Whole digits and fraction digits, or None when the text is not a number in this style."""
parts = s.split(decimal)
if len(parts) > 2:
return None
fraction = parts[1] if len(parts) == 2 else ""
if len(parts) == 2 and not _is_digits(fraction):
return None
chunks: List[str] = [""]
for ch in parts[0]:
if ch in groups:
chunks.append("")
else:
chunks[-1] += ch
if not all(_is_digits(c) for c in chunks):
return None
if len(chunks) > 1:
# Real thousands grouping only: 1-3 digits without a leading zero, then threes.
first, rest = chunks[0], chunks[1:]
if len(first) > 3 or first.startswith("0"):
return None
if not all(len(c) == 3 for c in rest):
return None
return "".join(chunks), fraction
def parse_money(text: str, currency: str, style: NumberStyle) -> Money:
"""Parse an amount written as text into integer minor units.
Strict by design: the caller names the currency and the number style, and
anything ambiguous (too many decimals, odd grouping, a symbol for another
currency) is an error rather than a guess.
"""
groups, decimal = _style_of(style)
digits = currency_digits(currency)
s = text.strip(" ")
negative = False
if s.startswith("-"):
negative = True
s = s[1:]
symbol = _longest_symbol(lambda k: s.startswith(k))
if symbol is not None:
s = s[len(symbol):]
if s[:1] in SPACES and s != "":
s = s[1:]
if not negative and s.startswith("-"):
negative = True
s = s[1:]
else:
symbol = _longest_symbol(lambda k: s.endswith(k))
if symbol is not None:
s = s[: len(s) - len(symbol)]
if s != "" and s[-1] in SPACES:
s = s[:-1]
if symbol is not None and symbol != currency and not any(
r.code == currency and r.symbol == symbol for r in CURRENCY_SYMBOLS
):
raise ValueError('currency symbol "%s" does not match %s' % (symbol, currency))
number = _split_number(s, groups, decimal)
if number is None:
raise ValueError('"%s" is not a valid amount' % (text,))
whole, fraction = number
if len(fraction) > digits:
raise ValueError("too many decimal places for %s (at most %d)" % (currency, digits))
minor = int(whole + fraction.ljust(digits, "0"))
if minor > MAX_SAFE:
raise ValueError('amount is too large: "%s" exceeds 2^53 - 1 minor units' % (text,))
return money(-minor if negative else minor, currency)