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))