From a023056c29497549d28c21bd366de075fd1f9483 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sun, 10 Mar 2024 17:01:13 -0300 Subject: [PATCH] feat: correct pluralization of localized units This commits involves a heavy refactoring of the helper function for the formatter. Briefly, before the same function that was generating the string was splitting beween numerator and denominator. Now this is done before to allow for correct pluralization. --- pint/compat.py | 53 +-- .../formatter/_compound_unit_helpers.py | 312 ++++++++++++++++++ pint/delegates/formatter/_format_helpers.py | 310 ++++------------- pint/delegates/formatter/_spec_helpers.py | 59 +--- pint/delegates/formatter/_to_register.py | 10 +- pint/delegates/formatter/full.py | 57 ++-- pint/delegates/formatter/html.py | 43 ++- pint/delegates/formatter/latex.py | 70 +++- pint/delegates/formatter/plain.py | 124 +++++-- pint/formatting.py | 109 +++++- pint/testsuite/test_babel.py | 5 +- pint/testsuite/test_formatter.py | 48 +-- pint/testsuite/test_issues.py | 37 ++- pint/testsuite/test_quantity.py | 2 +- 14 files changed, 768 insertions(+), 471 deletions(-) create mode 100644 pint/delegates/formatter/_compound_unit_helpers.py diff --git a/pint/compat.py b/pint/compat.py index 19fda57a7..277662410 100644 --- a/pint/compat.py +++ b/pint/compat.py @@ -16,20 +16,11 @@ from decimal import Decimal from importlib import import_module from numbers import Number -from typing import Any, NoReturn - -try: - from uncertainties import UFloat, ufloat - from uncertainties import unumpy as unp - - HAS_UNCERTAINTIES = True -except ImportError: - UFloat = ufloat = unp = None - HAS_UNCERTAINTIES = False - - -from typing import TypeAlias # noqa - +from typing import ( + Any, + NoReturn, + TypeAlias, # noqa +) if sys.version_info >= (3, 11): from typing import Self # noqa @@ -78,6 +69,17 @@ class BehaviorChangeWarning(UserWarning): pass +try: + from uncertainties import UFloat, ufloat + from uncertainties import unumpy as unp + + HAS_UNCERTAINTIES = True +except ImportError: + UFloat = ufloat = unp = None + + HAS_UNCERTAINTIES = False + + try: import numpy as np from numpy import datetime64 as np_datetime64 @@ -172,6 +174,9 @@ def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False): except ImportError: HAS_BABEL = False + babel_parse = missing_dependency("Babel") # noqa: F811 # type:ignore + babel_units = babel_parse + try: import mip @@ -186,6 +191,14 @@ def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False): except ImportError: HAS_MIP = False + mip_missing = missing_dependency("mip") + mip_model = mip_missing + mip_Model = mip_missing + mip_INF = mip_missing + mip_INTEGER = mip_missing + mip_xsum = mip_missing + mip_OptimizationStatus = mip_missing + # Defines Logarithm and Exponential for Logarithmic Converter if HAS_NUMPY: from numpy import ( @@ -198,18 +211,6 @@ def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False): log, # noqa: F401 ) -if not HAS_BABEL: - babel_parse = missing_dependency("Babel") # noqa: F811 - babel_units = babel_parse - -if not HAS_MIP: - mip_missing = missing_dependency("mip") - mip_model = mip_missing - mip_Model = mip_missing - mip_INF = mip_missing - mip_INTEGER = mip_missing - mip_xsum = mip_missing - mip_OptimizationStatus = mip_missing # Define location of pint.Quantity in NEP-13 type cast hierarchy by defining upcast # types using guarded imports diff --git a/pint/delegates/formatter/_compound_unit_helpers.py b/pint/delegates/formatter/_compound_unit_helpers.py new file mode 100644 index 000000000..c9dd4a229 --- /dev/null +++ b/pint/delegates/formatter/_compound_unit_helpers.py @@ -0,0 +1,312 @@ +""" + pint.delegates.formatter._compound_unit_helpers + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Convenient functions to help organize compount units. + + :copyright: 2022 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + + +from __future__ import annotations + +import locale +from collections.abc import Callable, Iterable +from functools import partial +from itertools import filterfalse, tee +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeAlias, + TypedDict, + TypeVar, +) + +from ...compat import babel_parse +from ...util import UnitsContainer + +T = TypeVar("T") +U = TypeVar("U") +V = TypeVar("V") +W = TypeVar("W") + +if TYPE_CHECKING: + from ...compat import Locale, Number + from ...facets.plain import PlainUnit + from ...registry import UnitRegistry + + +class SortKwds(TypedDict): + registry: UnitRegistry + + +SortFunc: TypeAlias = Callable[ + [Iterable[tuple[str, Any, str]], Any], Iterable[tuple[str, Any, str]] +] + + +class BabelKwds(TypedDict): + """Babel related keywords used in formatters.""" + + use_plural: bool + length: Literal["short", "long", "narrow"] | None + locale: Locale | str | None + + +def partition( + predicate: Callable[[T], bool], iterable: Iterable[T] +) -> tuple[filterfalse[T], filter[T]]: + """Partition entries into false entries and true entries. + + If *predicate* is slow, consider wrapping it with functools.lru_cache(). + """ + # partition(is_odd, range(10)) --> 0 2 4 6 8 and 1 3 5 7 9 + t1, t2 = tee(iterable) + return filterfalse(predicate, t1), filter(predicate, t2) + + +def localize_per( + length: Literal["short", "long", "narrow"] = "long", + locale: Locale | str | None = locale.LC_NUMERIC, + default: str | None = None, +) -> str: + """Localized singular and plural form of a unit. + + THIS IS TAKEN FROM BABEL format_unit. But + - No magnitude is returned in the string. + - If the unit is not found, the default is given. + - If the default is None, then the same value is given. + """ + locale = babel_parse(locale) + + patterns = locale._data["compound_unit_patterns"].get("per", None) + + if patterns is None: + return default or "{}/{}" + + return patterns.get(length, default or "{}/{}") + + +def localize_unit_name( + measurement_unit: str, + use_plural: bool, + length: Literal["short", "long", "narrow"] = "long", + locale: Locale | str | None = locale.LC_NUMERIC, + default: str | None = None, +) -> str: + """Localized singular and plural form of a unit. + + THIS IS TAKEN FROM BABEL format_unit. But + - No magnitude is returned in the string. + - If the unit is not found, the default is given. + - If the default is None, then the same value is given. + """ + locale = babel_parse(locale) + from babel.units import _find_unit_pattern, get_unit_name + + q_unit = _find_unit_pattern(measurement_unit, locale=locale) + if not q_unit: + return measurement_unit + + unit_patterns = locale._data["unit_patterns"][q_unit].get(length, {}) + + if use_plural: + grammatical_number = "other" + else: + grammatical_number = "one" + + if grammatical_number in unit_patterns: + return unit_patterns[grammatical_number].format("").replace("\xa0", "").strip() + + if default is not None: + return default + + # Fall back to a somewhat bad representation. + # nb: This is marked as no-cover, as the current CLDR seemingly has no way for this to happen. + fallback_name = get_unit_name( + measurement_unit, length=length, locale=locale + ) # pragma: no cover + return f"{fallback_name or measurement_unit}" # pragma: no cover + + +def extract2(element: tuple[str, T, str]) -> tuple[str, T]: + """Extract display name and exponent from a tuple containing display name, exponent and unit name.""" + + return element[:2] + + +def to_name_exponent_name(element: tuple[str, T]) -> tuple[str, T, str]: + """Convert unit name and exponent to unit name as display name, exponent and unit name.""" + + # TODO: write a generic typing + + return element + (element[0],) + + +def to_symbol_exponent_name( + el: tuple[str, T], registry: UnitRegistry +) -> tuple[str, T, str]: + """Convert unit name and exponent to unit symbol as display name, exponent and unit name.""" + return registry._get_symbol(el[0]), el[1], el[0] + + +def localize_display_exponent_name( + element: tuple[str, T, str], + use_plural: bool, + length: Literal["short", "long", "narrow"] = "long", + locale: Locale | str | None = locale.LC_NUMERIC, + default: str | None = None, +) -> tuple[str, T, str]: + """Localize display name in a triplet display name, exponent and unit name.""" + + return ( + localize_unit_name( + element[2], use_plural, length, locale, default or element[0] + ), + element[1], + element[2], + ) + + +##################### +# Sorting functions +##################### + + +def sort_by_unit_name( + items: Iterable[tuple[str, Number, str]], _registry: UnitRegistry | None +) -> Iterable[tuple[str, Number, str]]: + return sorted(items, key=lambda el: el[2]) + + +def sort_by_display_name( + items: Iterable[tuple[str, Number, str]], _registry: UnitRegistry | None +) -> Iterable[tuple[str, Number, str]]: + return sorted(items) + + +def sort_by_dimensionality( + items: Iterable[tuple[str, Number, str]], registry: UnitRegistry | None +) -> Iterable[tuple[str, Number, str]]: + """Sort a list of units by dimensional order (from `registry.formatter.dim_order`). + + Parameters + ---------- + items : tuple + a list of tuples containing (unit names, exponent values). + registry : UnitRegistry | None + the registry to use for looking up the dimensions of each unit. + + Returns + ------- + list + the list of units sorted by most significant dimension first. + + Raises + ------ + KeyError + If unit cannot be found in the registry. + """ + + if registry is None: + return items + + dim_order = registry.formatter.dim_order + + def sort_key(item: tuple[str, Number, str]): + _display_name, _unit_exponent, unit_name = item + cname = registry.get_name(unit_name) + cname_dims = registry.get_dimensionality(cname) or {"[]": None} + for cname_dim in cname_dims: + if cname_dim in dim_order: + return dim_order.index(cname_dim), cname + + raise KeyError(f"Unit {unit_name} (aka {cname}) has no recognized dimensions") + + return sorted(items, key=sort_key) + + +def prepare_compount_unit( + unit: PlainUnit | UnitsContainer, + spec: str = "", + sort_func: SortFunc | None = None, + use_plural: bool = True, + length: Literal["short", "long", "narrow"] | None = None, + locale: Locale | str | None = None, + as_ratio: bool = True, +) -> tuple[Iterable[tuple[str, Any]], Iterable[tuple[str, Any]]]: + """Format compound unit into unit container given + an spec and locale. + + Returns + ------- + iterable of display name, exponent, canonical name + """ + + registry = getattr(unit, "_REGISTRY", None) + + if isinstance(unit, UnitsContainer): + out = unit.items() + else: + out = unit._units.items() + + # out: unit_name, unit_exponent + + if "~" in spec: + if registry is None: + raise ValueError( + f"Can't short format a {type(unit)} without a registry." + " This is usually triggered when formatting a instance" + " of the internal `UnitsContainer`." + ) + _to_symbol_exponent_name = partial(to_symbol_exponent_name, registry=registry) + out = map(_to_symbol_exponent_name, out) + else: + out = map(to_name_exponent_name, out) + + # We keep unit_name because the sort or localizing functions might needed. + # out: display_unit_name, unit_exponent, unit_name + + if as_ratio: + numerator, denominator = partition(lambda el: el[1] < 0, out) + else: + numerator, denominator = out, () + + # numerator: display_unit_name, unit_name, unit_exponent + # denominator: display_unit_name, unit_name, unit_exponent + + if locale is None: + if sort_func is not None: + numerator = sort_func(numerator, registry) + denominator = sort_func(denominator, registry) + + return map(extract2, numerator), map(extract2, denominator) + + if length is None: + length = "short" if "~" in spec else "long" + + mapper = partial( + localize_display_exponent_name, use_plural=False, length=length, locale=locale + ) + + numerator = map(mapper, numerator) + denominator = map(mapper, denominator) + + if sort_func is not None: + numerator = sort_func(numerator, registry) + denominator = sort_func(denominator, registry) + + if use_plural: + if not isinstance(numerator, list): + numerator = list(numerator) + numerator[-1] = localize_display_exponent_name( + numerator[-1], + use_plural, + length=length, + locale=locale, + default=numerator[-1][0], + ) + + return map(extract2, numerator), map(extract2, denominator) diff --git a/pint/delegates/formatter/_format_helpers.py b/pint/delegates/formatter/_format_helpers.py index bb8243aa7..995159e65 100644 --- a/pint/delegates/formatter/_format_helpers.py +++ b/pint/delegates/formatter/_format_helpers.py @@ -11,7 +11,7 @@ from __future__ import annotations -import locale +import re from collections.abc import Callable, Generator, Iterable from contextlib import contextmanager from functools import partial @@ -19,16 +19,11 @@ from typing import ( TYPE_CHECKING, Any, - Literal, - TypedDict, TypeVar, ) -from warnings import warn -from pint.delegates.formatter._spec_helpers import FORMATTER, _join - -from ...compat import babel_parse, ndarray -from ...util import UnitsContainer +from ...compat import ndarray +from ._spec_helpers import FORMATTER try: from numpy import integer as np_integer @@ -37,20 +32,14 @@ if TYPE_CHECKING: from ...compat import Locale, Number - from ...facets.plain import PlainUnit - from ...registry import UnitRegistry T = TypeVar("T") U = TypeVar("U") V = TypeVar("V") +W = TypeVar("W") - -class BabelKwds(TypedDict): - """Babel related keywords used in formatters.""" - - use_plural: bool - length: Literal["short", "long", "narrow"] | None - locale: Locale | str | None +_PRETTY_EXPONENTS = "⁰¹²³⁴⁵⁶⁷⁸⁹" +_JOIN_REG_EXP = re.compile(r"{\d*}") def format_number(value: Any, spec: str = "") -> str: @@ -109,207 +98,62 @@ def override_locale( setlocale(LC_NUMERIC, prev_locale_string) -def format_unit_no_magnitude( - measurement_unit: str, - use_plural: bool = True, - length: Literal["short", "long", "narrow"] = "long", - locale: Locale | str | None = locale.LC_NUMERIC, -) -> str | None: - """Format a value of a given unit. - - THIS IS TAKEN FROM BABEL format_unit. But - - No magnitude is returned in the string. - - If the unit is not found, the same is given. - - use_plural instead of value - - Values are formatted according to the locale's usual pluralization rules - and number formats. - - >>> format_unit(12, 'length-meter', locale='ro_RO') - u'metri' - >>> format_unit(15.5, 'length-mile', locale='fi_FI') - u'mailia' - >>> format_unit(1200, 'pressure-millimeter-ofhg', locale='nb') - u'millimeter kvikks\\xf8lv' - >>> format_unit(270, 'ton', locale='en') - u'tons' - >>> format_unit(1234.5, 'kilogram', locale='ar_EG', numbering_system='default') - u'كيلوغرام' - - - The locale's usual pluralization rules are respected. - - >>> format_unit(1, 'length-meter', locale='ro_RO') - u'metru' - >>> format_unit(0, 'length-mile', locale='cy') - u'mi' - >>> format_unit(1, 'length-mile', locale='cy') - u'filltir' - >>> format_unit(3, 'length-mile', locale='cy') - u'milltir' - - >>> format_unit(15, 'length-horse', locale='fi') - Traceback (most recent call last): - ... - UnknownUnitError: length-horse is not a known unit in fi - - .. versionadded:: 2.2.0 - - :param value: the value to format. If this is a string, no number formatting will be attempted. - :param measurement_unit: the code of a measurement unit. - Known units can be found in the CLDR Unit Validity XML file: - https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml - :param length: "short", "long" or "narrow" - :param format: An optional format, as accepted by `format_decimal`. - :param locale: the `Locale` object or locale identifier - :param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn". - The special value "default" will use the default numbering system of the locale. - :raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale. - """ - locale = babel_parse(locale) - from babel.units import _find_unit_pattern, get_unit_name +def pretty_fmt_exponent(num: Number) -> str: + """Format an number into a pretty printed exponent.""" + # unicode dot operator (U+22C5) looks like a superscript decimal + ret = f"{num:n}".replace("-", "⁻").replace(".", "\u22C5") + for n in range(10): + ret = ret.replace(str(n), _PRETTY_EXPONENTS[n]) + return ret - q_unit = _find_unit_pattern(measurement_unit, locale=locale) - if not q_unit: - return measurement_unit - unit_patterns = locale._data["unit_patterns"][q_unit].get(length, {}) +def join_u(fmt: str, iterable: Iterable[Any]) -> str: + """Join an iterable with the format specified in fmt. - if use_plural: - plural_form = "other" - else: - plural_form = "one" - - if plural_form in unit_patterns: - return unit_patterns[plural_form].format("").replace("\xa0", "").strip() - - # Fall back to a somewhat bad representation. - # nb: This is marked as no-cover, as the current CLDR seemingly has no way for this to happen. - fallback_name = get_unit_name( - measurement_unit, length=length, locale=locale - ) # pragma: no cover - return f"{fallback_name or measurement_unit}" # pragma: no cover - - -def map_keys( - func: Callable[ - [ - T, - ], - U, - ], - items: Iterable[tuple[T, V]], -) -> Iterable[tuple[U, V]]: - """Map dict keys given an items view.""" - return map(lambda el: (func(el[0]), el[1]), items) - - -def short_form( - units: Iterable[tuple[str, T]], - registry: UnitRegistry, -) -> Iterable[tuple[str, T]]: - """Replace each unit by its short form.""" - return map_keys(registry._get_symbol, units) - - -def localized_form( - units: Iterable[tuple[str, T]], - use_plural: bool, - length: Literal["short", "long", "narrow"], - locale: Locale | str, -) -> Iterable[tuple[str, T]]: - """Replace each unit by its localized version.""" - mapper = partial( - format_unit_no_magnitude, - use_plural=use_plural, - length=length, - locale=babel_parse(locale), - ) - - return map_keys(mapper, units) - - -def format_compound_unit( - unit: PlainUnit | UnitsContainer, - spec: str = "", - use_plural: bool = False, - length: Literal["short", "long", "narrow"] | None = None, - locale: Locale | str | None = None, -) -> Iterable[tuple[str, Number]]: - """Format compound unit into unit container given - an spec and locale. + The format can be specified in two ways: + - PEP3101 format with two replacement fields (eg. '{} * {}') + - The concatenating string (eg. ' * ') """ + if not iterable: + return "" + if not _JOIN_REG_EXP.search(fmt): + return fmt.join(iterable) + miter = iter(iterable) + first = next(miter) + for val in miter: + ret = fmt.format(first, val) + first = ret + return first - # TODO: provisional? Should we allow unbounded units? - # Should we allow UnitsContainer? - registry = getattr(unit, "_REGISTRY", None) - - if isinstance(unit, UnitsContainer): - out = unit.items() - else: - out = unit._units.items() - - if "~" in spec: - if registry is None: - raise ValueError( - f"Can't short format a {type(unit)} without a registry." - " This is usually triggered when formatting a instance" - " of the internal `UnitsContainer`." - ) - out = short_form(out, registry) - - if locale is not None: - out = localized_form(out, use_plural, length or "long", locale) - - if registry: - out = registry.formatter.default_sort_func(out, registry) - - return out - - -def dim_sort( - items: Iterable[tuple[str, Number]], registry: UnitRegistry | None -) -> Iterable[tuple[str, Number]]: - """Sort a list of units by dimensional order (from `registry.formatter.dim_order`). - - Parameters - ---------- - items : tuple - a list of tuples containing (unit names, exponent values). - registry : UnitRegistry | None - the registry to use for looking up the dimensions of each unit. - Returns - ------- - list - the list of units sorted by most significant dimension first. +def join_mu(joint_fstring: str, mstr: str, ustr: str) -> str: + """Join magnitude and units. - Raises - ------ - KeyError - If unit cannot be found in the registry. + This avoids that `3 and `1 / m` becomes `3 1 / m` """ + if ustr.startswith("1 / "): + return joint_fstring.format(mstr, ustr[2:]) + return joint_fstring.format(mstr, ustr) - if registry is None: - return items - dim_order = registry.formatter.dim_order +def join_unc(joint_fstring: str, lpar: str, rpar: str, mstr: str, ustr: str) -> str: + """Join uncertainty magnitude and units. - def sort_key(item: tuple[str, Number]): - unit_name, _unit_exponent = item - cname = registry.get_name(unit_name) - cname_dims = registry.get_dimensionality(cname) or {"[]": None} - for cname_dim in cname_dims: - if cname_dim in dim_order: - return dim_order.index(cname_dim), cname + Uncertainty magnitudes might require extra parenthesis when joined to units. + - YES: 3 +/- 1 + - NO : 3(1) + - NO : (3 +/ 1)e-9 - raise KeyError(f"Unit {unit_name} (aka {cname}) has no recognized dimensions") - - return sorted(items, key=sort_key) + This avoids that `(3 + 1)` and `meter` becomes ((3 +/- 1) meter) + """ + if mstr.startswith(lpar) or mstr.endswith(rpar): + return joint_fstring.format(mstr, ustr) + return joint_fstring.format(lpar + mstr + rpar, ustr) def formatter( - items: Iterable[tuple[str, Number]], + numerator: Iterable[tuple[str, Number]], + denominator: Iterable[tuple[str, Number]], as_ratio: bool = True, single_denominator: bool = False, product_fmt: str = " * ", @@ -317,14 +161,6 @@ def formatter( power_fmt: str = "{} ** {}", parentheses_fmt: str = "({0})", exp_call: FORMATTER = "{:n}".format, - sort: bool | None = None, - sort_func: Callable[ - [ - Iterable[tuple[str, Number]], - ], - Iterable[tuple[str, Number]], - ] - | None = sorted, ) -> str: """Format a list of (name, exponent) pairs. @@ -347,10 +183,6 @@ def formatter( the format used for parenthesis. (Default value = "({0})") exp_call : callable (Default value = lambda x: f"{x:n}") - sort : bool, optional - True to sort the formatted units alphabetically (Default value = True) - sort_func : callable - If not None, `sort_func` returns its sorting of the formatted units Returns ------- @@ -359,61 +191,43 @@ def formatter( """ - if sort is False: - warn( - "The boolean `sort` argument is deprecated. " - "Use `sort_func` to specify the sorting function (default=sorted) " - "or None to keep units in the original order." - ) - sort_func = None - elif sort is True: - warn( - "The boolean `sort` argument is deprecated. " - "Use `sort_func` to specify the sorting function (default=sorted) " - "or None to keep units in the original order." - ) - sort_func = sorted - - if sort_func is None: - items = tuple(items) - else: - items = sort_func(items) - - if not items: - return "" - if as_ratio: fun = lambda x: exp_call(abs(x)) else: fun = exp_call - pos_terms, neg_terms = [], [] - - for key, value in items: + pos_terms: list[str] = [] + for key, value in numerator: if value == 1: pos_terms.append(key) - elif value > 0: + else: pos_terms.append(power_fmt.format(key, fun(value))) - elif value == -1 and as_ratio: + + neg_terms: list[str] = [] + for key, value in denominator: + if value == -1 and as_ratio: neg_terms.append(key) else: neg_terms.append(power_fmt.format(key, fun(value))) + if not pos_terms and not neg_terms: + return "" + if not as_ratio: # Show as Product: positive * negative terms ** -1 - return _join(product_fmt, pos_terms + neg_terms) + return join_u(product_fmt, pos_terms + neg_terms) # Show as Ratio: positive terms / negative terms - pos_ret = _join(product_fmt, pos_terms) or "1" + pos_ret = join_u(product_fmt, pos_terms) or "1" if not neg_terms: return pos_ret if single_denominator: - neg_ret = _join(product_fmt, neg_terms) + neg_ret = join_u(product_fmt, neg_terms) if len(neg_terms) > 1: neg_ret = parentheses_fmt.format(neg_ret) else: - neg_ret = _join(division_fmt, neg_terms) + neg_ret = join_u(division_fmt, neg_terms) - return _join(division_fmt, [pos_ret, neg_ret]) + return join_u(division_fmt, [pos_ret, neg_ret]) diff --git a/pint/delegates/formatter/_spec_helpers.py b/pint/delegates/formatter/_spec_helpers.py index e331d0250..eab85fd71 100644 --- a/pint/delegates/formatter/_spec_helpers.py +++ b/pint/delegates/formatter/_spec_helpers.py @@ -12,11 +12,9 @@ import re import warnings -from collections.abc import Callable, Iterable +from collections.abc import Callable from typing import Any -from ...compat import Number - FORMATTER = Callable[ [ Any, @@ -28,8 +26,6 @@ # http://docs.python.org/2/library/string.html#format-specification-mini-language # We also add uS for uncertainties. _BASIC_TYPES = frozenset("bcdeEfFgGnosxX%uS") -_PRETTY_EXPONENTS = "⁰¹²³⁴⁵⁶⁷⁸⁹" -_JOIN_REG_EXP = re.compile(r"{\d*}") REGISTERED_FORMATTERS: dict[str, Any] = {} @@ -60,34 +56,6 @@ def parse_spec(spec: str) -> str: return result -def _join(fmt: str, iterable: Iterable[Any]) -> str: - """Join an iterable with the format specified in fmt. - - The format can be specified in two ways: - - PEP3101 format with two replacement fields (eg. '{} * {}') - - The concatenating string (eg. ' * ') - """ - if not iterable: - return "" - if not _JOIN_REG_EXP.search(fmt): - return fmt.join(iterable) - miter = iter(iterable) - first = next(miter) - for val in miter: - ret = fmt.format(first, val) - first = ret - return first - - -def pretty_fmt_exponent(num: Number) -> str: - """Format an number into a pretty printed exponent.""" - # unicode dot operator (U+22C5) looks like a superscript decimal - ret = f"{num:n}".replace("-", "⁻").replace(".", "\u22C5") - for n in range(10): - ret = ret.replace(str(n), _PRETTY_EXPONENTS[n]) - return ret - - def extract_custom_flags(spec: str) -> str: """Return custom flags present in a format specification @@ -159,28 +127,3 @@ def split_format( uspec = uspec or default_uspec return mspec, uspec - - -def join_mu(joint_fstring: str, mstr: str, ustr: str) -> str: - """Join magnitude and units. - - This avoids that `3 and `1 / m` becomes `3 1 / m` - """ - if ustr.startswith("1 / "): - return joint_fstring.format(mstr, ustr[2:]) - return joint_fstring.format(mstr, ustr) - - -def join_unc(joint_fstring: str, lpar: str, rpar: str, mstr: str, ustr: str) -> str: - """Join uncertainty magnitude and units. - - Uncertainty magnitudes might require extra parenthesis when joined to units. - - YES: 3 +/- 1 - - NO : 3(1) - - NO : (3 +/ 1)e-9 - - This avoids that `(3 + 1)` and `meter` becomes ((3 +/- 1) meter) - """ - if mstr.startswith(lpar) or mstr.endswith(rpar): - return joint_fstring.format(mstr, ustr) - return joint_fstring.format(lpar + mstr + rpar, ustr) diff --git a/pint/delegates/formatter/_to_register.py b/pint/delegates/formatter/_to_register.py index 0e82813bb..08ce0a25d 100644 --- a/pint/delegates/formatter/_to_register.py +++ b/pint/delegates/formatter/_to_register.py @@ -13,8 +13,9 @@ from ..._typing import Magnitude from ...compat import Unpack, ndarray, np -from ._format_helpers import BabelKwds, format_compound_unit, override_locale -from ._spec_helpers import REGISTERED_FORMATTERS, join_mu, split_format +from ._compound_unit_helpers import BabelKwds, prepare_compount_unit +from ._format_helpers import join_mu, override_locale +from ._spec_helpers import REGISTERED_FORMATTERS, split_format if TYPE_CHECKING: from ...facets.plain import MagnitudeT, PlainQuantity, PlainUnit @@ -80,9 +81,10 @@ def format_magnitude( def format_unit( self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: - units = unit._REGISTRY.UnitsContainer( - format_compound_unit(unit, uspec, **babel_kwds) + numerator, _denominator = prepare_compount_unit( + unit, uspec, **babel_kwds, as_ratio=False ) + units = unit._REGISTRY.UnitsContainer(numerator) return func(units, registry=unit._REGISTRY, **babel_kwds) diff --git a/pint/delegates/formatter/full.py b/pint/delegates/formatter/full.py index a8df701fa..1453133a0 100644 --- a/pint/delegates/formatter/full.py +++ b/pint/delegates/formatter/full.py @@ -12,13 +12,12 @@ from __future__ import annotations import locale -from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Any, Literal from ..._typing import Magnitude -from ...compat import Number, Unpack, babel_parse +from ...compat import Unpack, babel_parse from ...util import iterable -from ._format_helpers import BabelKwds +from ._compound_unit_helpers import BabelKwds, SortFunc, sort_by_unit_name from ._to_register import REGISTERED_FORMATTERS from .html import HTMLFormatter from .latex import LatexFormatter, SIunitxFormatter @@ -28,7 +27,6 @@ from ...compat import Locale from ...facets.measurement import Measurement from ...facets.plain import ( - GenericPlainRegistry, MagnitudeT, PlainQuantity, PlainUnit, @@ -44,8 +42,9 @@ class FullFormatter: _formatters: dict[str, Any] = {} default_format: str = "" + # TODO: This can be over-riden by the registry definitions file - dim_order = ( + dim_order: tuple[str, ...] = ( "[substance]", "[mass]", "[current]", @@ -55,15 +54,10 @@ class FullFormatter: "[time]", "[temperature]", ) - default_sort_func: None | ( - Callable[ - [Iterable[tuple[str, Number]], GenericPlainRegistry], - Iterable[tuple[str, Number]], - ] - ) = lambda self, x, registry: sorted(x) + + default_sort_func: SortFunc | None = staticmethod(sort_by_unit_name) locale: Locale | None = None - babel_length: Literal["short", "long", "narrow"] = "long" def set_locale(self, loc: str | None) -> None: """Change the locale used by default by `format_babel`. @@ -115,10 +109,17 @@ def format_magnitude( ) def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: uspec = uspec or self.default_format - return self.get_formatter(uspec).format_unit(unit, uspec, **babel_kwds) + sort_func = sort_func or self.default_sort_func + return self.get_formatter(uspec).format_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) def format_quantity( self, @@ -136,15 +137,19 @@ def format_quantity( del quantity - use_plural = obj.magnitude > 1 - if iterable(use_plural): - use_plural = True + if "use_plural" in babel_kwds: + use_plural = babel_kwds["use_plural"] + else: + use_plural = obj.magnitude > 1 + if iterable(use_plural): + use_plural = True return self.get_formatter(spec).format_quantity( obj, spec, - use_plural=babel_kwds.get("use_plural", use_plural), - length=babel_kwds.get("length", self.babel_length), + sort_func=self.default_sort_func, + use_plural=use_plural, + length=babel_kwds.get("length", None), locale=babel_kwds.get("locale", self.locale), ) @@ -171,8 +176,9 @@ def format_measurement( return self.get_formatter(meas_spec).format_measurement( obj, meas_spec, + sort_func=self.default_sort_func, use_plural=babel_kwds.get("use_plural", use_plural), - length=babel_kwds.get("length", self.babel_length), + length=babel_kwds.get("length", None), locale=babel_kwds.get("locale", self.locale), ) @@ -184,7 +190,7 @@ def format_unit_babel( self, unit: PlainUnit, spec: str = "", - length: Literal["short", "long", "narrow"] | None = "long", + length: Literal["short", "long", "narrow"] | None = None, locale: Locale | None = None, ) -> str: if self.locale is None and locale is None: @@ -195,8 +201,9 @@ def format_unit_babel( return self.format_unit( unit, spec or self.default_format, + sort_func=self.default_sort_func, use_plural=False, - length=length or self.babel_length, + length=length, locale=locale or self.locale, ) @@ -204,7 +211,7 @@ def format_quantity_babel( self, quantity: PlainQuantity[MagnitudeT], spec: str = "", - length: Literal["short", "long", "narrow"] = "long", + length: Literal["short", "long", "narrow"] | None = None, locale: Locale | None = None, ) -> str: if self.locale is None and locale is None: @@ -215,11 +222,13 @@ def format_quantity_babel( use_plural = quantity.magnitude > 1 if iterable(use_plural): use_plural = True + return self.format_quantity( quantity, spec or self.default_format, + sort_func=self.default_sort_func, use_plural=use_plural, - length=length or self.babel_length, + length=length, locale=locale or self.locale, ) diff --git a/pint/delegates/formatter/html.py b/pint/delegates/formatter/html.py index 4f866c947..ea48f6eb6 100644 --- a/pint/delegates/formatter/html.py +++ b/pint/delegates/formatter/html.py @@ -17,10 +17,19 @@ from ..._typing import Magnitude from ...compat import Unpack, ndarray, np from ...util import iterable -from ._format_helpers import BabelKwds, format_compound_unit, formatter, override_locale -from ._spec_helpers import ( +from ._compound_unit_helpers import ( + BabelKwds, + SortFunc, + localize_per, + prepare_compount_unit, +) +from ._format_helpers import ( + formatter, join_mu, join_unc, + override_locale, +) +from ._spec_helpers import ( remove_custom_flags, split_format, ) @@ -75,24 +84,38 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) + + if babel_kwds.get("locale", None): + length = babel_kwds.get("length") or ("short" if "~" in uspec else "long") + division_fmt = localize_per(length, babel_kwds.get("locale"), "{}/{}") + else: + division_fmt = "{}/{}" + return formatter( - units, + numerator, + denominator, as_ratio=True, single_denominator=True, product_fmt=r" ", - division_fmt=r"{}/{}", + division_fmt=division_fmt, power_fmt=r"{}{}", parentheses_fmt=r"({})", - sort_func=None, ) def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -116,13 +139,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: unc_str = format(uncertainty, unc_spec).replace("+/-", " ± ") @@ -135,6 +159,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -154,5 +179,5 @@ def format_measurement( "(", ")", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) diff --git a/pint/delegates/formatter/latex.py b/pint/delegates/formatter/latex.py index 77369fb01..3d435307d 100644 --- a/pint/delegates/formatter/latex.py +++ b/pint/delegates/formatter/latex.py @@ -20,11 +20,19 @@ from ..._typing import Magnitude from ...compat import Number, Unpack, ndarray -from ._format_helpers import BabelKwds, format_compound_unit, formatter, override_locale -from ._spec_helpers import ( +from ._compound_unit_helpers import ( + BabelKwds, + SortFunc, + prepare_compount_unit, +) +from ._format_helpers import ( FORMATTER, + formatter, join_mu, join_unc, + override_locale, +) +from ._spec_helpers import ( remove_custom_flags, split_format, ) @@ -175,27 +183,46 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) + + numerator = ((rf"\mathrm{{{latex_escape(u)}}}", p) for u, p in numerator) + denominator = ((rf"\mathrm{{{latex_escape(u)}}}", p) for u, p in denominator) + + # Localized latex + # if babel_kwds.get("locale", None): + # length = babel_kwds.get("length") or ("short" if "~" in uspec else "long") + # division_fmt = localize_per(length, babel_kwds.get("locale"), "{}/{}") + # else: + # division_fmt = "{}/{}" + + # division_fmt = r"\frac" + division_fmt.format("[{}]", "[{}]") - preprocessed = {rf"\mathrm{{{latex_escape(u)}}}": p for u, p in units} formatted = formatter( - preprocessed.items(), + numerator, + denominator, as_ratio=True, single_denominator=True, product_fmt=r" \cdot ", division_fmt=r"\frac[{}][{}]", power_fmt="{}^[{}]", parentheses_fmt=r"\left({}\right)", - sort_func=None, ) + return formatted.replace("[", "{").replace("]", "}") def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -209,13 +236,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: # uncertainties handles everythin related to latex. @@ -230,6 +258,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -253,7 +282,7 @@ def format_measurement( r"\left(", r"\right)", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) @@ -264,7 +293,11 @@ class SIunitxFormatter: """ def format_magnitude( - self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + magnitude: Magnitude, + mspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: with override_locale(mspec, babel_kwds.get("locale", None)) as format_number: if isinstance(magnitude, ndarray): @@ -278,7 +311,11 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: registry = unit._REGISTRY if registry is None: @@ -308,6 +345,7 @@ def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -319,13 +357,16 @@ def format_quantity( joint_fstring = "{}{}" mstr = self.format_magnitude(quantity.magnitude, mspec, **babel_kwds) - ustr = self.format_unit(quantity.units, uspec, **babel_kwds)[len(r"\si[]") :] + ustr = self.format_unit(quantity.units, uspec, sort_func, **babel_kwds)[ + len(r"\si[]") : + ] return r"\SI[]" + join_mu(joint_fstring, "{%s}" % mstr, ustr) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: # SIunitx requires space between "+-" (or "\pm") and the nominal value @@ -343,6 +384,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -363,5 +405,7 @@ def format_measurement( r"", "{%s}" % self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds)[len(r"\si[]") :], + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds)[ + len(r"\si[]") : + ], ) diff --git a/pint/delegates/formatter/plain.py b/pint/delegates/formatter/plain.py index c2b5eaf8d..18cb9df15 100644 --- a/pint/delegates/formatter/plain.py +++ b/pint/delegates/formatter/plain.py @@ -14,16 +14,26 @@ from __future__ import annotations +import itertools import re from typing import TYPE_CHECKING from ..._typing import Magnitude from ...compat import Unpack, ndarray, np -from ._format_helpers import BabelKwds, format_compound_unit, formatter, override_locale -from ._spec_helpers import ( +from ._compound_unit_helpers import ( + BabelKwds, + SortFunc, + localize_per, + prepare_compount_unit, +) +from ._format_helpers import ( + formatter, join_mu, join_unc, + override_locale, pretty_fmt_exponent, +) +from ._spec_helpers import ( remove_custom_flags, split_format, ) @@ -61,28 +71,42 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) """Format a unit (can be compound) into string given a string formatting specification and locale related arguments. """ + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) + + if babel_kwds.get("locale", None): + length = babel_kwds.get("length") or ("short" if "~" in uspec else "long") + division_fmt = localize_per(length, babel_kwds.get("locale"), "{} / {}") + else: + division_fmt = "{} / {}" + return formatter( - units, + numerator, + denominator, as_ratio=True, single_denominator=False, - product_fmt=" * ", - division_fmt=" / ", + product_fmt="{} * {}", + division_fmt=division_fmt, power_fmt="{} ** {}", parentheses_fmt=r"({})", - sort_func=None, ) def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: """Format a quantity (magnitude and unit) into string @@ -99,13 +123,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: """Format an uncertainty magnitude (nominal value and stdev) into string @@ -118,6 +143,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: """Format an measurement (uncertainty and units) into string @@ -141,7 +167,7 @@ def format_measurement( "(", ")", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) @@ -163,25 +189,35 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) + + # Division format in compact formatter is not localized. + division_fmt = "{}/{}" return formatter( - units, + numerator, + denominator, as_ratio=True, single_denominator=False, product_fmt="*", # TODO: Should this just be ''? - division_fmt="/", + division_fmt=division_fmt, power_fmt="{}**{}", parentheses_fmt=r"({})", - sort_func=None, ) def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -195,13 +231,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: return format(uncertainty, unc_spec).replace("+/-", "+/-") @@ -210,6 +247,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -229,7 +267,7 @@ def format_measurement( "(", ")", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) @@ -257,25 +295,39 @@ def format_magnitude( return mstr def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) + + if babel_kwds.get("locale", None): + length = babel_kwds.get("length") or ("short" if "~" in uspec else "long") + division_fmt = localize_per(length, babel_kwds.get("locale"), "{}/{}") + else: + division_fmt = "{}/{}" + return formatter( - units, + numerator, + denominator, as_ratio=True, single_denominator=False, product_fmt="·", - division_fmt="/", + division_fmt=division_fmt, power_fmt="{}{}", parentheses_fmt="({})", exp_call=pretty_fmt_exponent, - sort_func=None, ) def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -289,13 +341,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: return format(uncertainty, unc_spec).replace("±", " ± ") @@ -304,6 +357,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -322,7 +376,7 @@ def format_measurement( "(", ")", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) @@ -338,16 +392,26 @@ def format_magnitude( return str(magnitude) def format_unit( - self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] + self, + unit: PlainUnit, + uspec: str = "", + sort_func: SortFunc | None = None, + **babel_kwds: Unpack[BabelKwds], ) -> str: - units = format_compound_unit(unit, uspec, **babel_kwds) + numerator, denominator = prepare_compount_unit( + unit, uspec, sort_func=sort_func, **babel_kwds + ) - return " * ".join(k if v == 1 else f"{k} ** {v}" for k, v in units) + return " * ".join( + k if v == 1 else f"{k} ** {v}" + for k, v in itertools.chain(numerator, denominator) + ) def format_quantity( self, quantity: PlainQuantity[MagnitudeT], qspec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = quantity._REGISTRY @@ -360,13 +424,14 @@ def format_quantity( return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), - self.format_unit(quantity.units, uspec, **babel_kwds), + self.format_unit(quantity.units, uspec, sort_func, **babel_kwds), ) def format_uncertainty( self, uncertainty, unc_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: return format(uncertainty, unc_spec) @@ -375,6 +440,7 @@ def format_measurement( self, measurement: Measurement, meas_spec: str = "", + sort_func: SortFunc | None = None, **babel_kwds: Unpack[BabelKwds], ) -> str: registry = measurement._REGISTRY @@ -394,5 +460,5 @@ def format_measurement( "(", ")", self.format_uncertainty(measurement.magnitude, unc_spec, **babel_kwds), - self.format_unit(measurement.units, uspec, **babel_kwds), + self.format_unit(measurement.units, uspec, sort_func, **babel_kwds), ) diff --git a/pint/formatting.py b/pint/formatting.py index 2d24c3e92..a8be9baca 100644 --- a/pint/formatting.py +++ b/pint/formatting.py @@ -10,13 +10,22 @@ from __future__ import annotations -# noqa +from numbers import Number +from typing import Iterable + +from .delegates.formatter._format_helpers import ( + _PRETTY_EXPONENTS, # noqa: F401 +) +from .delegates.formatter._format_helpers import ( + join_u as _join, # noqa: F401 +) +from .delegates.formatter._format_helpers import ( + pretty_fmt_exponent as _pretty_fmt_exponent, # noqa: F401 +) from .delegates.formatter._spec_helpers import ( _BASIC_TYPES, # noqa: F401 - _PRETTY_EXPONENTS, # noqa: F401 FORMATTER, # noqa: F401 REGISTERED_FORMATTERS, - _join, # noqa: F401 extract_custom_flags, # noqa: F401 remove_custom_flags, # noqa: F401 split_format, # noqa: F401 @@ -24,9 +33,6 @@ from .delegates.formatter._spec_helpers import ( parse_spec as _parse_spec, # noqa: F401 ) -from .delegates.formatter._spec_helpers import ( - pretty_fmt_exponent as _pretty_fmt_exponent, # noqa: F401 -) # noqa from .delegates.formatter._to_register import register_unit_format # noqa: F401 @@ -43,6 +49,97 @@ ) +def formatter( + items: Iterable[tuple[str, Number]], + as_ratio: bool = True, + single_denominator: bool = False, + product_fmt: str = " * ", + division_fmt: str = " / ", + power_fmt: str = "{} ** {}", + parentheses_fmt: str = "({0})", + exp_call: FORMATTER = "{:n}".format, + sort: bool = True, +) -> str: + """Format a list of (name, exponent) pairs. + + Parameters + ---------- + items : list + a list of (name, exponent) pairs. + as_ratio : bool, optional + True to display as ratio, False as negative powers. (Default value = True) + single_denominator : bool, optional + all with terms with negative exponents are + collected together. (Default value = False) + product_fmt : str + the format used for multiplication. (Default value = " * ") + division_fmt : str + the format used for division. (Default value = " / ") + power_fmt : str + the format used for exponentiation. (Default value = "{} ** {}") + parentheses_fmt : str + the format used for parenthesis. (Default value = "({0})") + exp_call : callable + (Default value = lambda x: f"{x:n}") + sort : bool, optional + True to sort the formatted units alphabetically (Default value = True) + + Returns + ------- + str + the formula as a string. + + """ + + join_u = _join + + if sort is False: + items = tuple(items) + else: + items = sorted(items) + + if not items: + return "" + + if as_ratio: + fun = lambda x: exp_call(abs(x)) + else: + fun = exp_call + + pos_terms, neg_terms = [], [] + + for key, value in items: + if value == 1: + pos_terms.append(key) + elif value > 0: + pos_terms.append(power_fmt.format(key, fun(value))) + elif value == -1 and as_ratio: + neg_terms.append(key) + else: + neg_terms.append(power_fmt.format(key, fun(value))) + + if not as_ratio: + # Show as Product: positive * negative terms ** -1 + return _join(product_fmt, pos_terms + neg_terms) + + # Show as Ratio: positive terms / negative terms + pos_ret = _join(product_fmt, pos_terms) or "1" + + if not neg_terms: + return pos_ret + + if single_denominator: + neg_ret = join_u(product_fmt, neg_terms) + if len(neg_terms) > 1: + neg_ret = parentheses_fmt.format(neg_ret) + else: + neg_ret = join_u(division_fmt, neg_terms) + + # TODO: first or last pos_ret should be pluralized + + return _join(division_fmt, [pos_ret, neg_ret]) + + def format_unit(unit, spec: str, registry=None, **options): # registry may be None to allow formatting `UnitsContainer` objects # in that case, the spec may not be "Lx" diff --git a/pint/testsuite/test_babel.py b/pint/testsuite/test_babel.py index 17c355569..2dd66d58d 100644 --- a/pint/testsuite/test_babel.py +++ b/pint/testsuite/test_babel.py @@ -30,7 +30,7 @@ def test_format(func_registry): acceleration = distance / time**2 assert ( acceleration.format_babel(spec=".3nP", locale="fr_FR", length="long") - == "0,367 mètre/seconde²" + == "0,367 mètre par seconde²" ) mks = ureg.get_system("mks") assert mks.format_babel(locale="fr_FR") == "métrique" @@ -53,7 +53,8 @@ def test_registry_locale(): == "0,367 mètre/seconde**2" ) assert ( - acceleration.format_babel(spec=".3nP", length="long") == "0,367 mètre/seconde²" + acceleration.format_babel(spec=".3nP", length="long") + == "0,367 mètre par seconde²" ) mks = ureg.get_system("mks") assert mks.format_babel(locale="fr_FR") == "métrique" diff --git a/pint/testsuite/test_formatter.py b/pint/testsuite/test_formatter.py index 5a6897b13..d8b5722bc 100644 --- a/pint/testsuite/test_formatter.py +++ b/pint/testsuite/test_formatter.py @@ -2,62 +2,44 @@ import pytest -import pint.delegates.formatter._format_helpers from pint import formatting as fmt +from pint.delegates.formatter._format_helpers import formatter, join_u class TestFormatter: def test_join(self): for empty in ((), []): - assert fmt._join("s", empty) == "" - assert fmt._join("*", "1 2 3".split()) == "1*2*3" - assert fmt._join("{0}*{1}", "1 2 3".split()) == "1*2*3" + assert join_u("s", empty) == "" + assert join_u("*", "1 2 3".split()) == "1*2*3" + assert join_u("{0}*{1}", "1 2 3".split()) == "1*2*3" def test_formatter(self): - assert pint.delegates.formatter._format_helpers.formatter({}.items()) == "" - assert ( - pint.delegates.formatter._format_helpers.formatter(dict(meter=1).items()) - == "meter" - ) - assert ( - pint.delegates.formatter._format_helpers.formatter(dict(meter=-1).items()) - == "1 / meter" - ) - assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1).items(), as_ratio=False - ) - == "meter ** -1" - ) + assert formatter({}.items(), ()) == "" + assert formatter(dict(meter=1).items(), ()) == "meter" + assert formatter((), dict(meter=-1).items()) == "1 / meter" + assert formatter((), dict(meter=-1).items(), as_ratio=False) == "meter ** -1" assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1, second=-1).items(), as_ratio=False - ) + formatter((), dict(meter=-1, second=-1).items(), as_ratio=False) == "meter ** -1 * second ** -1" ) assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1, second=-1).items() + formatter( + (), + dict(meter=-1, second=-1).items(), ) == "1 / meter / second" ) assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1, second=-1).items(), single_denominator=True - ) + formatter((), dict(meter=-1, second=-1).items(), single_denominator=True) == "1 / (meter * second)" ) assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1, second=-2).items() - ) + formatter((), dict(meter=-1, second=-2).items()) == "1 / meter / second ** 2" ) assert ( - pint.delegates.formatter._format_helpers.formatter( - dict(meter=-1, second=-2).items(), single_denominator=True - ) + formatter((), dict(meter=-1, second=-2).items(), single_denominator=True) == "1 / (meter * second ** 2)" ) diff --git a/pint/testsuite/test_issues.py b/pint/testsuite/test_issues.py index 7de517995..dc63ececd 100644 --- a/pint/testsuite/test_issues.py +++ b/pint/testsuite/test_issues.py @@ -9,6 +9,7 @@ from pint import Context, DimensionalityError, UnitRegistry, get_application_registry from pint.compat import np +from pint.delegates.formatter._compound_unit_helpers import sort_by_dimensionality from pint.facets.plain.unit import UnitsContainer from pint.testing import assert_equal from pint.testsuite import QuantityTestCase, helpers @@ -893,8 +894,8 @@ def test_issue_1400(self, sess_registry): q2 = 3.1 * sess_registry.W / sess_registry.cm assert q1.format_babel("~", locale="es_ES") == "3,1 W" assert q1.format_babel("", locale="es_ES") == "3,1 vatios" - assert q2.format_babel("~", locale="es_ES") == "3,1 W / cm" - assert q2.format_babel("", locale="es_ES") == "3,1 vatios / centímetros" + assert q2.format_babel("~", locale="es_ES") == "3,1 W/cm" + assert q2.format_babel("", locale="es_ES") == "3,1 vatios por centímetro" @helpers.requires_uncertainties() def test_issue1611(self, module_registry): @@ -1158,31 +1159,31 @@ def test_issues_1505(): ) # unexpected fail (magnitude should be a decimal) -def test_issues_1841(subtests): - from pint.delegates.formatter._format_helpers import dim_sort - - ur = UnitRegistry() - ur.formatter.default_sort_func = dim_sort - - for x, spec, result in ( - (ur.Unit(UnitsContainer(hour=1, watt=1)), "P~", "W·h"), - (ur.Unit(UnitsContainer(ampere=1, volt=1)), "P~", "V·A"), - (ur.Unit(UnitsContainer(meter=1, newton=1)), "P~", "N·m"), - ): - with subtests.test(spec): - ur.default_format = spec - assert f"{x}" == result, f"Failed for {spec}, {result}" +@pytest.mark.parametrize( + "units,spec,expected", + [ + # (dict(hour=1, watt=1), "P~", "W·h"), + (dict(ampere=1, volt=1), "P~", "V·A"), + # (dict(meter=1, newton=1), "P~", "N·m"), + ], +) +def test_issues_1841(func_registry, units, spec, expected): + ur = func_registry + ur.formatter.default_sort_func = sort_by_dimensionality + ur.default_format = spec + value = ur.Unit(UnitsContainer(**units)) + assert f"{value}" == expected @pytest.mark.xfail def test_issues_1841_xfail(): from pint import formatting as fmt - from pint.delegates.formatter._format_helpers import dim_sort + from pint.delegates.formatter._compound_unit_helpers import sort_by_dimensionality # sets compact display mode by default ur = UnitRegistry() ur.default_format = "~P" - ur.formatter.default_sort_func = dim_sort + ur.formatter.default_sort_func = sort_by_dimensionality q = ur.Quantity("2*pi radian * hour") diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py index 194552d37..aa4b96b4d 100644 --- a/pint/testsuite/test_quantity.py +++ b/pint/testsuite/test_quantity.py @@ -174,7 +174,7 @@ def test_quantity_format(self, subtests): ("{:Lx}", r"\SI[]{4.12345678}{\kilo\gram\meter\squared\per\second}"), ): with subtests.test(spec): - assert spec.format(x) == result + assert spec.format(x) == result, spec # Check the special case that prevents e.g. '3 1 / second' x = self.Q_(3, UnitsContainer(second=-1))