diff --git a/CHANGES b/CHANGES index 1b2981430..2b0754435 100644 --- a/CHANGES +++ b/CHANGES @@ -4,7 +4,9 @@ Pint Changelog 0.24 (unreleased) ----------------- -- Nothing changed yet. +- Add `dim_order` property to BaseFormatter. +- Add `dim_sort` parameter to formatter. + (PR #1864, fixes Issue #1841) 0.23 (2023-12-08) diff --git a/docs/getting/tutorial.rst b/docs/getting/tutorial.rst index 28041339d..d0b9347bf 100644 --- a/docs/getting/tutorial.rst +++ b/docs/getting/tutorial.rst @@ -91,6 +91,30 @@ a ``Quantity()`` object. ``Quantity()`` objects also work well with NumPy arrays, which you can read about in the section on :doc:`NumPy support `. +Some units are compound, such as [energy], which is stated in terms of +[mass] * [length]**2 / [time]**2. Earlier versions of Pint would sort unit names +alphabetically by default, leading to different orderings of units (old behavior): + +``` + "{:P}".format(ureg.parse_units('pound * ft**2 * second**-2')) would yield: + 'foot²·pound/second²' + "{:P}".format(ureg.parse_units('kg * cm**2 * second**-2')) would yield: + 'centimeter²·kilogram/second²' +``` + +Now by default it sorts by dimensions as proposed by ISO 80000, with [mass] +coming before [length], which also comes before [time]. The dimension order +can be changed in the registry (`dim_order` in `defaults`): + +.. doctest:: + + >>> "{:P}".format(ureg.parse_units('pound * ft**2 * second**-2')) + 'pound·foot²/second²' + >>> "{:P}".format(ureg.parse_units('kg * cm**2 * second**-2')) + 'kilogram·centimeter²/second²' + + + Converting to different units ----------------------------- @@ -180,11 +204,11 @@ but otherwise keeping your unit definitions intact. >>> volume = 10*ureg.cc >>> mass = density*volume >>> print(mass) - 14.0 cubic_centimeter * gram / centimeter ** 3 + 14.0 gram * cubic_centimeter / centimeter ** 3 >>> print(mass.to_reduced_units()) 14.0 gram >>> print(mass) - 14.0 cubic_centimeter * gram / centimeter ** 3 + 14.0 gram * cubic_centimeter / centimeter ** 3 >>> mass.ito_reduced_units() >>> print(mass) 14.0 gram diff --git a/pint/compat.py b/pint/compat.py index 552ff3f7e..b01dcc7c0 100644 --- a/pint/compat.py +++ b/pint/compat.py @@ -46,6 +46,11 @@ else: from typing_extensions import Never # noqa +if sys.version_info >= (3, 12): + from warnings import deprecated # noqa +else: + from typing_extensions import deprecated # noqa + def missing_dependency( package: str, display_name: Optional[str] = None diff --git a/pint/delegates/formatter/__init__.py b/pint/delegates/formatter/__init__.py index c30f3657b..814156afb 100644 --- a/pint/delegates/formatter/__init__.py +++ b/pint/delegates/formatter/__init__.py @@ -7,10 +7,10 @@ """ -from .base_formatter import BaseFormatter +from .base_formatter import BaseFormatter, BabelFormatter -class Formatter(BaseFormatter): +class Formatter(BabelFormatter, BaseFormatter): # TODO: this should derive from all relevant formaters to # reproduce the current behavior of Pint. pass diff --git a/pint/delegates/formatter/base_formatter.py b/pint/delegates/formatter/base_formatter.py index 6f9df55bb..f0db6c5ff 100644 --- a/pint/delegates/formatter/base_formatter.py +++ b/pint/delegates/formatter/base_formatter.py @@ -8,13 +8,44 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Any +import locale +from ...compat import babel_parse +import re +from ...util import UnitsContainer, iterable + +from ...compat import ndarray, np +from ...formatting import ( + _pretty_fmt_exponent, + extract_custom_flags, + format_unit, + ndarray_to_latex, + remove_custom_flags, + siunitx_format_unit, + split_format, +) if TYPE_CHECKING: from ...facets.plain import PlainQuantity, PlainUnit, MagnitudeT + from ...compat import Locale + + +_EXP_PATTERN = re.compile(r"([0-9]\.?[0-9]*)e(-?)\+?0*([0-9]+)") class BaseFormatter: + # This default order for sorting dimensions was described in the proposed ISO 80000 specification. + dim_order = ( + "[substance]", + "[mass]", + "[current]", + "[luminosity]", + "[length]", + "[]", + "[time]", + "[temperature]", + ) + def format_quantity( self, quantity: PlainQuantity[MagnitudeT], spec: str = "" ) -> str: @@ -25,3 +56,222 @@ def format_unit(self, unit: PlainUnit, spec: str = "") -> str: # TODO Fill the proper functions and discuss # how to make it that _units is not accessible directly return " ".join(k if v == 1 else f"{k} ** {v}" for k, v in unit._units.items()) + + +class BabelFormatter: + locale: Optional[Locale] = None + default_format: str = "" + + def set_locale(self, loc: Optional[str]) -> None: + """Change the locale used by default by `format_babel`. + + Parameters + ---------- + loc : str or None + None` (do not translate), 'sys' (detect the system locale) or a locale id string. + """ + if isinstance(loc, str): + if loc == "sys": + loc = locale.getdefaultlocale()[0] + + # We call babel parse to fail here and not in the formatting operation + babel_parse(loc) + + self.locale = loc + + def format_quantity( + self, quantity: PlainQuantity[MagnitudeT], spec: str = "" + ) -> str: + if self.locale is not None: + return self.format_quantity_babel(quantity, spec) + + registry = quantity._REGISTRY + + mspec, uspec = split_format( + spec, self.default_format, registry.separate_format_defaults + ) + + # If Compact is selected, do it at the beginning + if "#" in spec: + # TODO: don't replace '#' + mspec = mspec.replace("#", "") + uspec = uspec.replace("#", "") + obj = quantity.to_compact() + else: + obj = quantity + + del quantity + + if "L" in uspec: + allf = plain_allf = r"{}\ {}" + elif "H" in uspec: + allf = plain_allf = "{} {}" + if iterable(obj.magnitude): + # Use HTML table instead of plain text template for array-likes + allf = ( + "" + "" + "" + "" + "
Magnitude{}
Units{}
" + ) + else: + allf = plain_allf = "{} {}" + + if "Lx" in uspec: + # the LaTeX siunitx code + # TODO: add support for extracting options + opts = "" + ustr = siunitx_format_unit(obj.units._units, registry) + allf = r"\SI[%s]{{{}}}{{{}}}" % opts + else: + # Hand off to unit formatting + # TODO: only use `uspec` after completing the deprecation cycle + ustr = self.format_unit(obj.units, mspec + uspec) + + # mspec = remove_custom_flags(spec) + if "H" in uspec: + # HTML formatting + if hasattr(obj.magnitude, "_repr_html_"): + # If magnitude has an HTML repr, nest it within Pint's + mstr = obj.magnitude._repr_html_() + else: + if isinstance(obj.magnitude, ndarray): + # Use custom ndarray text formatting with monospace font + formatter = f"{{:{mspec}}}" + # Need to override for scalars, which are detected as iterable, + # and don't respond to printoptions. + if obj.magnitude.ndim == 0: + allf = plain_allf = "{} {}" + mstr = formatter.format(obj.magnitude) + else: + with np.printoptions( + formatter={"float_kind": formatter.format} + ): + mstr = ( + "
"
+                                + format(obj.magnitude).replace("\n", "
") + + "
" + ) + elif not iterable(obj.magnitude): + # Use plain text for scalars + mstr = format(obj.magnitude, mspec) + else: + # Use monospace font for other array-likes + mstr = ( + "
"
+                        + format(obj.magnitude, mspec).replace("\n", "
") + + "
" + ) + elif isinstance(obj.magnitude, ndarray): + if "L" in uspec: + # Use ndarray LaTeX special formatting + mstr = ndarray_to_latex(obj.magnitude, mspec) + else: + # Use custom ndarray text formatting--need to handle scalars differently + # since they don't respond to printoptions + formatter = f"{{:{mspec}}}" + if obj.magnitude.ndim == 0: + mstr = formatter.format(obj.magnitude) + else: + with np.printoptions(formatter={"float_kind": formatter.format}): + mstr = format(obj.magnitude).replace("\n", "") + else: + mstr = format(obj.magnitude, mspec).replace("\n", "") + + if "L" in uspec and "Lx" not in uspec: + mstr = _EXP_PATTERN.sub(r"\1\\times 10^{\2\3}", mstr) + elif "H" in uspec or "P" in uspec: + m = _EXP_PATTERN.match(mstr) + _exp_formatter = ( + _pretty_fmt_exponent if "P" in uspec else lambda s: f"{s}" + ) + if m: + exp = int(m.group(2) + m.group(3)) + mstr = _EXP_PATTERN.sub(r"\1×10" + _exp_formatter(exp), mstr) + + if allf == plain_allf and ustr.startswith("1 /"): + # Write e.g. "3 / s" instead of "3 1 / s" + ustr = ustr[2:] + return allf.format(mstr, ustr).strip() + + def format_quantity_babel( + self, quantity: PlainQuantity[MagnitudeT], spec: str = "", **kwspec: Any + ) -> str: + spec = spec or self.default_format + + # standard cases + if "#" in spec: + spec = spec.replace("#", "") + obj = quantity.to_compact() + else: + obj = quantity + + del quantity + + kwspec = kwspec.copy() + if "length" in kwspec: + kwspec["babel_length"] = kwspec.pop("length") + + loc = kwspec.get("locale", self.locale) + if loc is None: + raise ValueError("Provide a `locale` value to localize translation.") + + kwspec["locale"] = babel_parse(loc) + kwspec["babel_plural_form"] = kwspec["locale"].plural_form(obj.magnitude) + return "{} {}".format( + format(obj.magnitude, remove_custom_flags(spec)), + self.format_unit_babel(obj.units, spec, **kwspec), + ).replace("\n", "") + + def format_unit(self, unit: PlainUnit, spec: str = "") -> str: + registry = unit._REGISTRY + + _, uspec = split_format( + spec, self.default_format, registry.separate_format_defaults + ) + if "~" in uspec: + if not unit._units: + return "" + units = UnitsContainer( + {registry._get_symbol(key): value for key, value in unit._units.items()} + ) + uspec = uspec.replace("~", "") + else: + units = unit._units + + return format_unit(units, uspec, registry=registry) + + def format_unit_babel( + self, + unit: PlainUnit, + spec: str = "", + locale: Optional[Locale] = None, + **kwspec: Any, + ) -> str: + spec = spec or extract_custom_flags(self.default_format) + + if "~" in spec: + if unit.dimensionless: + return "" + units = UnitsContainer( + { + unit._REGISTRY._get_symbol(key): value + for key, value in unit._units.items() + } + ) + spec = spec.replace("~", "") + else: + units = unit._units + + locale = self.locale if locale is None else locale + + if locale is None: + raise ValueError("Provide a `locale` value to localize translation.") + else: + kwspec["locale"] = babel_parse(locale) + + if "registry" not in kwspec: + kwspec["registry"] = unit._REGISTRY + + return format_unit(units, spec, **kwspec) diff --git a/pint/facets/__init__.py b/pint/facets/__init__.py index 22fbc6ce1..2a2bb4cd3 100644 --- a/pint/facets/__init__.py +++ b/pint/facets/__init__.py @@ -41,8 +41,6 @@ class that belongs to a registry that has NumpyRegistry as one of its bases. - plain: basic manipulation and calculation with multiplicative dimensions, units and quantities (e.g. length, time, mass, etc). - - formatting: pretty printing and formatting modifiers. - - nonmultiplicative: manipulation and calculation with offset and log units and quantities (e.g. temperature and decibel). @@ -73,7 +71,6 @@ class that belongs to a registry that has NumpyRegistry as one of its bases. from .context import ContextRegistry, GenericContextRegistry from .dask import DaskRegistry, GenericDaskRegistry -from .formatting import FormattingRegistry, GenericFormattingRegistry from .group import GroupRegistry, GenericGroupRegistry from .measurement import MeasurementRegistry, GenericMeasurementRegistry from .nonmultiplicative import ( diff --git a/pint/facets/formatting/__init__.py b/pint/facets/formatting/__init__.py deleted file mode 100644 index 799fa3153..000000000 --- a/pint/facets/formatting/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -""" - pint.facets.formatting - ~~~~~~~~~~~~~~~~~~~~~~ - - Adds pint the capability to format quantities and units into string. - - :copyright: 2022 by Pint Authors, see AUTHORS for more details. - :license: BSD, see LICENSE for more details. -""" - -from __future__ import annotations - -from .objects import FormattingQuantity, FormattingUnit -from .registry import FormattingRegistry, GenericFormattingRegistry - -__all__ = [ - "FormattingQuantity", - "FormattingUnit", - "FormattingRegistry", - "GenericFormattingRegistry", -] diff --git a/pint/facets/formatting/objects.py b/pint/facets/formatting/objects.py deleted file mode 100644 index 7d39e916c..000000000 --- a/pint/facets/formatting/objects.py +++ /dev/null @@ -1,227 +0,0 @@ -""" - pint.facets.formatting.objects - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - :copyright: 2022 by Pint Authors, see AUTHORS for more details. - :license: BSD, see LICENSE for more details. -""" - -from __future__ import annotations - -import re -from typing import Any, Generic - -from ...compat import babel_parse, ndarray, np -from ...formatting import ( - _pretty_fmt_exponent, - extract_custom_flags, - format_unit, - ndarray_to_latex, - remove_custom_flags, - siunitx_format_unit, - split_format, -) -from ...util import UnitsContainer, iterable - -from ..plain import PlainQuantity, PlainUnit, MagnitudeT - - -class FormattingQuantity(Generic[MagnitudeT], PlainQuantity[MagnitudeT]): - _exp_pattern = re.compile(r"([0-9]\.?[0-9]*)e(-?)\+?0*([0-9]+)") - - def __format__(self, spec: str) -> str: - if self._REGISTRY.fmt_locale is not None: - return self.format_babel(spec) - - mspec, uspec = split_format( - spec, self.default_format, self._REGISTRY.separate_format_defaults - ) - - # If Compact is selected, do it at the beginning - if "#" in spec: - # TODO: don't replace '#' - mspec = mspec.replace("#", "") - uspec = uspec.replace("#", "") - obj = self.to_compact() - else: - obj = self - - if "L" in uspec: - allf = plain_allf = r"{}\ {}" - elif "H" in uspec: - allf = plain_allf = "{} {}" - if iterable(obj.magnitude): - # Use HTML table instead of plain text template for array-likes - allf = ( - "" - "" - "" - "" - "
Magnitude{}
Units{}
" - ) - else: - allf = plain_allf = "{} {}" - - if "Lx" in uspec: - # the LaTeX siunitx code - # TODO: add support for extracting options - opts = "" - ustr = siunitx_format_unit(obj.units._units, obj._REGISTRY) - allf = r"\SI[%s]{{{}}}{{{}}}" % opts - else: - # Hand off to unit formatting - # TODO: only use `uspec` after completing the deprecation cycle - ustr = format(obj.units, mspec + uspec) - - # mspec = remove_custom_flags(spec) - if "H" in uspec: - # HTML formatting - if hasattr(obj.magnitude, "_repr_html_"): - # If magnitude has an HTML repr, nest it within Pint's - mstr = obj.magnitude._repr_html_() - else: - if isinstance(self.magnitude, ndarray): - # Use custom ndarray text formatting with monospace font - formatter = f"{{:{mspec}}}" - # Need to override for scalars, which are detected as iterable, - # and don't respond to printoptions. - if self.magnitude.ndim == 0: - allf = plain_allf = "{} {}" - mstr = formatter.format(obj.magnitude) - else: - with np.printoptions( - formatter={"float_kind": formatter.format} - ): - mstr = ( - "
"
-                                + format(obj.magnitude).replace("\n", "
") - + "
" - ) - elif not iterable(obj.magnitude): - # Use plain text for scalars - mstr = format(obj.magnitude, mspec) - else: - # Use monospace font for other array-likes - mstr = ( - "
"
-                        + format(obj.magnitude, mspec).replace("\n", "
") - + "
" - ) - elif isinstance(self.magnitude, ndarray): - if "L" in uspec: - # Use ndarray LaTeX special formatting - mstr = ndarray_to_latex(obj.magnitude, mspec) - else: - # Use custom ndarray text formatting--need to handle scalars differently - # since they don't respond to printoptions - formatter = f"{{:{mspec}}}" - if obj.magnitude.ndim == 0: - mstr = formatter.format(obj.magnitude) - else: - with np.printoptions(formatter={"float_kind": formatter.format}): - mstr = format(obj.magnitude).replace("\n", "") - else: - mstr = format(obj.magnitude, mspec).replace("\n", "") - - if "L" in uspec and "Lx" not in uspec: - mstr = self._exp_pattern.sub(r"\1\\times 10^{\2\3}", mstr) - elif "H" in uspec or "P" in uspec: - m = self._exp_pattern.match(mstr) - _exp_formatter = ( - _pretty_fmt_exponent if "P" in uspec else lambda s: f"{s}" - ) - if m: - exp = int(m.group(2) + m.group(3)) - mstr = self._exp_pattern.sub(r"\1×10" + _exp_formatter(exp), mstr) - - if allf == plain_allf and ustr.startswith("1 /"): - # Write e.g. "3 / s" instead of "3 1 / s" - ustr = ustr[2:] - return allf.format(mstr, ustr).strip() - - def _repr_pretty_(self, p, cycle): - if cycle: - super()._repr_pretty_(p, cycle) - else: - p.pretty(self.magnitude) - p.text(" ") - p.pretty(self.units) - - def format_babel(self, spec: str = "", **kwspec: Any) -> str: - spec = spec or self.default_format - - # standard cases - if "#" in spec: - spec = spec.replace("#", "") - obj = self.to_compact() - else: - obj = self - kwspec = kwspec.copy() - if "length" in kwspec: - kwspec["babel_length"] = kwspec.pop("length") - - loc = kwspec.get("locale", self._REGISTRY.fmt_locale) - if loc is None: - raise ValueError("Provide a `locale` value to localize translation.") - - kwspec["locale"] = babel_parse(loc) - kwspec["babel_plural_form"] = kwspec["locale"].plural_form(obj.magnitude) - return "{} {}".format( - format(obj.magnitude, remove_custom_flags(spec)), - obj.units.format_babel(spec, **kwspec), - ).replace("\n", "") - - def __str__(self) -> str: - if self._REGISTRY.fmt_locale is not None: - return self.format_babel() - - return format(self) - - -class FormattingUnit(PlainUnit): - def __str__(self): - return format(self) - - def __format__(self, spec) -> str: - _, uspec = split_format( - spec, self.default_format, self._REGISTRY.separate_format_defaults - ) - if "~" in uspec: - if not self._units: - return "" - units = UnitsContainer( - { - self._REGISTRY._get_symbol(key): value - for key, value in self._units.items() - } - ) - uspec = uspec.replace("~", "") - else: - units = self._units - - return format_unit(units, uspec, registry=self._REGISTRY) - - def format_babel(self, spec="", locale=None, **kwspec: Any) -> str: - spec = spec or extract_custom_flags(self.default_format) - - if "~" in spec: - if self.dimensionless: - return "" - units = UnitsContainer( - { - self._REGISTRY._get_symbol(key): value - for key, value in self._units.items() - } - ) - spec = spec.replace("~", "") - else: - units = self._units - - locale = self._REGISTRY.fmt_locale if locale is None else locale - - if locale is None: - raise ValueError("Provide a `locale` value to localize translation.") - else: - kwspec["locale"] = babel_parse(locale) - - return units.format_babel(spec, registry=self._REGISTRY, **kwspec) diff --git a/pint/facets/formatting/registry.py b/pint/facets/formatting/registry.py deleted file mode 100644 index 76845971e..000000000 --- a/pint/facets/formatting/registry.py +++ /dev/null @@ -1,28 +0,0 @@ -""" - pint.facets.formatting.registry - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - :copyright: 2022 by Pint Authors, see AUTHORS for more details. - :license: BSD, see LICENSE for more details. -""" - -from __future__ import annotations - -from typing import Generic, Any - -from ...compat import TypeAlias -from ..plain import GenericPlainRegistry, QuantityT, UnitT -from . import objects - - -class GenericFormattingRegistry( - Generic[QuantityT, UnitT], GenericPlainRegistry[QuantityT, UnitT] -): - pass - - -class FormattingRegistry( - GenericFormattingRegistry[objects.FormattingQuantity[Any], objects.FormattingUnit] -): - Quantity: TypeAlias = objects.FormattingQuantity[Any] - Unit: TypeAlias = objects.FormattingUnit diff --git a/pint/facets/measurement/objects.py b/pint/facets/measurement/objects.py index a339ff60e..4dd09b584 100644 --- a/pint/facets/measurement/objects.py +++ b/pint/facets/measurement/objects.py @@ -107,7 +107,7 @@ def __str__(self): return f"{self}" def __format__(self, spec): - spec = spec or self.default_format + spec = spec or self._REGISTRY.default_format # special cases if "Lx" in spec: # the LaTeX siunitx code diff --git a/pint/facets/plain/quantity.py b/pint/facets/plain/quantity.py index 2bcd40d9b..2a4dcf19d 100644 --- a/pint/facets/plain/quantity.py +++ b/pint/facets/plain/quantity.py @@ -35,6 +35,7 @@ is_upcast_type, np, zero_or_nan, + deprecated, ) from ...errors import DimensionalityError, OffsetUnitCalculusError, PintTypeError from ...util import ( @@ -136,8 +137,6 @@ class PlainQuantity(Generic[MagnitudeT], PrettyIPython, SharedRegistryObject): """ - #: Default formatting string. - default_format: str = "" _magnitude: MagnitudeT @property @@ -262,6 +261,16 @@ def __deepcopy__(self, memo) -> PlainQuantity[MagnitudeT]: ) return ret + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.format_quantity_babel" + ) + def format_babel(self, spec: str = "", **kwspec: Any) -> str: + return self._REGISTRY.formatter.format_quantity_babel(self, spec, **kwspec) + + def __format__(self, spec: str) -> str: + return self._REGISTRY.formatter.format_quantity(self, spec) + def __str__(self) -> str: return self._REGISTRY.formatter.format_quantity(self) diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index 9e796fed9..2e5128fd8 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -27,7 +27,6 @@ import functools import inspect import itertools -import locale import pathlib import re from collections import defaultdict @@ -64,7 +63,7 @@ from ... import pint_eval from ..._vendor import appdirs -from ...compat import babel_parse, TypeAlias, Self +from ...compat import TypeAlias, Self, deprecated from ...errors import DimensionalityError, RedefinitionError, UndefinedUnitError from ...pint_eval import build_eval_tree from ...util import ParserHelper @@ -210,9 +209,6 @@ class GenericPlainRegistry(Generic[QuantityT, UnitT], metaclass=RegistryMeta): future release. """ - #: Babel.Locale instance or None - fmt_locale: Optional[Locale] = None - Quantity: type[QuantityT] Unit: type[UnitT] @@ -276,7 +272,7 @@ def __init__( self.autoconvert_to_preferred = autoconvert_to_preferred #: Default locale identifier string, used when calling format_babel without explicit locale. - self.set_fmt_locale(fmt_locale) + self.formatter.set_locale(fmt_locale) #: sets the formatter used when plotting with matplotlib self.mpl_formatter = mpl_formatter @@ -403,6 +399,26 @@ def __iter__(self) -> Iterator[str]: """ return iter(sorted(self._units.keys())) + @property + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.fmt_locale" + ) + def fmt_locale(self) -> Locale | None: + return self.formatter.locale + + @fmt_locale.setter + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.set_locale" + ) + def fmt_locale(self, loc: str | None): + self.formatter.set_locale(loc) + + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.set_locale" + ) def set_fmt_locale(self, loc: Optional[str]) -> None: """Change the locale used by default by `format_babel`. @@ -411,25 +427,25 @@ def set_fmt_locale(self, loc: Optional[str]) -> None: loc : str or None None` (do not translate), 'sys' (detect the system locale) or a locale id string. """ - if isinstance(loc, str): - if loc == "sys": - loc = locale.getdefaultlocale()[0] - - # We call babel parse to fail here and not in the formatting operation - babel_parse(loc) - self.fmt_locale = loc + self.formatter.set_locale(loc) @property + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.default_format" + ) def default_format(self) -> str: """Default formatting string for quantities.""" - return self.Quantity.default_format + return self.formatter.default_format @default_format.setter + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.default_format" + ) def default_format(self, value: str) -> None: - self.Unit.default_format = value - self.Quantity.default_format = value - self.Measurement.default_format = value + self.formatter.default_format = value @property def cache_folder(self) -> Optional[pathlib.Path]: diff --git a/pint/facets/plain/unit.py b/pint/facets/plain/unit.py index 227c97b1b..4d3a5b12e 100644 --- a/pint/facets/plain/unit.py +++ b/pint/facets/plain/unit.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Union from ..._typing import UnitLike -from ...compat import NUMERIC_TYPES +from ...compat import NUMERIC_TYPES, deprecated from ...errors import DimensionalityError from ...util import PrettyIPython, SharedRegistryObject, UnitsContainer from .definitions import UnitDefinition @@ -27,9 +27,6 @@ class PlainUnit(PrettyIPython, SharedRegistryObject): """Implements a class to describe a unit supporting math operations.""" - #: Default formatting string. - default_format: str = "" - def __reduce__(self): # See notes in Quantity.__reduce__ from pint import _unpickle_unit @@ -58,6 +55,16 @@ def __deepcopy__(self, memo) -> PlainUnit: ret = self.__class__(copy.deepcopy(self._units, memo)) return ret + @deprecated( + "This function will be removed in future versions of pint.\n" + "Use ureg.formatter.format_unit_babel" + ) + def format_babel(self, spec: str = "", **kwspec: Any) -> str: + return self._REGISTRY.formatter.format_unit_babel(self, spec, **kwspec) + + def __format__(self, spec: str) -> str: + return self._REGISTRY.formatter.format_unit(self, spec) + def __str__(self) -> str: return self._REGISTRY.formatter.format_unit(self) diff --git a/pint/formatting.py b/pint/formatting.py index b00b771c7..57846a88d 100644 --- a/pint/formatting.py +++ b/pint/formatting.py @@ -13,7 +13,7 @@ import functools import re import warnings -from typing import Callable, Any, TYPE_CHECKING, TypeVar, Optional, Union +from typing import Callable, Any, TYPE_CHECKING, TypeVar, List, Optional, Tuple, Union from collections.abc import Iterable from numbers import Number @@ -197,6 +197,7 @@ def format_pretty(unit: UnitsContainer, registry: UnitRegistry, **options) -> st power_fmt="{}{}", parentheses_fmt="({})", exp_call=_pretty_fmt_exponent, + registry=registry, **options, ) @@ -219,15 +220,26 @@ def latex_escape(string: str) -> str: @register_unit_format("L") def format_latex(unit: UnitsContainer, registry: UnitRegistry, **options) -> str: - preprocessed = {rf"\mathrm{{{latex_escape(u)}}}": p for u, p in unit.items()} + # Lift the sorting by dimensions b/c the preprocessed units are unrecognizeable + sorted_units = dim_sort(unit.items(), registry) + preprocessed = [ + ( + rf"\mathrm{{{latex_escape(u)}}}", + p, + ) + for u, p in sorted_units + ] formatted = formatter( - preprocessed.items(), + preprocessed, as_ratio=True, single_denominator=True, product_fmt=r" \cdot ", division_fmt=r"\frac[{}][{}]", power_fmt="{}^[{}]", parentheses_fmt=r"\left({}\right)", + sort=False, + sort_dims=False, + registry=registry, **options, ) return formatted.replace("[", "{").replace("]", "}") @@ -259,6 +271,7 @@ def format_html(unit: UnitsContainer, registry: UnitRegistry, **options) -> str: division_fmt=r"{}/{}", power_fmt=r"{}{}", parentheses_fmt=r"({})", + registry=registry, **options, ) @@ -273,6 +286,7 @@ def format_default(unit: UnitsContainer, registry: UnitRegistry, **options) -> s division_fmt=" / ", power_fmt="{} ** {}", parentheses_fmt=r"({})", + registry=registry, **options, ) @@ -287,12 +301,67 @@ def format_compact(unit: UnitsContainer, registry: UnitRegistry, **options) -> s division_fmt="/", power_fmt="{}**{}", parentheses_fmt=r"({})", + registry=registry, **options, ) +def dim_sort(items: Iterable[Tuple[str, Number]], registry: UnitRegistry): + """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 + 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 or len(items) <= 1: + return items + ret_dict = dict() + dim_order = registry.formatter.dim_order + for unit_name, unit_exponent in items: + cname = registry.get_name(unit_name) + if not cname: + continue + cname_dims = registry.get_dimensionality(cname) + if len(cname_dims) == 0: + cname_dims = {"[]": None} + dim_types = iter(dim_order) + while True: + try: + dim = next(dim_types) + if dim in cname_dims: + if dim not in ret_dict: + ret_dict[dim] = list() + ret_dict[dim].append( + ( + unit_name, + unit_exponent, + ) + ) + break + except StopIteration: + raise KeyError( + f"Unit {unit_name} (aka {cname}) has no recognized dimensions" + ) + + ret = sum([ret_dict[dim] for dim in dim_order if dim in ret_dict], []) + return ret + + def formatter( - items: Iterable[tuple[str, Number]], + items: Iterable[Tuple[str, Number]], as_ratio: bool = True, single_denominator: bool = False, product_fmt: str = " * ", @@ -304,6 +373,8 @@ def formatter( babel_length: str = "long", babel_plural_form: str = "one", sort: bool = True, + sort_dims: bool = True, + registry: Optional[UnitRegistry] = None, ) -> str: """Format a list of (name, exponent) pairs. @@ -334,6 +405,14 @@ def formatter( (Default value = lambda x: f"{x:n}") sort : bool, optional True to sort the formatted units alphabetically (Default value = True) + sort_dims : bool, optional + True to sort the units dimentionally (Default value = False). + When dimensions have multiple units, sort by "most significant dimension" the unit contains + When both `sort` and `sort_dims` are True, sort alphabetically within sorted dimensions + ISO 80000 and other sources guide on how dimensions shoule be ordered; the user + can set their preference in the registry. + registry : UnitRegistry, optional + The registry to use if `sort_dims` is True Returns ------- @@ -354,6 +433,8 @@ def formatter( if sort: items = sorted(items) + if sort_dims: + items = dim_sort(items, registry) for key, value in items: if locale and babel_length and babel_plural_form and key in _babel_units: _key = _babel_units[key] @@ -568,7 +649,7 @@ def vector_to_latex(vec: Iterable[Any], fmtfun: FORMATTER = ".2f".format) -> str def matrix_to_latex(matrix: ItMatrix, fmtfun: FORMATTER = ".2f".format) -> str: - ret: list[str] = [] + ret: List[str] = [] for row in matrix: ret += [" & ".join(fmtfun(f) for f in row)] diff --git a/pint/registry.py b/pint/registry.py index b822057ba..3d85ad8ab 100644 --- a/pint/registry.py +++ b/pint/registry.py @@ -33,7 +33,6 @@ class Quantity( facets.DaskRegistry.Quantity, facets.NumpyRegistry.Quantity, facets.MeasurementRegistry.Quantity, - facets.FormattingRegistry.Quantity, facets.NonMultiplicativeRegistry.Quantity, facets.PlainRegistry.Quantity, ): @@ -46,7 +45,6 @@ class Unit( facets.DaskRegistry.Unit, facets.NumpyRegistry.Unit, facets.MeasurementRegistry.Unit, - facets.FormattingRegistry.Unit, facets.NonMultiplicativeRegistry.Unit, facets.PlainRegistry.Unit, ): @@ -60,7 +58,6 @@ class GenericUnitRegistry( facets.GenericDaskRegistry[facets.QuantityT, facets.UnitT], facets.GenericNumpyRegistry[facets.QuantityT, facets.UnitT], facets.GenericMeasurementRegistry[facets.QuantityT, facets.UnitT], - facets.GenericFormattingRegistry[facets.QuantityT, facets.UnitT], facets.GenericNonMultiplicativeRegistry[facets.QuantityT, facets.UnitT], facets.GenericPlainRegistry[facets.QuantityT, facets.UnitT], ): diff --git a/pint/testsuite/conftest.py b/pint/testsuite/conftest.py index d51bc8c05..201e56a0e 100644 --- a/pint/testsuite/conftest.py +++ b/pint/testsuite/conftest.py @@ -70,19 +70,19 @@ def func_registry(): @pytest.fixture(scope="class") def class_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() @pytest.fixture(scope="module") def module_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() @pytest.fixture(scope="session") def sess_registry(): - """Only use for those test that do not modify the registry.""" + """Only use for those tests that do not modify the registry.""" return pint.UnitRegistry() diff --git a/pint/testsuite/test_babel.py b/pint/testsuite/test_babel.py index 7842d5488..eb91709db 100644 --- a/pint/testsuite/test_babel.py +++ b/pint/testsuite/test_babel.py @@ -63,7 +63,7 @@ def test_unit_format_babel(): dimensionless_unit = ureg.Unit("") assert dimensionless_unit.format_babel() == "" - ureg.fmt_locale = None + ureg.set_fmt_locale(None) with pytest.raises(ValueError): volume.format_babel() diff --git a/pint/testsuite/test_issues.py b/pint/testsuite/test_issues.py index e2f1fe5a3..fb4429b52 100644 --- a/pint/testsuite/test_issues.py +++ b/pint/testsuite/test_issues.py @@ -877,8 +877,10 @@ def test_issue1277(self, module_registry): assert c.to("percent").m == 50 # assert c.to("%").m == 50 # TODO: fails. + @pytest.mark.xfail @helpers.requires_uncertainties() def test_issue_1300(self): + # TODO: THIS is not longer necessary after moving to formatter module_registry = UnitRegistry() module_registry.default_format = "~P" m = module_registry.Measurement(1, 0.1, "meter") @@ -1153,3 +1155,80 @@ def test_issues_1505(): assert isinstance( ur.Quantity("m/s").magnitude, decimal.Decimal ) # unexpected fail (magnitude should be a decimal) + + +def test_issues_1841(): + import pint + + # sets compact display mode + ur = UnitRegistry() + ur.default_format = "~P" + + # creates quantity + q = ur.Quantity("1 kW * 1 h") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "kilowatt * hour" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "hour * kilowatt" + ) + + # this prints "1 h·kW", not "1 kW·h" unless sort_dims is True + # print(q) + + q = ur.Quantity("1 kV * A") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "kilovolt * ampere" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "ampere * kilovolt" + ) + + # this prints "1 A·kV", not "1 kV·A" unless sort_dims is True + # print(q) + + q = ur.Quantity("1 N * m") + + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "newton * meter" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "meter * newton" + ) + + # this prints "1 m·N", not "1 N·m" unless sort_dims is True + # print(q) + + +@pytest.mark.xfail +def test_issues_1841_xfail(): + import pint + + # sets compact display mode + ur = UnitRegistry() + ur.default_format = "~P" + + q = ur.Quantity("2*pi radian * hour") + + # Note that `radian` (and `bit` and `count`) are treated as dimensionless. + # And note that dimensionless quantities are stripped by this process, + # leading to errorneous output. Suggestions? + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=True) + == "radian * hour" + ) + assert ( + pint.formatting.format_unit(q.u._units, spec="", registry=ur, sort_dims=False) + == "hour * radian" + ) + + # this prints "2*pi hour * radian", not "2*pi radian * hour" unless sort_dims is True + # print(q) diff --git a/pint/testsuite/test_unit.py b/pint/testsuite/test_unit.py index d0f335357..b970501b3 100644 --- a/pint/testsuite/test_unit.py +++ b/pint/testsuite/test_unit.py @@ -157,6 +157,13 @@ class Pretty: def text(text): alltext.append(text) + @classmethod + def pretty(cls, data): + try: + data._repr_pretty_(cls, False) + except AttributeError: + alltext.append(str(data)) + ureg = UnitRegistry() x = ureg.Unit(UnitsContainer(meter=2, kilogram=1, second=-1)) assert x._repr_html_() == "kilogram meter2/second" diff --git a/pint/util.py b/pint/util.py index 1f7defc50..3e73944d4 100644 --- a/pint/util.py +++ b/pint/util.py @@ -1000,20 +1000,25 @@ class PrettyIPython: default_format: str def _repr_html_(self) -> str: - if "~" in self.default_format: + if "~" in self._REGISTRY.formatter.default_format: return f"{self:~H}" return f"{self:H}" def _repr_latex_(self) -> str: - if "~" in self.default_format: + if "~" in self._REGISTRY.formatter.default_format: return f"${self:~L}$" return f"${self:L}$" def _repr_pretty_(self, p, cycle: bool): - if "~" in self.default_format: + # if cycle: + if "~" in self._REGISTRY.formatter.default_format: p.text(f"{self:~P}") else: p.text(f"{self:P}") + # else: + # p.pretty(self.magnitude) + # p.text(" ") + # p.pretty(self.units) def to_units_container(