diff --git a/pint/__init__.py b/pint/__init__.py index d7f08d58c..127a45ca6 100644 --- a/pint/__init__.py +++ b/pint/__init__.py @@ -15,6 +15,8 @@ from importlib.metadata import version +from .delegates.formatter._format_helpers import formatter + from .errors import ( # noqa: F401 DefinitionSyntaxError, DimensionalityError, @@ -25,7 +27,7 @@ UndefinedUnitError, UnitStrippedWarning, ) -from .formatting import formatter, register_unit_format +from .formatting import register_unit_format from .registry import ApplicationRegistry, LazyRegistry, UnitRegistry from .util import logger, pi_theorem # noqa: F401 diff --git a/pint/delegates/formatter/__init__.py b/pint/delegates/formatter/__init__.py index 84fdd8777..31d36b0f6 100644 --- a/pint/delegates/formatter/__init__.py +++ b/pint/delegates/formatter/__init__.py @@ -1,18 +1,23 @@ """ pint.delegates.formatter - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Formats quantities and units. + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Easy to replace and extend string formatting. + + See pint.delegates.formatter.plain.DefaultFormatter for a + description of a formatter. + :copyright: 2022 by Pint Authors, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ -from .full import MultipleFormatter +from .full import FullFormatter + +class Formatter(FullFormatter): + """Default Pint Formatter""" -class Formatter(MultipleFormatter): - # TODO: this should derive from all relevant formaters to - # reproduce the current behavior of Pint. pass diff --git a/pint/delegates/formatter/_unit_handlers.py b/pint/delegates/formatter/_format_helpers.py similarity index 61% rename from pint/delegates/formatter/_unit_handlers.py rename to pint/delegates/formatter/_format_helpers.py index 8ff9a8f77..5f36b39d0 100644 --- a/pint/delegates/formatter/_unit_handlers.py +++ b/pint/delegates/formatter/_format_helpers.py @@ -1,3 +1,14 @@ +""" + pint.delegates.formatter._format_helpers + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Convenient functions to help string formatting operations. + + :copyright: 2022 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + + from __future__ import annotations from functools import partial @@ -17,6 +28,8 @@ import locale +from pint.delegates.formatter._spec_helpers import FORMATTER, _join + from ...compat import babel_parse, ndarray from ...util import UnitsContainer @@ -31,6 +44,72 @@ from ...compat import Locale, Number T = TypeVar("T") +U = TypeVar("U") +V = TypeVar("U") + + +class BabelKwds(TypedDict): + """Babel related keywords used in formatters.""" + + use_plural: bool + length: Literal["short", "long", "narrow"] | None + locale: Locale | str | None + + +def format_number(value: Any, spec: str = "") -> str: + """Format number + + This function might disapear in the future. + Right now is aiding backwards compatible migration. + """ + if isinstance(value, float): + return format(value, spec or ".16n") + + elif isinstance(value, int): + return format(value, spec or "n") + + elif isinstance(value, ndarray) and value.ndim == 0: + if issubclass(value.dtype.type, np_integer): + return format(value, spec or "n") + else: + return format(value, spec or ".16n") + else: + return str(value) + + +def builtin_format(value: Any, spec: str = "") -> str: + """A keyword enabled replacement for builtin format + + format has positional only arguments + and this cannot be partialized + and np requires a callable. + """ + return format(value, spec) + + +@contextmanager +def override_locale( + spec: str, locale: str | Locale | None +) -> Generator[Callable[[Any], str], Any, None]: + """Given a spec a locale, yields a function to format a number. + + IMPORTANT: When the locale is not None, this function uses setlocale + and therefore is not thread safe. + """ + + if locale is None: + # If locale is None, just return the builtin format function. + yield ("{:" + spec + "}").format + else: + # If locale is not None, change it and return the backwards compatible + # format_number. + prev_locale_string = getlocale(LC_NUMERIC) + if isinstance(locale, str): + setlocale(LC_NUMERIC, locale) + else: + setlocale(LC_NUMERIC, str(locale)) + yield partial(format_number, spec=spec) + setlocale(LC_NUMERIC, prev_locale_string) def format_unit_no_magnitude( @@ -115,23 +194,25 @@ def format_unit_no_magnitude( return f"{fallback_name or measurement_unit}" # pragma: no cover -def _unit_mapper( - units: Iterable[tuple[str, T]], - shortener: Callable[ +def map_keys( + func: Callable[ [ - str, + T, ], - str, + U, ], -) -> Iterable[tuple[str, T]]: - return map(lambda el: (shortener(el[0]), el[1]), units) + 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]]: - return _unit_mapper(units, registry.get_symbol) + """Replace each unit by its short form.""" + return map_keys(registry.get_symbol, units) def localized_form( @@ -140,6 +221,7 @@ def localized_form( 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, @@ -147,13 +229,7 @@ def localized_form( locale=babel_parse(locale), ) - return _unit_mapper(units, mapper) - - -class BabelKwds(TypedDict): - use_plural: bool - length: Literal["short", "long", "narrow"] | None - locale: Locale | str | None + return map_keys(mapper, units) def format_compound_unit( @@ -163,6 +239,10 @@ def format_compound_unit( 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. + """ + # TODO: provisional? Should we allow unbounded units? # Should we allow UnitsContainer? registry = getattr(unit, "_REGISTRY", None) @@ -187,41 +267,88 @@ def format_compound_unit( return out -def format_number(value: Any, spec: str = "") -> str: - if isinstance(value, float): - return format(value, spec or ".16n") +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. - elif isinstance(value, int): - return format(value, spec or "n") + """ - elif isinstance(value, ndarray) and value.ndim == 0: - if issubclass(value.dtype.type, np_integer): - return format(value, spec or "n") - else: - return format(value, spec or ".16n") + if sort: + items = sorted(items) else: - return str(value) + items = tuple(items) + if not items: + return "" -# TODO: ugly, ugly -# format has positional only arguments -# and this cannot be partialized -# and np requires a callable. We could create a lambda -def builtin_format(value: Any, spec: str = "") -> str: - return format(value, spec) + if as_ratio: + fun = lambda x: exp_call(abs(x)) + else: + fun = exp_call + pos_terms, neg_terms = [], [] -@contextmanager -def override_locale( - spec: str, locale: str | Locale | None -) -> Generator[Callable[[Any], str], Any, None]: - if locale is None: - yield ("{:" + spec + "}").format - else: - prev_locale_string = getlocale(LC_NUMERIC) - if isinstance(locale, str): - setlocale(LC_NUMERIC, locale) + 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: - setlocale(LC_NUMERIC, str(locale)) - yield partial(format_number, spec=spec) - setlocale(LC_NUMERIC, prev_locale_string) + 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(product_fmt, neg_terms) + if len(neg_terms) > 1: + neg_ret = parentheses_fmt.format(neg_ret) + else: + neg_ret = _join(division_fmt, neg_terms) + + return _join(division_fmt, [pos_ret, neg_ret]) diff --git a/pint/delegates/formatter/_helpers.py b/pint/delegates/formatter/_spec_helpers.py similarity index 62% rename from pint/delegates/formatter/_helpers.py rename to pint/delegates/formatter/_spec_helpers.py index 4ae48375f..2c1b8dea8 100644 --- a/pint/delegates/formatter/_helpers.py +++ b/pint/delegates/formatter/_spec_helpers.py @@ -1,3 +1,13 @@ +""" + pint.delegates.formatter._spec_helpers + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Convenient functions to deal with format specifications. + + :copyright: 2022 by Pint Authors, see AUTHORS for more details. + :license: BSD, see LICENSE for more details. +""" + from __future__ import annotations from typing import Iterable, Callable, Any @@ -12,101 +22,23 @@ str, ] - -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. - - """ - - if sort: - items = sorted(items) - else: - items = tuple(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(product_fmt, neg_terms) - if len(neg_terms) > 1: - neg_ret = parentheses_fmt.format(neg_ret) - else: - neg_ret = _join(division_fmt, neg_terms) - - return _join(division_fmt, [pos_ret, neg_ret]) - - # Extract just the type from the specification mini-language: see # 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*}") + + +def parse_spec(spec: str) -> str: + """Parse and return spec. + If an unknown item is found, raise a ValueError. -def _parse_spec(spec: str) -> str: + This function still needs work: + - what happens if two distinct values are found? + + """ # TODO: provisional from ...formatting import _ORPHAN_FORMATTER @@ -128,31 +60,16 @@ def _parse_spec(spec: str) -> str: return result -__JOIN_REG_EXP = re.compile(r"{\d*}") - - 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. ' * ') - - Parameters - ---------- - fmt : str - - iterable : - - - Returns - ------- - str - """ if not iterable: return "" - if not __JOIN_REG_EXP.search(fmt): + if not _JOIN_REG_EXP.search(fmt): return fmt.join(iterable) miter = iter(iterable) first = next(miter) @@ -162,21 +79,8 @@ def _join(fmt: str, iterable: Iterable[Any]) -> str: return first -_PRETTY_EXPONENTS = "⁰¹²³⁴⁵⁶⁷⁸⁹" - - -def _pretty_fmt_exponent(num: Number) -> str: - """Format an number into a pretty printed exponent. - - Parameters - ---------- - num : int - - Returns - ------- - str - - """ +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): @@ -185,6 +89,10 @@ def _pretty_fmt_exponent(num: Number) -> str: def extract_custom_flags(spec: str) -> str: + """Return custom flags present in a format specification + + (i.e those not part of Python's formatting mini language) + """ import re if not spec: @@ -205,6 +113,11 @@ def extract_custom_flags(spec: str) -> str: def remove_custom_flags(spec: str) -> str: + """Remove custom flags present in a format specification + + (i.e those not part of Python's formatting mini language) + """ + # TODO: provisional from ...formatting import _ORPHAN_FORMATTER @@ -219,6 +132,7 @@ def remove_custom_flags(spec: str) -> str: def split_format( spec: str, default: str, separate_format_defaults: bool = True ) -> tuple[str, str]: + """Split format specification into magnitude and unit format.""" mspec = remove_custom_flags(spec) uspec = extract_custom_flags(spec) @@ -259,12 +173,25 @@ def split_format( 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 778733801..b98defa8b 100644 --- a/pint/delegates/formatter/_to_register.py +++ b/pint/delegates/formatter/_to_register.py @@ -10,14 +10,14 @@ from typing import TYPE_CHECKING, Callable from ...compat import ndarray, np, Unpack -from ._helpers import ( +from ._spec_helpers import ( split_format, join_mu, ) from ..._typing import Magnitude -from ._unit_handlers import format_compound_unit, BabelKwds, override_locale +from ._format_helpers import format_compound_unit, BabelKwds, override_locale if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT diff --git a/pint/delegates/formatter/full.py b/pint/delegates/formatter/full.py index c04f77771..3f3c6ad2f 100644 --- a/pint/delegates/formatter/full.py +++ b/pint/delegates/formatter/full.py @@ -1,7 +1,10 @@ """ - pint.delegates.formatter.base_formatter - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Common class and function for all formatters. + pint.delegates.formatter.full + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Implements: + - Full: dispatch to other formats, accept defaults. + :copyright: 2022 by Pint Authors, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ @@ -17,7 +20,7 @@ from .html import HTMLFormatter from .latex import LatexFormatter, SIunitxFormatter from .plain import RawFormatter, CompactFormatter, PrettyFormatter, DefaultFormatter -from ._unit_handlers import BabelKwds +from ._format_helpers import BabelKwds if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT @@ -25,7 +28,12 @@ from ...compat import Locale -class MultipleFormatter: +class FullFormatter: + """A formatter that dispatch to other formatters. + + Has a default format, locale and babel_length + """ + _formatters: dict[str, Any] = {} default_format: str = "" diff --git a/pint/delegates/formatter/html.py b/pint/delegates/formatter/html.py index 7381a9c33..3dc14330c 100644 --- a/pint/delegates/formatter/html.py +++ b/pint/delegates/formatter/html.py @@ -1,7 +1,10 @@ """ - pint.delegates.formatter.base_formatter - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Common class and function for all formatters. + pint.delegates.formatter.html + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Implements: + - HTML: suitable for web/jupyter notebook outputs. + :copyright: 2022 by Pint Authors, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ @@ -12,16 +15,15 @@ import re from ...util import iterable from ...compat import ndarray, np, Unpack -from ._helpers import ( +from ._spec_helpers import ( split_format, - formatter, join_mu, join_unc, remove_custom_flags, ) from ..._typing import Magnitude -from ._unit_handlers import BabelKwds, format_compound_unit, override_locale +from ._format_helpers import BabelKwds, format_compound_unit, formatter, override_locale if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT @@ -31,6 +33,8 @@ class HTMLFormatter: + """HTML localizable text formatter.""" + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: diff --git a/pint/delegates/formatter/latex.py b/pint/delegates/formatter/latex.py index f751b3b8b..aacf8cdf5 100644 --- a/pint/delegates/formatter/latex.py +++ b/pint/delegates/formatter/latex.py @@ -1,23 +1,28 @@ """ - pint.delegates.formatter.base_formatter - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Common class and function for all formatters. + pint.delegates.formatter.latex + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Implements: + - Latex: uses vainilla latex. + - SIunitx: uses latex siunitx package format. + :copyright: 2022 by Pint Authors, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ + from __future__ import annotations import functools from typing import TYPE_CHECKING, Any, Iterable, Union import re -from ._helpers import split_format, formatter, FORMATTER +from ._spec_helpers import split_format, FORMATTER from ..._typing import Magnitude from ...compat import ndarray, Unpack, Number -from ._unit_handlers import BabelKwds, override_locale, format_compound_unit -from ._helpers import join_mu, join_unc, remove_custom_flags +from ._format_helpers import BabelKwds, formatter, override_locale, format_compound_unit +from ._spec_helpers import join_mu, join_unc, remove_custom_flags if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT @@ -29,10 +34,13 @@ def vector_to_latex( vec: Iterable[Any], fmtfun: FORMATTER | str = "{:.2n}".format ) -> str: + """Format a vector into a latex string.""" return matrix_to_latex([vec], fmtfun) def matrix_to_latex(matrix: ItMatrix, fmtfun: FORMATTER | str = "{:.2n}".format) -> str: + """Format a matrix into a latex string.""" + ret: list[str] = [] for row in matrix: @@ -42,8 +50,15 @@ def matrix_to_latex(matrix: ItMatrix, fmtfun: FORMATTER | str = "{:.2n}".format) def ndarray_to_latex_parts( - ndarr, fmtfun: FORMATTER = "{:.2n}".format, dim: tuple[int, ...] = tuple() -): + ndarr: ndarray, fmtfun: FORMATTER = "{:.2n}".format, dim: tuple[int, ...] = tuple() +) -> list[str]: + """Convert an numpy array into an iterable of elements to be print. + + e.g. + - if the array is 2d, it will return an iterable of rows. + - if the array is 3d, it will return an iterable of matrices. + """ + if isinstance(fmtfun, str): fmtfun = fmtfun.format @@ -68,15 +83,16 @@ def ndarray_to_latex_parts( def ndarray_to_latex( - ndarr, fmtfun: FORMATTER | str = "{:.2n}".format, dim: tuple[int, ...] = tuple() + ndarr: ndarray, + fmtfun: FORMATTER | str = "{:.2n}".format, + dim: tuple[int, ...] = tuple(), ) -> str: + """Format a numpy array into string.""" return "\n".join(ndarray_to_latex_parts(ndarr, fmtfun, dim)) def latex_escape(string: str) -> str: - """ - Prepend characters that have a special meaning in LaTeX with a backslash. - """ + """Prepend characters that have a special meaning in LaTeX with a backslash.""" return functools.reduce( lambda s, m: re.sub(m[0], m[1], s), ( @@ -138,6 +154,8 @@ def _tothe(power: Union[int, float]) -> str: class LatexFormatter: + """Latex localizable text formatter.""" + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: @@ -234,6 +252,11 @@ def format_measurement( class SIunitxFormatter: + """Latex localizable text formatter with siunitx format. + + See: https://ctan.org/pkg/siunitx + """ + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: diff --git a/pint/delegates/formatter/plain.py b/pint/delegates/formatter/plain.py index 7eb66a923..4b9616631 100644 --- a/pint/delegates/formatter/plain.py +++ b/pint/delegates/formatter/plain.py @@ -1,7 +1,13 @@ """ - pint.delegates.formatter.base_formatter - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Common class and function for all formatters. + pint.delegates.formatter.plain + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Implements plain text formatters: + - Raw: as simple as it gets (no locale aware, no unit formatter.) + - Default: used when no string spec is given. + - Compact: like default but with less spaces. + - Pretty: pretty printed formatter. + :copyright: 2022 by Pint Authors, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ @@ -11,10 +17,9 @@ from typing import TYPE_CHECKING import re from ...compat import ndarray, np, Unpack -from ._helpers import ( - _pretty_fmt_exponent, +from ._spec_helpers import ( + pretty_fmt_exponent, split_format, - formatter, join_mu, join_unc, remove_custom_flags, @@ -22,7 +27,7 @@ from ..._typing import Magnitude -from ._unit_handlers import format_compound_unit, BabelKwds, override_locale +from ._format_helpers import format_compound_unit, BabelKwds, formatter, override_locale if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT @@ -32,18 +37,47 @@ _EXP_PATTERN = re.compile(r"([0-9]\.?[0-9]*)e(-?)\+?0*([0-9]+)") -class RawFormatter: +class DefaultFormatter: + """Simple, localizable plain text formatter. + + A formatter is a class with methods to format into string each of the objects + that appear in pint (magnitude, unit, quantity, uncertainty, measurement) + """ + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: - return str(magnitude) + """Format scalar/array into string + given a string formatting specification and locale related arguments. + """ + with override_locale(mspec, babel_kwds.get("locale", None)) as format_number: + if isinstance(magnitude, ndarray) and magnitude.ndim > 0: + # Use custom ndarray text formatting--need to handle scalars differently + # since they don't respond to printoptions + with np.printoptions(formatter={"float_kind": format_number}): + mstr = format(magnitude).replace("\n", "") + else: + mstr = format_number(magnitude) + + return mstr def format_unit( self, unit: PlainUnit, uspec: str = "", **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. + """ - return " * ".join(k if v == 1 else f"{k} ** {v}" for k, v in units) + return formatter( + units, + as_ratio=True, + single_denominator=False, + product_fmt=" * ", + division_fmt=" / ", + power_fmt="{} ** {}", + parentheses_fmt=r"({})", + ) def format_quantity( self, @@ -51,6 +85,10 @@ def format_quantity( qspec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: + """Format a quantity (magnitude and unit) into string + given a string formatting specification and locale related arguments. + """ + registry = quantity._REGISTRY mspec, uspec = split_format( @@ -70,7 +108,11 @@ def format_uncertainty( unc_spec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: - return format(uncertainty, unc_spec) + """Format an uncertainty magnitude (nominal value and stdev) into string + given a string formatting specification and locale related arguments. + """ + + return format(uncertainty, unc_spec).replace("+/-", " +/- ") def format_measurement( self, @@ -78,6 +120,10 @@ def format_measurement( meas_spec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: + """Format an measurement (uncertainty and units) into string + given a string formatting specification and locale related arguments. + """ + registry = measurement._REGISTRY mspec, uspec = split_format( @@ -99,7 +145,9 @@ def format_measurement( ) -class DefaultFormatter: +class CompactFormatter: + """Simple, localizable plain text formatter without extra spaces.""" + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: @@ -123,9 +171,9 @@ def format_unit( units, as_ratio=True, single_denominator=False, - product_fmt=" * ", - division_fmt=" / ", - power_fmt="{} ** {}", + product_fmt="*", # TODO: Should this just be ''? + division_fmt="/", + power_fmt="{}**{}", parentheses_fmt=r"({})", ) @@ -142,6 +190,7 @@ def format_quantity( ) joint_fstring = "{} {}" + return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), @@ -154,7 +203,7 @@ def format_uncertainty( unc_spec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: - return format(uncertainty, unc_spec).replace("+/-", " +/- ") + return format(uncertainty, unc_spec).replace("+/-", "+/-") def format_measurement( self, @@ -183,7 +232,9 @@ def format_measurement( ) -class CompactFormatter: +class PrettyFormatter: + """Pretty printed localizable plain text formatter without extra spaces.""" + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: @@ -196,7 +247,13 @@ def format_magnitude( else: mstr = format_number(magnitude) - return mstr + m = _EXP_PATTERN.match(mstr) + + if m: + exp = int(m.group(2) + m.group(3)) + mstr = _EXP_PATTERN.sub(r"\1×10" + pretty_fmt_exponent(exp), mstr) + + return mstr def format_unit( self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] @@ -207,10 +264,11 @@ def format_unit( units, as_ratio=True, single_denominator=False, - product_fmt="*", # TODO: Should this just be ''? + product_fmt="·", division_fmt="/", - power_fmt="{}**{}", - parentheses_fmt=r"({})", + power_fmt="{}{}", + parentheses_fmt="({})", + exp_call=pretty_fmt_exponent, ) def format_quantity( @@ -239,7 +297,7 @@ def format_uncertainty( unc_spec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: - return format(uncertainty, unc_spec).replace("+/-", "+/-") + return format(uncertainty, unc_spec).replace("±", " ± ") def format_measurement( self, @@ -255,8 +313,7 @@ def format_measurement( registry.separate_format_defaults, ) - unc_spec = remove_custom_flags(meas_spec) - + unc_spec = meas_spec joint_fstring = "{} {}" return join_unc( @@ -268,42 +325,23 @@ def format_measurement( ) -class PrettyFormatter: +class RawFormatter: + """Very simple non-localizable plain text formatter. + + Ignores all pint custom string formatting specification. + """ + def format_magnitude( self, magnitude: Magnitude, mspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: - with override_locale(mspec, babel_kwds.get("locale", None)) as format_number: - if isinstance(magnitude, ndarray) and magnitude.ndim > 0: - # Use custom ndarray text formatting--need to handle scalars differently - # since they don't respond to printoptions - with np.printoptions(formatter={"float_kind": format_number}): - mstr = format(magnitude).replace("\n", "") - else: - mstr = format_number(magnitude) - - m = _EXP_PATTERN.match(mstr) - - if m: - exp = int(m.group(2) + m.group(3)) - mstr = _EXP_PATTERN.sub(r"\1×10" + _pretty_fmt_exponent(exp), mstr) - - return mstr + return str(magnitude) def format_unit( self, unit: PlainUnit, uspec: str = "", **babel_kwds: Unpack[BabelKwds] ) -> str: units = format_compound_unit(unit, uspec, **babel_kwds) - return formatter( - units, - as_ratio=True, - single_denominator=False, - product_fmt="·", - division_fmt="/", - power_fmt="{}{}", - parentheses_fmt="({})", - exp_call=_pretty_fmt_exponent, - ) + return " * ".join(k if v == 1 else f"{k} ** {v}" for k, v in units) def format_quantity( self, @@ -318,7 +356,6 @@ def format_quantity( ) joint_fstring = "{} {}" - return join_mu( joint_fstring, self.format_magnitude(quantity.magnitude, mspec, **babel_kwds), @@ -331,7 +368,7 @@ def format_uncertainty( unc_spec: str = "", **babel_kwds: Unpack[BabelKwds], ) -> str: - return format(uncertainty, unc_spec).replace("±", " ± ") + return format(uncertainty, unc_spec) def format_measurement( self, @@ -347,7 +384,8 @@ def format_measurement( registry.separate_format_defaults, ) - unc_spec = meas_spec + unc_spec = remove_custom_flags(meas_spec) + joint_fstring = "{} {}" return join_unc( diff --git a/pint/formatting.py b/pint/formatting.py index 2ade46b8c..0f47d0de9 100644 --- a/pint/formatting.py +++ b/pint/formatting.py @@ -22,15 +22,14 @@ siunitx_format_unit, # noqa _EXP_PATTERN, # noqa ) # noqa -from .delegates.formatter._helpers import ( - formatter, # noqa +from .delegates.formatter._spec_helpers import ( FORMATTER, # noqa _BASIC_TYPES, # noqa - _parse_spec, # noqa - __JOIN_REG_EXP, # noqa, + parse_spec as _parse_spec, # noqa + _JOIN_REG_EXP as __JOIN_REG_EXP, # noqa, _join, # noqa _PRETTY_EXPONENTS, # noqa - _pretty_fmt_exponent, # noqa + pretty_fmt_exponent as _pretty_fmt_exponent, # noqa extract_custom_flags, # noqa remove_custom_flags, # noqa split_format, # noqa diff --git a/pint/testsuite/test_formatter.py b/pint/testsuite/test_formatter.py index 5a51a0a2b..761414b75 100644 --- a/pint/testsuite/test_formatter.py +++ b/pint/testsuite/test_formatter.py @@ -1,6 +1,7 @@ import pytest from pint import formatting as fmt +import pint.delegates.formatter._format_helpers class TestFormatter: @@ -11,30 +12,54 @@ def test_join(self): assert fmt._join("{0}*{1}", "1 2 3".split()) == "1*2*3" def test_formatter(self): - assert fmt.formatter({}.items()) == "" - assert fmt.formatter(dict(meter=1).items()) == "meter" - assert fmt.formatter(dict(meter=-1).items()) == "1 / meter" - assert fmt.formatter(dict(meter=-1).items(), as_ratio=False) == "meter ** -1" + 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 ( - fmt.formatter(dict(meter=-1, second=-1).items(), as_ratio=False) + pint.delegates.formatter._format_helpers.formatter( + dict(meter=-1, second=-1).items(), as_ratio=False + ) == "meter ** -1 * second ** -1" ) - assert fmt.formatter(dict(meter=-1, second=-1).items()) == "1 / meter / second" assert ( - fmt.formatter(dict(meter=-1, second=-1).items(), single_denominator=True) + pint.delegates.formatter._format_helpers.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 + ) == "1 / (meter * second)" ) assert ( - fmt.formatter(dict(meter=-1, second=-2).items()) + pint.delegates.formatter._format_helpers.formatter( + dict(meter=-1, second=-2).items() + ) == "1 / meter / second ** 2" ) assert ( - fmt.formatter(dict(meter=-1, second=-2).items(), single_denominator=True) + pint.delegates.formatter._format_helpers.formatter( + dict(meter=-1, second=-2).items(), single_denominator=True + ) == "1 / (meter * second ** 2)" ) - def test_parse_spec(self): + def testparse_spec(self): assert fmt._parse_spec("") == "" assert fmt._parse_spec("") == "" with pytest.raises(ValueError):