mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: Jean Massucatto <massucattoj@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
22 KiB
Python
608 lines
22 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""
|
|
Server-side port of the d3-format based number and currency formatters used by
|
|
the Table and Pivot Table chart plugins.
|
|
|
|
Report notifications that embed a chart as text build the table in Python and
|
|
have no access to the frontend formatters, so chart number/currency format
|
|
configuration has to be reproduced here to render the same values an end user
|
|
sees in the browser.
|
|
|
|
Only d3-format specifiers (and the ``SMART_NUMBER`` pseudo-formats) are ported.
|
|
The duration, memory, and length formatters depend on separate frontend
|
|
factories and are explicitly rejected, causing the public wrapper to preserve
|
|
the raw value rather than silently misformat it. The fill/align/zero/width d3
|
|
flags are likewise rejected because report text has no equivalent of the
|
|
frontend's padding behavior. Accounting-parenthesis and space-sign modes are
|
|
supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from functools import lru_cache
|
|
from typing import Any, Iterable
|
|
|
|
from babel.numbers import format_currency, get_currency_symbol
|
|
from flask import current_app
|
|
from flask_babel import get_locale
|
|
|
|
SMART_NUMBER: str = "SMART_NUMBER"
|
|
SMART_NUMBER_SIGNED: str = "SMART_NUMBER_SIGNED"
|
|
AUTO_CURRENCY: str = "AUTO"
|
|
|
|
UNSUPPORTED_FRONTEND_PRESETS: frozenset[str] = frozenset(
|
|
{
|
|
"DURATION",
|
|
"DURATION_SUB",
|
|
"DURATION_COL",
|
|
"MEMORY_DECIMAL",
|
|
"MEMORY_BINARY",
|
|
"MEMORY_TRANSFER_RATE_DECIMAL",
|
|
"MEMORY_TRANSFER_RATE_BINARY",
|
|
"LENGTH",
|
|
"LENGTH_CM_KM",
|
|
"LENGTH_CM_M",
|
|
}
|
|
)
|
|
|
|
DEFAULT_LOCALE: str = "en"
|
|
CURRENCY_SYMBOL_LOCALE: str = "en_US"
|
|
|
|
# SI prefixes keyed by their power-of-1000 exponent, mirroring d3-format.
|
|
SI_PREFIXES: dict[int, str] = {
|
|
-8: "y",
|
|
-7: "z",
|
|
-6: "a",
|
|
-5: "f",
|
|
-4: "p",
|
|
-3: "n",
|
|
-2: "µ",
|
|
-1: "m",
|
|
0: "",
|
|
1: "k",
|
|
2: "M",
|
|
3: "G",
|
|
4: "T",
|
|
5: "P",
|
|
6: "E",
|
|
7: "Z",
|
|
8: "Y",
|
|
}
|
|
|
|
# d3-format specifier grammar:
|
|
# [[fill]align][sign][symbol][0][width][,][.precision][~][type]
|
|
D3_FORMAT_RE: re.Pattern[str] = re.compile(
|
|
r"^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(?:\.(\d+))?(~)?([a-z%])?$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def resolve_auto_currency(
|
|
currency: dict[str, Any],
|
|
detected_currency: str | None,
|
|
currency_context: Iterable[Any] | float | None = None,
|
|
fallback_to_detected: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Resolve an ``AUTO`` currency to the code detected from the data.
|
|
|
|
Mirrors ``currency-format/utils.ts::resolveAutoCurrency`` and the per-cell
|
|
handling in the Table and Pivot Table plugins. A single valid currency in
|
|
``currency_context`` takes precedence over the query-wide detection. Mixed
|
|
cell currencies deliberately keep ``AUTO`` so the caller renders a neutral
|
|
number. Empty cell context can use the detected fallback when the plugin's
|
|
behavior allows it.
|
|
|
|
:param currency_context: the currencies contributing to a cell. A dense
|
|
pivot cell provides an iterable of codes, but a sparse 2D pivot passes a
|
|
scalar ``NaN`` float for a missing cross-product cell (hence the ``float``
|
|
arm); a missing/NaN/non-iterable context is treated as empty.
|
|
:return: a copied config containing the detected code, or the input config
|
|
"""
|
|
if currency.get("symbol") != AUTO_CURRENCY:
|
|
return currency
|
|
|
|
if currency_context is not None:
|
|
# A dense pivot cell carries an iterable of currency codes, but a sparse
|
|
# 2D pivot leaves missing cross-product cells as a scalar missing value
|
|
# (``np.nan``; pandas never runs the union aggregator for them). Test
|
|
# positively for an iterable so any non-iterable sentinel (``np.nan``,
|
|
# ``pd.NA``, ``pd.NaT``) falls to the empty-context path instead of
|
|
# raising and taking down the whole report.
|
|
context_values: list[Any] = (
|
|
list(currency_context) if isinstance(currency_context, Iterable) else []
|
|
)
|
|
normalized_currencies = {
|
|
normalized
|
|
for value in context_values
|
|
if (normalized := normalize_currency(value)) is not None
|
|
}
|
|
if len(normalized_currencies) > 1:
|
|
return currency
|
|
if context_values and (cell_currency := normalize_currency(context_values[0])):
|
|
return {**currency, "symbol": cell_currency}
|
|
if not fallback_to_detected:
|
|
return currency
|
|
|
|
if detected_currency := normalize_currency(detected_currency):
|
|
return {**currency, "symbol": detected_currency}
|
|
return currency
|
|
|
|
|
|
def normalize_currency(value: Any) -> str | None:
|
|
"""
|
|
Normalize a possible ISO-4217 code for AUTO currency resolution.
|
|
|
|
Mirrors ``currency-format/CurrencyFormatter.ts::normalizeCurrency``:
|
|
non-strings and values other than three ASCII letters are rejected, while
|
|
valid strings are stripped and upper-cased.
|
|
|
|
:return: the normalized three-letter code, or ``None``
|
|
"""
|
|
if not isinstance(value, str):
|
|
return None
|
|
normalized = value.strip().upper()
|
|
return normalized if re.fullmatch(r"[A-Z]{3}", normalized) else None
|
|
|
|
|
|
def format_number_with_config(
|
|
d3_format: str | None,
|
|
currency: dict[str, Any] | None,
|
|
value: Any,
|
|
) -> Any:
|
|
"""
|
|
Format ``value`` using a d3-format string and optional currency config.
|
|
|
|
This is the report-side entry point corresponding to
|
|
``currency-format/CurrencyFormatter.ts::format`` and the formatter invoked
|
|
by the Table and Pivot Table plugins.
|
|
|
|
:param d3_format: a d3-format specifier (e.g. ``",.2f"``) or ``SMART_NUMBER``
|
|
:param currency: ``{"symbol": <ISO 4217>, "symbolPosition": "prefix"|"suffix"}``
|
|
:param value: the raw value to format
|
|
:return: the formatted string, or the value unchanged when it is not a
|
|
number that can be formatted
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
|
return value
|
|
if isinstance(value, Decimal):
|
|
value = float(value)
|
|
if math.isnan(value) or math.isinf(value):
|
|
return ""
|
|
|
|
try:
|
|
if currency and currency.get("symbol"):
|
|
# the frontend strips the currency symbol from the d3 format and
|
|
# falls back to SMART_NUMBER when no explicit format is set
|
|
number_format = (d3_format or SMART_NUMBER).replace("$", "")
|
|
formatted = format_numeric(number_format, value)
|
|
if currency["symbol"] == AUTO_CURRENCY:
|
|
return formatted
|
|
try:
|
|
return apply_currency(formatted, currency)
|
|
except Exception: # pylint: disable=broad-except # noqa: BLE001
|
|
return formatted
|
|
if not d3_format:
|
|
return raw_string(value)
|
|
return format_numeric(d3_format, value)
|
|
except Exception: # pylint: disable=broad-except # noqa: BLE001
|
|
# never let an unexpected value break a whole report table
|
|
return raw_string(value)
|
|
|
|
|
|
def format_numeric(d3_format: str, value: float) -> str:
|
|
"""
|
|
Format ``value`` according to a d3 number format.
|
|
|
|
Delegates to the port of ``createSmartNumberFormatter.ts`` for the two smart
|
|
pseudo-formats and to the port of ``d3-format/src/locale.js`` for d3
|
|
specifiers. Registered frontend-only factories are rejected explicitly.
|
|
|
|
:return: a formatted number string
|
|
"""
|
|
if d3_format in UNSUPPORTED_FRONTEND_PRESETS:
|
|
raise ValueError(f"Frontend preset {d3_format!r} is not available in reports")
|
|
if d3_format in (SMART_NUMBER, SMART_NUMBER_SIGNED):
|
|
return format_smart_number(value, signed=d3_format == SMART_NUMBER_SIGNED)
|
|
return format_d3(d3_format, value)
|
|
|
|
|
|
def format_d3(d3_format: str, value: float) -> str:
|
|
"""
|
|
Format ``value`` with a d3-format specifier.
|
|
|
|
Mirrors ``d3-format/src/locale.js`` and ``formatTypes.js``. Supports the
|
|
subset of the specifier grammar the Table/Pivot plugins emit:
|
|
the ``+ - ( space`` sign modes, the ``$`` currency prefix, the ``,`` group
|
|
separator, ``.precision``, the ``~`` trim flag, and the ``s`` (SI), ``r``
|
|
(significant), ``d`` (integer), ``f``/``e``/``g``/``%`` numeric types.
|
|
Returns the formatted string and raises ``ValueError`` for an unparseable
|
|
specifier. Padding flags are rejected because they cannot be represented by
|
|
the report table path.
|
|
|
|
:return: a d3-compatible formatted string
|
|
"""
|
|
match = D3_FORMAT_RE.match(d3_format)
|
|
if not match:
|
|
raise ValueError(d3_format)
|
|
if any(match.group(index) for index in (1, 2, 5, 6)):
|
|
raise ValueError(f"d3 padding is not supported in reports: {d3_format!r}")
|
|
|
|
sign_mode = match.group(3) or "-"
|
|
currency_symbol = match.group(4) == "$"
|
|
comma = "," if match.group(7) else ""
|
|
precision = int(match.group(8)) if match.group(8) is not None else None
|
|
trim = bool(match.group(9))
|
|
type_ = (match.group(10) or "").lower()
|
|
|
|
if type_ == "n":
|
|
comma = ","
|
|
type_ = "g"
|
|
|
|
formatted = format_d3_magnitude(
|
|
type_, abs(value), precision, trim, comma, d3_format
|
|
)
|
|
|
|
if currency_symbol:
|
|
formatted = f"${formatted}"
|
|
return apply_sign(formatted, value, sign_mode)
|
|
|
|
|
|
def format_d3_magnitude(
|
|
type_: str,
|
|
magnitude: float,
|
|
precision: int | None,
|
|
trim: bool,
|
|
comma: str,
|
|
d3_format: str,
|
|
) -> str:
|
|
"""
|
|
Render the unsigned numeric portion of a parsed d3 specifier.
|
|
|
|
Mirrors the formatter dispatch in ``d3-format/src/locale.js`` and
|
|
``formatTypes.js``. The result excludes sign and currency decoration.
|
|
"""
|
|
if type_ == "s":
|
|
return format_si(
|
|
magnitude, max(1, precision if precision is not None else 6), trim
|
|
)
|
|
if type_ == "r":
|
|
return format_significant(
|
|
magnitude,
|
|
max(1, precision if precision is not None else 6),
|
|
trim,
|
|
comma,
|
|
)
|
|
if type_ == "":
|
|
return format_general(
|
|
magnitude, precision if precision is not None else 12, True, comma
|
|
)
|
|
if type_ == "d":
|
|
formatted = format(int(quantize_half_up(magnitude, 0)), f"{comma}d")
|
|
elif type_ in ("f", "%"):
|
|
precision = precision if precision is not None else 6
|
|
scaled = magnitude * 100 if type_ == "%" else magnitude
|
|
suffix = "%" if type_ == "%" else ""
|
|
if scaled >= 1e21:
|
|
formatted = normalize_exponent(repr(float(scaled))) + suffix
|
|
else:
|
|
rounded = quantize_half_up(scaled, precision)
|
|
formatted = format(rounded, f"{comma}.{precision}f") + suffix
|
|
elif type_ == "e":
|
|
formatted = format_exponential(
|
|
magnitude, precision if precision is not None else 6
|
|
)
|
|
elif type_ == "g":
|
|
formatted = format_general(
|
|
magnitude, precision if precision is not None else 6, trim, comma
|
|
)
|
|
else:
|
|
raise ValueError(d3_format)
|
|
return trim_trailing_zeros(formatted) if trim else formatted
|
|
|
|
|
|
def apply_sign(formatted: str, value: float, sign_mode: str) -> str:
|
|
"""
|
|
Decorate a formatted magnitude with the d3 sign mode.
|
|
|
|
Negative values get a leading ``-`` (or wrapping parentheses for the ``(``
|
|
accounting mode); positive values get a ``+`` or a leading space only for the
|
|
``+`` and space modes respectively. Mirrors the sign decoration in
|
|
``d3-format/src/locale.js``.
|
|
|
|
:return: the signed or accounting-decorated string
|
|
"""
|
|
if value < 0:
|
|
return f"({formatted})" if sign_mode == "(" else f"-{formatted}"
|
|
if sign_mode == "+":
|
|
return f"+{formatted}"
|
|
if sign_mode == " ":
|
|
return f" {formatted}"
|
|
return formatted
|
|
|
|
|
|
def format_default(value: float, comma: str) -> str:
|
|
"""
|
|
Format ``value`` the way d3's default (no-type) specifier does.
|
|
|
|
d3 aliases an omitted type to ``.12~g``. This preserves fixed notation from
|
|
``1e-6`` through twelve significant integer digits, then uses exponent
|
|
notation outside that range. Mirrors the omitted-type alias in
|
|
``d3-format/src/formatSpecifier.js``.
|
|
|
|
:return: the ``.12~g`` representation
|
|
"""
|
|
return format_general(value, 12, True, comma)
|
|
|
|
|
|
def format_general(value: float, precision: int, trim: bool, comma: str = "") -> str:
|
|
"""
|
|
Format d3's ``g`` type with JavaScript ``toPrecision`` thresholds.
|
|
|
|
Mirrors ``d3-format/src/formatTypes.js`` and returns fixed or exponential
|
|
notation with the requested significant-digit precision.
|
|
"""
|
|
precision = max(1, precision)
|
|
rounded = round_to_significant(value, precision)
|
|
exponent = decimal_exponent(rounded)
|
|
if value and (exponent < -6 or exponent >= precision):
|
|
formatted = format_exponential(value, precision - 1)
|
|
else:
|
|
formatted = format_significant(value, precision, False, comma)
|
|
return trim_trailing_zeros(formatted) if trim else formatted
|
|
|
|
|
|
def format_exponential(value: float, precision: int) -> str:
|
|
"""
|
|
Format d3's ``e`` type using binary-float, half-up rounding.
|
|
|
|
Mirrors the ``e`` formatter in ``d3-format/src/formatTypes.js`` and returns
|
|
an exponent without redundant leading zeros.
|
|
"""
|
|
rounded = round_to_significant(value, precision + 1)
|
|
exponent = decimal_exponent(rounded)
|
|
mantissa = rounded / (10**exponent) if rounded else 0.0
|
|
return f"{mantissa:.{precision}f}e{exponent:+d}"
|
|
|
|
|
|
def format_smart_number(value: float, signed: bool = False) -> str:
|
|
"""
|
|
Format ``value`` the way the frontend ``SMART_NUMBER`` formatter does.
|
|
|
|
The notation is chosen by magnitude: SI prefixes (with ``G`` shown as ``B``)
|
|
for ``abs(value) >= 1000``, two decimals down to ``1``, four decimals down to
|
|
``0.001``, a micro (``µ``) suffix down to ``1e-6``, and SI prefixes again
|
|
below that. When ``signed`` is set, positive values are prefixed with ``+``.
|
|
Mirrors ``number-format/factories/createSmartNumberFormatter.ts``.
|
|
|
|
:return: the adaptive frontend-compatible number string
|
|
"""
|
|
if value == 0:
|
|
body = "0"
|
|
else:
|
|
absolute = abs(value)
|
|
if absolute >= 1000:
|
|
body = format_si(value, 3, trim=True, billions=True)
|
|
elif absolute >= 1:
|
|
body = trim_trailing_zeros(format(quantize_half_up(value, 2), ".2f"))
|
|
elif absolute >= 0.001:
|
|
body = trim_trailing_zeros(format(quantize_half_up(value, 4), ".4f"))
|
|
elif absolute > 0.000001:
|
|
body = format_si(value * 1000000, 3, trim=True) + "µ"
|
|
else:
|
|
body = format_si(value, 3, trim=True)
|
|
prefix = "+" if signed and value > 0 else ""
|
|
return prefix + body
|
|
|
|
|
|
def format_si(value: float, precision: int, trim: bool, billions: bool = False) -> str:
|
|
"""
|
|
Format ``value`` with an SI prefix to ``precision`` significant digits.
|
|
|
|
Rounds to ``precision`` significant figures first, then scales into the
|
|
nearest power-of-1000 bracket (clamped to the ``y``..``Y`` range) and appends
|
|
the matching SI symbol. Rounding before the divide matches d3 and keeps
|
|
``4725`` at ``4.73k`` (the inexact ``4.725`` mantissa would round to
|
|
``4.72k``), and lets a value that rounds up into the next bracket pick the
|
|
right symbol (``999.5k`` -> ``1M``). With ``billions`` set, the ``G`` (giga)
|
|
symbol is rendered as ``B``. Mirrors d3's
|
|
``formatPrefixAuto.js``/``formatRounded.js`` combination.
|
|
|
|
:return: a significant-digit mantissa followed by its SI prefix
|
|
"""
|
|
if value == 0:
|
|
return format_significant(0.0, precision, trim)
|
|
|
|
rounded = round_to_significant(value, precision)
|
|
exponent = max(-8, min(8, math.floor(math.log10(abs(rounded))) // 3))
|
|
mantissa = rounded / (10 ** (exponent * 3))
|
|
|
|
symbol = SI_PREFIXES[exponent]
|
|
if billions and symbol == "G":
|
|
symbol = "B"
|
|
|
|
return format_significant(mantissa, precision, trim) + symbol
|
|
|
|
|
|
def format_significant(
|
|
value: float, precision: int, trim: bool, comma: str = ""
|
|
) -> str:
|
|
"""
|
|
Format to `precision` significant digits in fixed-point notation.
|
|
|
|
Serves both the d3 `r` type and SI mantissas, and avoids the scientific
|
|
notation Python's `g` would switch to. Mirrors the fixed representation
|
|
produced by ``d3-format/src/formatRounded.js``.
|
|
|
|
:return: a fixed-point significant-digit string
|
|
"""
|
|
rounded = round_to_significant(value, precision)
|
|
decimals = decimals_for_significant(rounded, precision)
|
|
formatted = format(rounded, f"{comma}.{decimals}f")
|
|
return trim_trailing_zeros(formatted) if trim else formatted
|
|
|
|
|
|
def round_to_significant(value: float, precision: int) -> float:
|
|
"""
|
|
Round ``value`` to ``precision`` significant digits.
|
|
|
|
The number of decimal places to keep is derived from the value's order of
|
|
magnitude (``precision - 1 - floor(log10(abs(value)))``) and the rounding is
|
|
half away from zero, matching d3-format's ``formatDecimalParts`` path.
|
|
|
|
:return: the rounded binary-float value
|
|
"""
|
|
if value == 0:
|
|
return 0.0
|
|
return float(quantize_half_up(value, precision - 1 - decimal_exponent(value)))
|
|
|
|
|
|
def quantize_half_up(value: float, decimals: int) -> Decimal:
|
|
"""
|
|
Round to `decimals` places, half away from zero, matching d3-format.
|
|
|
|
Quantizes the binary float value (not its decimal string) so the result
|
|
matches d3, which rounds the IEEE-754 value: ``2.675`` is ``2.67`` because it
|
|
is really ``2.67499...``, while an exact ``0.125`` rounds up to ``0.13``.
|
|
This supplies the rounding semantics of ``d3-format/src/formatTypes.js``.
|
|
|
|
:return: a ``Decimal`` rounded at the requested decimal place
|
|
"""
|
|
return Decimal(value).quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_HALF_UP)
|
|
|
|
|
|
def decimals_for_significant(value: float, precision: int) -> int:
|
|
"""
|
|
Return fixed-point decimal places needed for significant-digit formatting.
|
|
|
|
This is the report-side equivalent of the exponent adjustment in
|
|
``d3-format/src/formatRounded.js``.
|
|
"""
|
|
integer_digits = 1 if value == 0 else decimal_exponent(value) + 1
|
|
return max(0, precision - integer_digits)
|
|
|
|
|
|
def decimal_exponent(value: float) -> int:
|
|
"""
|
|
Return the base-10 exponent without ``log10`` boundary drift.
|
|
|
|
Used where d3-format derives an exponent through ``formatDecimalParts``.
|
|
"""
|
|
return Decimal(repr(value)).adjusted() if value else 0
|
|
|
|
|
|
def normalize_exponent(formatted: str) -> str:
|
|
"""
|
|
Drop exponent leading zeros (``1e+07`` to ``1e+7``), as d3 does.
|
|
|
|
:return: the exponent string style emitted by ``d3-format``
|
|
"""
|
|
return re.sub(r"([eE][+-])0*(\d)", r"\1\2", formatted)
|
|
|
|
|
|
def get_currency_locale() -> str:
|
|
"""
|
|
Return the request locale, or the configured default outside a request.
|
|
|
|
Report tasks run with a Flask application context but without a request, so
|
|
Flask-Babel can return ``None``. The config fallback keeps Celery-rendered
|
|
reports aligned with the locale supplied to the frontend at bootstrap. The
|
|
result feeds the locale argument used by ``currency-format/symbolPosition.ts``.
|
|
|
|
:return: a Babel locale identifier, always with a safe default
|
|
"""
|
|
try:
|
|
if locale := get_locale():
|
|
return str(locale)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
try:
|
|
return str(current_app.config.get("BABEL_DEFAULT_LOCALE") or DEFAULT_LOCALE)
|
|
except RuntimeError:
|
|
return DEFAULT_LOCALE
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def resolve_symbol_position(code: str, locale: str) -> str:
|
|
"""
|
|
Derive the symbol position from the locale's convention for the currency.
|
|
|
|
Mirrors ``currency-format/symbolPosition.ts::resolveSymbolPosition`` and
|
|
returns ``"prefix"`` on invalid locale/currency input.
|
|
"""
|
|
try:
|
|
sample = format_currency(1, code, locale=locale)
|
|
first_digit = next(i for i, char in enumerate(sample) if char.isdigit())
|
|
return "prefix" if first_digit > 0 else "suffix"
|
|
except Exception: # pylint: disable=broad-except # noqa: BLE001
|
|
return "prefix"
|
|
|
|
|
|
def apply_currency(formatted: str, currency: dict[str, Any]) -> str:
|
|
"""
|
|
Add a localized currency symbol to an already formatted number.
|
|
|
|
Mirrors ``currency-format/CurrencyFormatter.ts::format``: percentage signs
|
|
are removed, explicit positions win, and an unset position is locale-driven.
|
|
|
|
:return: the number with a prefix or suffix currency symbol
|
|
"""
|
|
normalized = formatted.replace("%", "")
|
|
code = currency["symbol"]
|
|
symbol = get_currency_symbol(code, locale=CURRENCY_SYMBOL_LOCALE) or code
|
|
position = currency.get("symbolPosition")
|
|
if position not in ("prefix", "suffix"):
|
|
position = resolve_symbol_position(code, get_currency_locale())
|
|
if position == "prefix":
|
|
return f"{symbol} {normalized}"
|
|
return f"{normalized} {symbol}"
|
|
|
|
|
|
def trim_trailing_zeros(formatted: str) -> str:
|
|
"""
|
|
Remove insignificant fractional zeros while preserving suffixes.
|
|
|
|
Mirrors ``d3-format/src/formatTrim.js`` for decimal, exponent, and percent
|
|
strings and returns the compact representation.
|
|
"""
|
|
suffix = "%" if formatted.endswith("%") else ""
|
|
body = formatted[: -len(suffix)] if suffix else formatted
|
|
coefficient, separator, exponent = body.partition("e")
|
|
if "." in coefficient:
|
|
coefficient = coefficient.rstrip("0").rstrip(".")
|
|
exponent_suffix = f"{separator}{exponent}" if separator else ""
|
|
return coefficient + exponent_suffix + suffix
|
|
|
|
|
|
def raw_string(value: float) -> str:
|
|
"""
|
|
Convert an unformatted number to the frontend-like neutral representation.
|
|
|
|
Integral floats lose their Python-only ``.0`` suffix. The result is the
|
|
safe fallback used by ``CurrencyFormatter.ts`` and invalid format handling.
|
|
"""
|
|
if isinstance(value, float) and value.is_integer():
|
|
return str(int(value))
|
|
return str(value)
|