Repository: hgrecco/pint

Issue:
numeric formatting overriding `default_format ` in `0.19`
I noticed that the unit string formatting changed slightly with the new `0.19` release

specifically, when setting a custom `default_format` on the `UnitRegistry` the setting is now lost when printing with additional numeric formatting options (e.g. `.2f`) (see the code below)

is this intended or is there a different way to keep the default unit formatting style?

### 0.18
```python
import pint
print(f"{pint.__version__=}")
#> pint.__version__='0.18'
ureg = pint.UnitRegistry()
q = ureg.Quantity("1.2345 m")

print(f"{q}")
#> 1.2345 meter
print(f"{q:.2f}")
#> 1.23 meter
print(f"{q:~.2f}")
#> 1.23 m

ureg.default_format = "~"

print(f"{q}")
#> 1.2345 m
print(f"{q:.2f}")
#> 1.23 m <- keeps the unit formatting options
print(f"{q:~.2f}")
#> 1.23 m
```

### 0.19
```python
import pint
print(f"{pint.__version__=}")
#> pint.__version__='0.19'
ureg = pint.UnitRegistry()
q = ureg.Quantity("1.2345 m")

print(f"{q}")
#> 1.2345 meter
print(f"{q:.2f}")
#> 1.23 meter
print(f"{q:~.2f}")
#> 1.23 m

ureg.default_format = "~"

print(f"{q}")
#> 1.2345 m
print(f"{q:.2f}")
#> 1.23 meter <- ignores the unit formatting options
print(f"{q:~.2f}")
#> 1.23 m
```

Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index c04aea8..30bf597 100644
--- a/CHANGES
+++ b/CHANGES
@@ -4,7 +4,7 @@ Pint Changelog
 0.20 (unreleased)
 -----------------
 
-- Nothing changed yet.
+- Add the ``separate_format_defaults`` registry setting (Issue #1501, PR #1503)
 
 
 0.19.1 (2022-04-06)
diff --git a/docs/formatting.rst b/docs/formatting.rst
index a261147..3ed3540 100644
--- a/docs/formatting.rst
+++ b/docs/formatting.rst
@@ -50,7 +50,8 @@ In case the format is omitted, the corresponding value in the object's
    In the future, the magnitude and unit format spec will be evaluated
    independently, such that with a global default of
    ``ureg.default_format = ".3f"`` and ``f"{q:P}`` the format that
-   will be used is ``".3fP"``.
+   will be used is ``".3fP"``. This behavior can be opted into by
+   setting :py:attr:`UnitRegistry.separate_format_defaults` to :py:obj:`True`.
 
 If both are not set, the global default of ``"D"`` and the magnitude's default
 format are used instead.
diff --git a/pint/formatting.py b/pint/formatting.py
index 5a458db..a4ddfb0 100644
--- a/pint/formatting.py
+++ b/pint/formatting.py
@@ -9,6 +9,7 @@
 """
 
 import re
+import warnings
 from typing import Callable, Dict
 
 from .babel_names import _babel_lengths, _babel_units
@@ -474,6 +475,46 @@ def remove_custom_flags(spec):
     return spec
 
 
+def split_format(spec, default, separate_format_defaults=True):
+    mspec = remove_custom_flags(spec)
+    uspec = extract_custom_flags(spec)
+
+    default_mspec = remove_custom_flags(default)
+    default_uspec = extract_custom_flags(default)
+
+    if separate_format_defaults in (False, None):
+        # should we warn always or only if there was no explicit choice?
+        # Given that we want to eventually remove the flag again, I'd say yes?
+        if spec and separate_format_defaults is None:
+            if not uspec and default_uspec:
+                warnings.warn(
+                    (
+                        "The given format spec does not contain a unit formatter."
+                        " Falling back to the builtin defaults, but in the future"
+                        " the unit formatter specified in the `default_format`"
+                        " attribute will be used instead."
+                    ),
+                    DeprecationWarning,
+                )
+            if not mspec and default_mspec:
+                warnings.warn(
+                    (
+                        "The given format spec does not contain a magnitude formatter."
+                        " Falling back to the builtin defaults, but in the future"
+                        " the magnitude formatter specified in the `default_format`"
+                        " attribute will be used instead."
+                    ),
+                    DeprecationWarning,
+                )
+        elif not spec:
+            mspec, uspec = default_mspec, default_uspec
+    else:
+        mspec = mspec if mspec else default_mspec
+        uspec = uspec if uspec else default_uspec
+
+    return mspec, uspec
+
+
 def vector_to_latex(vec, fmtfun=lambda x: format(x, ".2f")):
     return matrix_to_latex([vec], fmtfun)
 
diff --git a/pint/quantity.py b/pint/quantity.py
index f8b8e1f..1207b94 100644
--- a/pint/quantity.py
+++ b/pint/quantity.py
@@ -61,10 +61,10 @@ from .errors import (
 )
 from .formatting import (
     _pretty_fmt_exponent,
-    extract_custom_flags,
     ndarray_to_latex,
     remove_custom_flags,
     siunitx_format_unit,
+    split_format,
 )
 from .numpy_func import (
     HANDLED_UFUNCS,
@@ -345,34 +345,9 @@ class Quantity(PrettyIPython, SharedRegistryObject, Generic[_MagnitudeType]):
         if self._REGISTRY.fmt_locale is not None:
             return self.format_babel(spec)
 
-        mspec = remove_custom_flags(spec)
-        uspec = extract_custom_flags(spec)
-
-        default_mspec = remove_custom_flags(self.default_format)
-        default_uspec = extract_custom_flags(self.default_format)
-        if spec:
-            if not uspec and default_uspec:
-                warnings.warn(
-                    (
-                        "The given format spec does not contain a unit formatter."
-                        " Falling back to the builtin defaults, but in the future"
-                        " the unit formatter specified in the `default_format`"
-                        " attribute will be used instead."
-                    ),
-                    DeprecationWarning,
-                )
-            if not mspec and default_mspec:
-                warnings.warn(
-                    (
-                        "The given format spec does not contain a magnitude formatter."
-                        " Falling back to the builtin defaults, but in the future"
-                        " the magnitude formatter specified in the `default_format`"
-                        " attribute will be used instead."
-                    ),
-                    DeprecationWarning,
-                )
-        else:
-            mspec, uspec = default_mspec, default_uspec
+        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:
diff --git a/pint/registry.py b/pint/registry.py
index d9b095b..3c33e85 100644
--- a/pint/registry.py
+++ b/pint/registry.py
@@ -241,6 +241,10 @@ class BaseRegistry(metaclass=RegistryMeta):
     cache_folder : str or pathlib.Path or None, optional
         Specify the folder in which cache files are saved and loaded from.
         If None, the cache is disabled. (default)
+    separate_format_defaults : bool, optional
+        Separate the default format into magnitude and unit formats as soon as
+        possible. The deprecated default is not to separate. This will change in a
+        future release.
     """
 
     #: Babel.Locale instance or None
@@ -260,6 +264,7 @@ class BaseRegistry(metaclass=RegistryMeta):
         non_int_type: NON_INT_TYPE = float,
         case_sensitive: bool = True,
         cache_folder: Union[str, pathlib.Path, None] = None,
+        separate_format_defaults: Optional[bool] = None,
     ):
         #: Map context prefix to (loader function, parser function, single_line)
         #: type: Dict[str, Tuple[Callable[[Any], None]], Any]
@@ -278,6 +283,9 @@ class BaseRegistry(metaclass=RegistryMeta):
         self.force_ndarray_like = force_ndarray_like
         self.preprocessors = preprocessors or []
 
+        #: mode used to fill in the format defaults
+        self.separate_format_defaults = separate_format_defaults
+
         #: Action to take in case a unit is redefined. 'warn', 'raise', 'ignore'
         self._on_redefinition = on_redefinition
 
diff --git a/pint/unit.py b/pint/unit.py
index eca1718..6301132 100644
--- a/pint/unit.py
+++ b/pint/unit.py
@@ -20,7 +20,7 @@ from ._typing import UnitLike
 from .compat import NUMERIC_TYPES, babel_parse, is_upcast_type
 from .definitions import UnitDefinition
 from .errors import DimensionalityError
-from .formatting import extract_custom_flags, format_unit
+from .formatting import extract_custom_flags, format_unit, split_format
 from .util import PrettyIPython, SharedRegistryObject, UnitsContainer
 
 if TYPE_CHECKING:
@@ -80,8 +80,10 @@ class Unit(PrettyIPython, SharedRegistryObject):
         return "<Unit('{}')>".format(self._units)
 
     def __format__(self, spec) -> str:
-        spec = extract_custom_flags(spec or self.default_format)
-        if "~" in spec:
+        _, uspec = split_format(
+            spec, self.default_format, self._REGISTRY.separate_format_defaults
+        )
+        if "~" in uspec:
             if not self._units:
                 return ""
             units = UnitsContainer(
@@ -90,11 +92,11 @@ class Unit(PrettyIPython, SharedRegistryObject):
                     for key, value in self._units.items()
                 )
             )
-            spec = spec.replace("~", "")
+            uspec = uspec.replace("~", "")
         else:
             units = self._units
 
-        return format_unit(units, spec, registry=self._REGISTRY)
+        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)


Test patch:
diff --git a/pint/testsuite/__init__.py b/pint/testsuite/__init__.py
index 35b6c89..8c0cd09 100644
--- a/pint/testsuite/__init__.py
+++ b/pint/testsuite/__init__.py
@@ -2,6 +2,8 @@ import doctest
 import math
 import os
 import unittest
+import warnings
+from contextlib import contextmanager
 
 from pint import UnitRegistry
 from pint.testsuite.helpers import PintOutputChecker
@@ -23,6 +25,14 @@ class QuantityTestCase:
         cls.U_ = None
 
 
+@contextmanager
+def assert_no_warnings():
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+
+        yield
+
+
 def testsuite():
     """A testsuite that has all the pint tests."""
     suite = unittest.TestLoader().discover(os.path.dirname(__file__))
diff --git a/pint/testsuite/test_formatting.py b/pint/testsuite/test_formatting.py
new file mode 100644
index 0000000..d287948
--- /dev/null
+++ b/pint/testsuite/test_formatting.py
@@ -0,0 +1,54 @@
+import pytest
+
+import pint.formatting as fmt
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning:pint*")
+@pytest.mark.parametrize(
+    ["format", "default", "flag", "expected"],
+    (
+        pytest.param(".02fD", ".3fP", True, (".02f", "D"), id="both-both-separate"),
+        pytest.param(".02fD", ".3fP", False, (".02f", "D"), id="both-both-combine"),
+        pytest.param(".02fD", ".3fP", None, (".02f", "D"), id="both-both-default"),
+        pytest.param("D", ".3fP", True, (".3f", "D"), id="unit-both-separate"),
+        pytest.param("D", ".3fP", False, ("", "D"), id="unit-both-combine"),
+        pytest.param("D", ".3fP", None, ("", "D"), id="unit-both-default"),
+        pytest.param(".02f", ".3fP", True, (".02f", "P"), id="magnitude-both-separate"),
+        pytest.param(".02f", ".3fP", False, (".02f", ""), id="magnitude-both-combine"),
+        pytest.param(".02f", ".3fP", None, (".02f", ""), id="magnitude-both-default"),
+        pytest.param("D", "P", True, ("", "D"), id="unit-unit-separate"),
+        pytest.param("D", "P", False, ("", "D"), id="unit-unit-combine"),
+        pytest.param("D", "P", None, ("", "D"), id="unit-unit-default"),
+        pytest.param(
+            ".02f", ".3f", True, (".02f", ""), id="magnitude-magnitude-separate"
+        ),
+        pytest.param(
+            ".02f", ".3f", False, (".02f", ""), id="magnitude-magnitude-combine"
+        ),
+        pytest.param(
+            ".02f", ".3f", None, (".02f", ""), id="magnitude-magnitude-default"
+        ),
+        pytest.param("D", ".3f", True, (".3f", "D"), id="unit-magnitude-separate"),
+        pytest.param("D", ".3f", False, ("", "D"), id="unit-magnitude-combine"),
+        pytest.param("D", ".3f", None, ("", "D"), id="unit-magnitude-default"),
+        pytest.param(".02f", "P", True, (".02f", "P"), id="magnitude-unit-separate"),
+        pytest.param(".02f", "P", False, (".02f", ""), id="magnitude-unit-combine"),
+        pytest.param(".02f", "P", None, (".02f", ""), id="magnitude-unit-default"),
+        pytest.param("", ".3fP", True, (".3f", "P"), id="none-both-separate"),
+        pytest.param("", ".3fP", False, (".3f", "P"), id="none-both-combine"),
+        pytest.param("", ".3fP", None, (".3f", "P"), id="none-both-default"),
+        pytest.param("", "P", True, ("", "P"), id="none-unit-separate"),
+        pytest.param("", "P", False, ("", "P"), id="none-unit-combine"),
+        pytest.param("", "P", None, ("", "P"), id="none-unit-default"),
+        pytest.param("", ".3f", True, (".3f", ""), id="none-magnitude-separate"),
+        pytest.param("", ".3f", False, (".3f", ""), id="none-magnitude-combine"),
+        pytest.param("", ".3f", None, (".3f", ""), id="none-magnitude-default"),
+        pytest.param("", "", True, ("", ""), id="none-none-separate"),
+        pytest.param("", "", False, ("", ""), id="none-none-combine"),
+        pytest.param("", "", None, ("", ""), id="none-none-default"),
+    ),
+)
+def test_split_format(format, default, flag, expected):
+    result = fmt.split_format(format, default, flag)
+
+    assert result == expected
diff --git a/pint/testsuite/test_quantity.py b/pint/testsuite/test_quantity.py
index f4f6806..196d0c3 100644
--- a/pint/testsuite/test_quantity.py
+++ b/pint/testsuite/test_quantity.py
@@ -17,7 +17,7 @@ from pint import (
     get_application_registry,
 )
 from pint.compat import np
-from pint.testsuite import QuantityTestCase, helpers
+from pint.testsuite import QuantityTestCase, assert_no_warnings, helpers
 from pint.unit import UnitsContainer
 
 
@@ -272,6 +272,10 @@ class TestQuantity(QuantityTestCase):
         with pytest.warns(DeprecationWarning):
             assert f"{x:d}" == "4 meter ** 2"
 
+        ureg.separate_format_defaults = True
+        with assert_no_warnings():
+            assert f"{x:d}" == "4 m ** 2"
+
     def test_formatting_override_default_magnitude(self):
         ureg = UnitRegistry()
         ureg.default_format = ".2f"
@@ -281,6 +285,10 @@ class TestQuantity(QuantityTestCase):
         with pytest.warns(DeprecationWarning):
             assert f"{x:D}" == "4 meter ** 2"
 
+        ureg.separate_format_defaults = True
+        with assert_no_warnings():
+            assert f"{x:D}" == "4.00 meter ** 2"
+
     def test_exponent_formatting(self):
         ureg = UnitRegistry()
         x = ureg.Quantity(1e20, "meter")
diff --git a/pint/testsuite/test_unit.py b/pint/testsuite/test_unit.py
index 992551c..e6ce581 100644
--- a/pint/testsuite/test_unit.py
+++ b/pint/testsuite/test_unit.py
@@ -15,7 +15,7 @@ from pint import (
 )
 from pint.compat import np
 from pint.registry import LazyRegistry, UnitRegistry
-from pint.testsuite import QuantityTestCase, helpers
+from pint.testsuite import QuantityTestCase, assert_no_warnings, helpers
 from pint.util import ParserHelper, UnitsContainer
 
 
@@ -80,6 +80,18 @@ class TestUnit(QuantityTestCase):
                 ureg.default_format = spec
                 assert f"{x}" == result, f"Failed for {spec}, {result}"
 
+    def test_unit_formatting_defaults_warning(self):
+        ureg = UnitRegistry()
+        ureg.default_format = "~P"
+        x = ureg.Unit("m / s ** 2")
+
+        with pytest.warns(DeprecationWarning):
+            assert f"{x:.2f}" == "meter / second ** 2"
+
+        ureg.separate_format_defaults = True
+        with assert_no_warnings():
+            assert f"{x:.2f}" == "m/s²"
+
     def test_unit_formatting_snake_case(self, subtests):
         # Test that snake_case units are escaped where appropriate
         ureg = UnitRegistry()